diff --git a/.env.example b/.env.example index bf9f5a3..3f98ed9 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net # # Unset, it falls back to MEMBY_EMBY_URL and behaves exactly as before. Confirm the port your # Emby listens on over plain HTTP on the LAN before uncommenting. -#MEMBY_EMBY_MEDIA_URL=http://10.0.0.2:8096 +MEMBY_EMBY_MEDIA_URL=http://10.0.0.2:8096 # Postgres password for the memby role. Generate one, e.g. # openssl rand -base64 24 @@ -140,6 +140,11 @@ MEMBY_TRACEARR_FULL_INTERVAL=24h # It is the only thing in the gateway that reads media bytes, which is why it is off by # default. With ffmpeg absent it still runs, writing markers from where viewers actually # stopped — which on a well-watched show is the better signal anyway. +# +# Where those bytes are read FROM is MEMBY_EMBY_MEDIA_URL, near the top of this file beside +# the other Emby addresses. It is the setting that decides whether a scan crosses the LAN to +# the HTPC or goes out to the DDNS name and back through the reverse proxy, so it is worth +# setting before turning this on. MEMBY_CREDITS_ENABLED=true # Leave blank to find ffmpeg on the path, which is where the image puts it. MEMBY_CREDITS_FFMPEG= diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e421bf..c7f4cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.66 - 2026-08-15 +- Improved: Design of skip end credits display. + +## 0.2.65 - 2026-08-15 +- Added: Server now detects end credits more accurately. +- Fixed: Security improvements to API endpoints. + ## 0.2.64 — 2026-08-14 - Bug fixes & general improvements. diff --git a/admin-ui/dist/assets/index-Ax7UuMTz.js b/admin-ui/dist/assets/index-Ax7UuMTz.js deleted file mode 100644 index 7112bb4..0000000 --- a/admin-ui/dist/assets/index-Ax7UuMTz.js +++ /dev/null @@ -1,12 +0,0 @@ -import{r as j,a as Ns,u as De,L as ee,b as qe,m as $e,O as Ss,N as Cs,c as Re,B as Ms,R as Rs,d as U,e as As}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const c of r)if(c.type==="childList")for(const t of c.addedNodes)t.tagName==="LINK"&&t.rel==="modulepreload"&&i(t)}).observe(document,{childList:!0,subtree:!0});function o(r){const c={};return r.integrity&&(c.integrity=r.integrity),r.referrerPolicy&&(c.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?c.credentials="include":r.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function i(r){if(r.ep)return;r.ep=!0;const c=o(r);fetch(r.href,c)}})();var ls={exports:{}},Ae={};/** - * @license React - * react-jsx-runtime.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Es=j,Is=Symbol.for("react.element"),$s=Symbol.for("react.fragment"),Ts=Object.prototype.hasOwnProperty,Ls=Es.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Ds={key:!0,ref:!0,__self:!0,__source:!0};function os(s,n,o){var i,r={},c=null,t=null;o!==void 0&&(c=""+o),n.key!==void 0&&(c=""+n.key),n.ref!==void 0&&(t=n.ref);for(i in n)Ts.call(n,i)&&!Ds.hasOwnProperty(i)&&(r[i]=n[i]);if(s&&s.defaultProps)for(i in n=s.defaultProps,n)r[i]===void 0&&(r[i]=n[i]);return{$$typeof:Is,type:s,key:c,ref:t,props:r,_owner:Ls.current}}Ae.Fragment=$s;Ae.jsx=os;Ae.jsxs=os;ls.exports=Ae;var e=ls.exports,cs,Oe=Ns;cs=Oe.createRoot,Oe.hydrateRoot;const ds={overview:"M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",library:"M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4",people:"M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2",person:"M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",tv:"M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5",sliders:"M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8",pulse:"M3 12h3.5L9 19l5-14 2.5 7H21",chart:"M4 19V9m5 10V5m5 14v-7m5 7V3",chip:"M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22",database:"M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6",download:"M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15",upload:"M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",sync:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",search:"M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",star:"m12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z",sparkle:"m10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z",bell:"M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0",shield:"m12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6",wrench:"m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",play:"M8 5.2v13.6L19 12 8 5.2ZM4 5v14",list:"M4 7h16M4 12h16M4 17h10",inbox:"M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",history:"M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8",check:"m5 12.5 4.5 4.5L19 7.5",alert:"M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z",power:"M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0",key:"M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z",captions:"M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",journey:"M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",plug:"M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3",calendar:"M4 6h16v15H4zM8 3v5M16 3v5M4 11h16",trash:"M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5",plus:"M12 5v14M5 12h14",close:"M6 6l12 12M18 6 6 18",caret:"m6 9 6 6 6-6",external:"M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10",refresh:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",filter:"M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z",menu:"M4 7h16M4 12h16M4 17h16",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z",logout:"M15 17l5-5-5-5M20 12H9M12 4H5v16h7"};function ne({name:s,className:n}){const o=ds[s];return o?e.jsx("svg",{className:n??"ico",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",preserveAspectRatio:"xMidYMid meet","aria-hidden":"true",children:e.jsx("path",{d:o})}):null}function Ee({name:s,tone:n}){return ds[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(ne,{name:s})}):null}const ve=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],qs=ve.flatMap(s=>s.items),Fs=ve.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Be extends Error{constructor(n,o){super(n),this.status=o,this.name="ApiError"}}const Ps=5*60*1e3;let hs=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{hs=Date.now()},{passive:!0});const Os=()=>Date.now()-hs({}));throw new Be(i.error??`Request failed (${o.status})`,o.status)}if(o.status!==204)return await o.json()}function Se(s){const n=new URLSearchParams;for(const[i,r]of Object.entries(s))r==null||r===""||r===!1||n.set(i,String(r));const o=n.toString();return o?`?${o}`:""}const F={get:s=>Ce(s),post:(s,n)=>Ce(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Ce(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Ce(s,{method:"DELETE"})},Vs=3e4,us=j.createContext(null);function Us({children:s}){var p;const[n,o]=j.useState(),[i,r]=j.useState(!0),[c,t]=j.useState(""),[l,d]=j.useState(""),[a,v]=j.useState(!0),y=j.useRef(0),u=j.useCallback(async()=>{const b=++y.current;try{const x=await F.get("/admin/api/status");if(b!==y.current)return;o(x),r(!0),d("")}catch(x){if(b!==y.current)return;r(!1),d(x instanceof Error?x.message:String(x))}finally{b===y.current&&(t(new Date().toISOString()),v(!1))}},[]),m=j.useCallback(async b=>{var x;await F.post("/admin/api/maintenance",{enabled:b,message:((x=n==null?void 0:n.maintenance)==null?void 0:x.message)??""}),await u()},[u,(p=n==null?void 0:n.maintenance)==null?void 0:p.message]);j.useEffect(()=>{u();let b;const x=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void u(),Vs)},w=()=>{x(),document.hidden||u()};return x(),document.addEventListener("visibilitychange",w),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",w)}},[u]);const g=j.useMemo(()=>({status:n,version:(n==null?void 0:n.serverVersion)??"",online:i,checkedAt:c,error:l,loading:a,reload:u,setMaintenance:m}),[n,i,c,l,a,u,m]);return e.jsx(us.Provider,{value:g,children:s})}function ie(){const s=j.useContext(us);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Ws(s,n){const o=s.label.toLowerCase(),i=s.group.toLowerCase();return o.startsWith(n)?4:o.includes(n)?3:i.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function _s(){const s=De(),{status:n}=ie(),[o,i]=j.useState(!1),[r,c]=j.useState(""),[t,l]=j.useState(0),d=j.useRef(null),a=j.useRef(null),v=j.useMemo(()=>{const u=r.trim().toLowerCase();return[...Fs,...((n==null?void 0:n.requestUsers)??[]).map(g=>({id:`user-${g.id}`,path:`/admin/accounts/${encodeURIComponent(g.id)}`,label:g.username||"Unnamed user",title:`User: ${g.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(g=>({id:`device-${g.deviceId}`,path:`/admin/devices/${encodeURIComponent(g.deviceId)}`,label:g.deviceName||"Unnamed device",title:`Device: ${g.deviceName||"Unnamed device"}`,intro:`${g.username||"Unknown user"} · ${g.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((g,p)=>({item:g,rank:u?Ws(g,u):1,index:p})).filter(g=>g.rank>0).sort((g,p)=>p.rank-g.rank||g.index-p.index).map(({item:g,rank:p})=>({item:g,rank:p}))},[r]);j.useEffect(()=>l(0),[r]),j.useEffect(()=>{const u=m=>{var g;(g=d.current)!=null&&g.contains(m.target)||i(!1)};return document.addEventListener("pointerdown",u),()=>document.removeEventListener("pointerdown",u)},[]),j.useEffect(()=>{const u=m=>{var p,b;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const g=document.activeElement;g&&(g.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(g.tagName))||(m.preventDefault(),(p=a.current)==null||p.focus(),(b=a.current)==null||b.select())};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[]);const y=u=>{var m;i(!1),c(""),(m=a.current)==null||m.blur(),s(u)};return e.jsxs("div",{className:"omni",ref:d,"data-open":o||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(ne,{name:"search"}),e.jsx("input",{ref:a,type:"search",value:r,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":o,onFocus:()=>i(!0),onChange:u=>{c(u.target.value),i(!0)},onKeyDown:u=>{var m;if(u.key==="Escape")c(""),i(!1),(m=a.current)==null||m.blur();else if(u.key==="ArrowDown")u.preventDefault(),l(g=>Math.min(g+1,v.length-1));else if(u.key==="ArrowUp")u.preventDefault(),l(g=>Math.max(g-1,0));else if(u.key==="Enter"){const g=v[t];if(!g)return;u.preventDefault(),y(g.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),o?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((u,m)=>e.jsxs("a",{className:m===t?"omni-item on":"omni-item",href:u.item.path,role:"option","aria-selected":m===t,onPointerEnter:()=>l(m),onClick:g=>{g.preventDefault(),y(u.item.path)},children:[u.item.icon?e.jsx(ne,{name:u.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:u.item.label}),e.jsx("small",{children:u.item.intro})]}),e.jsx("span",{className:"omni-group",children:u.item.group})]},u.item.id))}):null]})}const k=s=>(s??0).toLocaleString(),O=s=>s?new Date(s).toLocaleString():"—",zs=s=>s?new Date(s).toLocaleTimeString():"—";function ke(s){if(!s)return"0s";if(s<1e3)return`${Math.round(s)}ms`;const n=Math.round(s/1e3);if(n<60)return`${n}s`;const o=Math.floor(n/60);return o<60?`${o}m ${n%60}s`:`${Math.floor(o/60)}h ${o%60}m`}function Hs(s){if(!s||s<=0)return"on request only";if(s<60)return`every ${s}s`;const n=Math.round(s/60);if(n<60)return`every ${n} min`;const o=Math.round(n/60);return o<48?o===1?"hourly":`every ${o} hours`:`every ${Math.round(o/24)} days`}function fe(s){const n=["B","KB","MB","GB"];let o=Number(s??0),i=0;for(;o>=1024&&iString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Te=s=>`${Math.round((s??0)*100)}%`;function be(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const o=Math.floor(n/1e3);if(o<45)return"just now";const i=Math.floor(o/60);if(i<60)return`${i} min ago`;const r=Math.floor(i/60);if(r<24)return`${r}h ago`;const c=Math.floor(r/24);return c<30?`${c}d ago`:new Date(s).toLocaleDateString()}const ps=15*60*1e3,Ks=3*60*60*1e3,Ne=s=>!!s&&Date.now()-new Date(s).getTime(){var x;try{const w=await F.get(`/admin/api/notifications?limit=${Ue}`);o(w.events),r(w.unread),t(w.types),y.current=Math.max(y.current,((x=w.events[0])==null?void 0:x.id)??0),v("")}catch(w){v(w instanceof Error?w.message:String(w))}},[]),m=j.useCallback(x=>{y.current=Math.max(y.current,x.id),o(w=>w.some(h=>h.id===x.id)?w:[x,...w].sort((h,C)=>C.id-h.id).slice(0,Ue)),x.readAt||r(w=>w+1),t(w=>w.some(h=>h.type===x.type)?w.map(h=>h.type===x.type?{...h,count:h.count+1}:h):[...w,{type:x.type,count:1}])},[]);j.useEffect(()=>{u()},[u]),j.useEffect(()=>{let x=null,w,h=!1;return(()=>{h||(x=new EventSource(`/admin/api/notifications/stream?after=${y.current}`),x.addEventListener("open",()=>{d(!0),window.clearInterval(w),w=void 0}),x.addEventListener("admin",M=>{try{m(JSON.parse(M.data))}catch{}}),x.addEventListener("error",()=>{d(!1),w===void 0&&(w=window.setInterval(()=>void u(),Gs))}))})(),()=>{h=!0,x==null||x.close(),window.clearInterval(w)}},[m,u]);const g=j.useCallback(async x=>{const w=x.filter(h=>h>0);if(w.length!==0){o(h=>h.map(C=>w.includes(C.id)&&!C.readAt?{...C,readAt:new Date().toISOString()}:C));try{const h=await F.post("/admin/api/notifications/read",{ids:w});r(h.unread)}catch{u()}}},[u]),p=j.useCallback(async()=>{r(0),o(x=>x.map(w=>w.readAt?w:{...w,readAt:new Date().toISOString()}));try{const x=await F.post("/admin/api/notifications/read",{all:!0});r(x.unread)}catch{u()}},[u]),b=j.useMemo(()=>({events:n,unread:i,types:c,connected:l,error:a,markRead:g,markAllRead:p,reload:u}),[n,i,c,l,a,g,p,u]);return e.jsx(xs.Provider,{value:b,children:s})}function Pe(){const s=j.useContext(xs);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function js(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function vs(s){return s.startsWith("auth.")?"key":s.startsWith("device.")?"tv":s.startsWith("admin.")?"shield":s.startsWith("task.")?"clock":s.startsWith("integration.")?"plug":s.startsWith("library.")?"library":s.startsWith("emby.")?"globe":s.startsWith("server.")?"power":"bell"}function We(s){return{"auth.login":"Signed in","auth.login_failed":"Sign-in refused","auth.logout":"Signed out","device.registered":"New device","device.removed":"Device removed","device.renamed":"Device renamed","admin.sign_in":"Admin sign-in","server.started":"Server started","server.maintenance":"Maintenance","task.completed":"Task finished","task.failed":"Task failed","integration.failed":"Integration failed","integration.test":"Integration test","library.sync":"Library sync","emby.unreachable":"Emby unreachable","emby.recovered":"Emby recovered"}[s]??s.replace(/[._]/g," ")}const _e=99;function Js(){const{events:s,unread:n,connected:o,markRead:i,markAllRead:r}=Pe(),[c,t]=j.useState(!1),l=j.useRef(null);return j.useEffect(()=>{const d=v=>{var y;(y=l.current)!=null&&y.contains(v.target)||t(!1)},a=v=>{v.key==="Escape"&&t(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",a),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",a)}},[]),j.useEffect(()=>{if(!c)return;const d=s.filter(a=>!a.readAt).map(a=>a.id);d.length>0&&i(d)},[c]),e.jsxs("div",{className:"bell",ref:l,"data-open":c||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":c,onClick:()=>t(d=>!d),children:[e.jsx(ne,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>_e?`${_e}+`:n}):null]}),c?e.jsxs("div",{className:"bell-panel",children:[e.jsxs("div",{className:"bell-head",children:[e.jsx("b",{children:"Activity"}),e.jsxs("div",{className:"row tight",children:[o?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void r(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(d=>{const a=e.jsxs(e.Fragment,{children:[e.jsx(Ee,{name:vs(d.type),tone:js(d)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:d.title||d.type}),d.summary?e.jsx("p",{children:d.summary}):null,e.jsx("time",{dateTime:d.occurredAt,children:be(d.occurredAt)})]})]});return d.link?e.jsx(ee,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>t(!1),children:a},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:a},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(ee,{to:"/admin/activity",onClick:()=>t(!1),children:"All activity"})})]}):null]})}function V({title:s,intro:n,actions:o,crumbs:i,icon:r}){var l;const c=qe(),t=r??((l=qs.find(d=>$e({path:d.path,end:!0},c.pathname)))==null?void 0:l.icon);return e.jsxs("header",{className:"page-head",children:[i?e.jsx("nav",{className:"crumbs",children:i}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[t?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(ne,{name:t})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),o?e.jsx("div",{className:"page-head-actions",children:o}):null]})]})}function E({title:s,intro:n,icon:o,tone:i,actions:r,footer:c,children:t}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[o?e.jsx(Ee,{name:o,tone:i}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),r?e.jsx("div",{className:"card-head-actions",children:r}):null]}):null,t,c?e.jsx("div",{className:"card-foot",children:c}):null]})}function de({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function ae({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Ee,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function S({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function Q({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function G({children:s}){return e.jsx("p",{className:"empty",children:s})}function te({columns:s,children:n}){return e.jsx("tr",{children:e.jsx("td",{colSpan:s,className:"muted",children:e.jsx("p",{className:"empty",children:n})})})}function me({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function A({children:s,onClick:n,variant:o,size:i,disabled:r,busy:c,icon:t,type:l="button",title:d}){return e.jsxs("button",{type:l,className:"","data-variant":o,"data-size":i,disabled:r||c,onClick:n,title:d,children:[c?e.jsx("span",{className:"spinner"}):t?e.jsx(ne,{name:t}):null,s]})}function q({label:s,hint:n,children:o,grow:i}){return e.jsxs("label",{className:i?"field grow":"field",children:[e.jsx("span",{children:s}),o,n?e.jsx("small",{children:n}):null]})}function W({label:s,hint:n,checked:o,onChange:i,disabled:r}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:o,disabled:r,onChange:c=>i(c.target.checked)}),e.jsx("span",{className:"switch"}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:s}),n?e.jsx("p",{children:n}):null]})]})}function Me({value:s,options:n,onChange:o}){return e.jsx("div",{className:"segments",role:"group",children:n.map(i=>e.jsx("button",{type:"button","aria-pressed":i.value===s,onClick:()=>o(i.value),children:i.label},String(i.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function bs({data:s,labelOf:n,valueOf:o,toneOf:i,title:r}){if(s.length===0)return e.jsx(G,{children:"Nothing in this window."});const c=s.map(l=>o(l)),t=Math.max(1,...c);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((l,d)=>{const a=o(l);return e.jsx("div",{className:"bar","data-tone":i==null?void 0:i(l),"data-empty":a===0||void 0,style:{height:`${Math.max(2,a/t*100)}%`},title:r?r(l):`${n(l,d)}: ${a}`},d)})}),e.jsxs("div",{className:"bars-axis",children:[e.jsx("span",{children:n(s[0],0)}),e.jsx("span",{children:n(s[s.length-1],s.length-1)})]})]})}function Ys({value:s,total:n,tone:o}){const i=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":o,children:e.jsx("div",{style:{width:`${i*100}%`}})})}function B({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(ne,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})}):null]}):null}function _({rows:s=3}){return e.jsxs("div",{className:"loading-page","aria-busy":"true","aria-label":"Loading",children:[e.jsxs("div",{className:"loading-heading",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]}),e.jsx("div",{className:"loading-tiles",children:Array.from({length:4},(n,o)=>e.jsx("span",{className:"skeleton"},o))}),Array.from({length:s},(n,o)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},o))]})}function he({title:s,body:n,confirmLabel:o="Confirm",destructive:i,busy:r,onConfirm:c,onCancel:t}){const l=j.useId(),d=j.useRef(null);return j.useEffect(()=>{var v;(v=d.current)==null||v.focus();const a=y=>{y.key==="Escape"&&t()};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[t]),e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":l,ref:d,tabIndex:-1,children:[e.jsx("h2",{id:l,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{onClick:t,variant:"quiet",children:"Cancel"}),e.jsx(A,{onClick:c,variant:i?"danger":"primary",busy:r,children:o})]})]})})}function ze({rows:s}){return e.jsx("div",{className:"kv",children:s.map(n=>e.jsxs("div",{className:"kv-row",children:[e.jsx("span",{children:n.label}),e.jsx("span",{children:n.value})]},n.label))})}function gs({tiles:s}){return e.jsx("div",{className:"tiles plain",children:s.map(n=>e.jsxs("div",{className:"tile",children:[e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function Xs({open:s,onNavigate:n}){const{unread:o}=Pe(),i=qe(),[r,c]=j.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),t=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};j.useEffect(()=>{const d=ve.find(a=>a.items.some(v=>$e({path:v.path,end:!0},i.pathname)));d&&c(a=>{const v={...a};return ve.forEach(y=>{y.collapsible!==!1&&(v[y.id]=y.id!==d.id)}),t(v),v})},[i.pathname]);const l=(d,a=!1)=>{c(v=>{const y={...v},u=!(v[d]??a);return ve.forEach(m=>{m.collapsible!==!1&&(y[m.id]=m.id===d?u:!0)}),t(y),y})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:ve.map(d=>{const a=d.items.filter(m=>!m.hidden);if(a.length===0)return null;const v=a.some(m=>$e({path:m.path,end:!0},i.pathname)),y=d.collapsible!==!1,u=v||!y||!(r[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&y?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":u,onClick:()=>l(d.id,d.defaultCollapsed),children:[d.label,e.jsx(ne,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,u?a.map(m=>e.jsx(Cs,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:g})=>g?"on":"","aria-current":void 0,children:({isActive:g})=>e.jsxs("span",{style:{display:"contents"},ref:p=>{const b=p==null?void 0:p.parentElement;b&&(g?b.setAttribute("aria-current","page"):b.removeAttribute("aria-current"))},children:[m.icon?e.jsx(ne,{name:m.icon}):null,m.label,m.badge==="notifications"&&o>0?e.jsx("span",{className:"rail-badge",children:o>99?"99+":o}):null]})},m.id)):null]},d.id)})})}function Qs(){var g;const{version:s,online:n,checkedAt:o,status:i,setMaintenance:r}=ie(),[c,t]=j.useState(!1),[l,d]=j.useState(!1),[a,v]=j.useState(!1),y=qe(),u=!!((g=i==null?void 0:i.maintenance)!=null&&g.enabled),m=async()=>{if(!(l||!i)){d(!0);try{await r(!u)}finally{d(!1)}}};return j.useEffect(()=>t(!1),[y.pathname]),j.useEffect(()=>{if(!c)return;const p=document.body.style.overflow;document.body.style.overflow="hidden";const b=x=>{x.key==="Escape"&&t(!1)};return window.addEventListener("keydown",b),()=>{document.body.style.overflow=p,window.removeEventListener("keydown",b)}},[c]),e.jsxs(e.Fragment,{children:[e.jsx("a",{className:"skip",href:"#main",children:"Skip to content"}),e.jsxs("header",{className:"topbar",children:[e.jsxs("a",{className:"topbar-brand",href:"/admin",children:[e.jsx("span",{className:"brand-mark","aria-hidden":"true",children:"M"}),e.jsx("span",{className:"brand-word",children:"Memby Gateway"})]}),e.jsx("button",{type:"button",className:"rail-toggle","aria-label":"Sections","aria-expanded":c,"aria-controls":"rail",onClick:()=>t(p=>!p),children:e.jsx(ne,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(_s,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":n&&!u?"ok":"bad","aria-pressed":u,disabled:!i||l,title:u?"Bring Memby back online":"Take Memby offline",onClick:()=>u?void m():v(!0),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:u?"offline":n?"online":"not responding"}),e.jsx("span",{children:u?"click to bring back online":o?`updated ${zs(o)}`:""})]}),e.jsx(Js,{}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsx("button",{type:"submit","data-variant":"quiet","data-size":"sm",title:"Sign out",children:e.jsx(ne,{name:"logout"})})})]})]}),c?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>t(!1)}):null,e.jsx(Xs,{open:c,onNavigate:()=>t(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Ss,{})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available.",confirmLabel:"Go offline",destructive:!0,busy:l,onConfirm:()=>{v(!1),m()},onCancel:()=>v(!1)}):null]})}const fs=j.createContext(null),en=5e3;function sn({children:s}){const[n,o]=j.useState([]),i=j.useRef(1),r=j.useCallback(d=>{o(a=>a.filter(v=>v.id!==d))},[]),c=j.useCallback((d,a="ok")=>{const v=i.current++;o(y=>[...y,{id:v,message:d,tone:a}]),window.setTimeout(()=>r(v),en)},[r]),t=j.useCallback(async(d,a)=>{try{const v=await d();return a&&c(a,"ok"),v}catch(v){c(v instanceof Error?v.message:String(v),"bad");return}},[c]),l=j.useMemo(()=>({show:c,wrap:t}),[c,t]);return e.jsxs(fs.Provider,{value:l,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(d=>e.jsxs("div",{className:"toast","data-tone":d.tone,children:[e.jsx(ne,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>r(d.id),"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})})]},d.id))})]})}function se(){const s=j.useContext(fs);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:o,enabled:i=!0}=n,[r,c]=j.useState(),[t,l]=j.useState(""),[d,a]=j.useState(i),[v,y]=j.useState(!1),u=j.useRef(0),m=j.useRef(!1),g=j.useCallback(async()=>{if(!i)return;const p=++u.current;m.current&&y(!0);try{const b=await F.get(s);if(p!==u.current)return;c(b),l(""),m.current=!0}catch(b){if(p!==u.current)return;l(b instanceof Error?b.message:String(b))}finally{p===u.current&&(a(!1),y(!1))}},[s,i]);return j.useEffect(()=>(m.current=!1,a(!0),g(),()=>{u.current+=1}),[g]),j.useEffect(()=>{if(!o||!i)return;let p;const b=()=>{window.clearInterval(p),p=document.hidden?void 0:window.setInterval(()=>void g(),o)},x=()=>{b(),document.hidden||g()};return b(),document.addEventListener("visibilitychange",x),()=>{window.clearInterval(p),document.removeEventListener("visibilitychange",x)}},[o,i,g]),{data:r,error:t,loading:d,refreshing:v,reload:g,set:c}}function Y(){const[s,n]=j.useState(null),o=j.useRef(!0);j.useEffect(()=>()=>{o.current=!1},[]);const i=j.useCallback(async(r,c)=>{n(r);try{return await c(),!0}finally{o.current&&n(null)}},[]);return{busy:s,run:i}}function nn(){var b,x,w,h,C;const{status:s,error:n,loading:o}=ie(),i=J("/admin/api/runtime",{pollMs:3e4}),r=J("/admin/api/views",{pollMs:6e4});if(o||!s)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(_,{})]});const c=s.features??{features:[],revision:0,safeMode:!1},t=c.features??[],l=s.clients??[],d=l.filter(M=>Ne(M.lastSeen)).length,a=s.updatePolicy??{},v=!!a.minimumVersion&&a.minimumVersion===a.latestVersion,y=s.playbackPolicy,u=s.mdblist,m=s.forYou,g=(s.runs??[]).slice(0,5),p=i.data;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(ae,{tiles:[{label:"items in the library",value:k(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:k((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:k(l.length),icon:"tv",tone:"info"},{label:`visits today · ${((b=r.data)==null?void 0:b.lastWeek.visits)??0} this time last week`,value:k((x=r.data)==null?void 0:x.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((w=r.data)==null?void 0:w.lastWeek.viewers)??0} this time last week`,value:k((h=r.data)==null?void 0:h.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${t.filter(M=>M.enabled).length} / ${t.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(E,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(ze,{rows:[{label:"Availability",value:(C=s.maintenance)!=null&&C.enabled?e.jsx(S,{tone:"bad",children:"offline for maintenance"}):e.jsx(S,{tone:"ok",children:"online"})},{label:"Feature control plane",value:c.safeMode?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["revision r",k(c.revision)]})},{label:"App update prompt",value:a.enabled?e.jsxs(S,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",a.latestVersion]}):e.jsx(S,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(S,{tone:"warn",children:"running"}):e.jsxs(S,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(y==null?void 0:y.prerollEnabled)===!1?e.jsx(S,{children:"off"}):e.jsxs(S,{tone:"ok",children:[((y==null?void 0:y.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(E,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(ze,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"MDBList ratings",value:u!=null&&u.enabled?e.jsxs(S,{tone:"ok",children:[k(u.cachedTitles)," titles stored"]}):e.jsx(S,{children:u!=null&&u.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(S,{tone:"warn",children:"rebuilding"}):e.jsxs(S,{children:[k((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:k((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(de,{cols:"2",children:[e.jsx(E,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(ee,{to:"/admin/imports",children:"All imports"}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Written"})]})}),e.jsx("tbody",{children:g.length===0?e.jsx(te,{columns:4,children:"No imports have run yet."}):g.map(M=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(M.startedAt)}),e.jsx("td",{children:M.kind}),e.jsx("td",{children:e.jsx(S,{tone:M.status==="success"?"ok":M.status==="running"?"warn":"bad",children:M.status})}),e.jsx("td",{className:"num",children:k(M.itemsUpserted)})]},M.id||M.startedAt))})]})})}),e.jsx(E,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:p?e.jsxs(e.Fragment,{children:[e.jsx(gs,{tiles:[{label:"goroutines",value:k(p.goroutines)},{label:"heap in use",value:fe(p.heapInuse)},{label:"reserved",value:fe(p.sys)},{label:"collections",value:k(p.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",fe(p.nextGc)," · memory limit"," ",p.memoryLimit>0&&p.memoryLimit`/admin/api/notifications${Se({days:r,type:t,severity:d,unread:v,limit:g,offset:u*g})}`,[r,t,d,v,u]),{data:b,error:x,loading:w,reload:h}=J(p),C=async()=>{await o(),await h()};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Activity",intro:"Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from.",actions:s>0?e.jsx(A,{onClick:()=>void C(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(B,{message:x}),e.jsx(ae,{tiles:[{label:"Events in window",value:k((b==null?void 0:b.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:k(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:k((b==null?void 0:b.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:r,options:tn.map(M=>({value:M.value,label:M.label})),onChange:M=>{c(M),m(0)}})}),e.jsx(q,{label:"Kind",children:e.jsxs("select",{value:t,onChange:M=>{l(M.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((b==null?void 0:b.types)??[]).map(M=>e.jsxs("option",{value:M.type,children:[We(M.type)," (",M.count,")"]},M.type))]})}),e.jsx(q,{label:"Severity",children:e.jsxs("select",{value:d,onChange:M=>{a(M.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Any"}),e.jsx("option",{value:"info",children:"Information"}),e.jsx("option",{value:"warning",children:"Warning"}),e.jsx("option",{value:"error",children:"Error"})]})}),e.jsx(q,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:M=>{y(M.target.value==="unread"),m(0)},children:[e.jsx("option",{value:"",children:"All"}),e.jsx("option",{value:"unread",children:"Unread only"})]})}),e.jsx("div",{className:"filter-actions",children:e.jsx(A,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{h(),i()},children:"Refresh"})})]}),w?e.jsx(_,{}):e.jsx(E,{title:"Events",icon:"bell",tone:"info",footer:((b==null?void 0:b.total)??0)>g?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:u===0,onClick:()=>m(u-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(u+1)*g>=((b==null?void 0:b.total)??0),onClick:()=>m(u+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((b==null?void 0:b.events.length)??0)===0?e.jsx(te,{columns:6,children:"Nothing has happened in this window."}):b==null?void 0:b.events.map(M=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(M.occurredAt),children:be(M.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Ee,{name:vs(M.type),tone:js(M)}),We(M.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:M.title}),M.summary?e.jsx("div",{className:"muted",children:M.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:M.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:M.target||"—"}),e.jsxs("td",{className:"nowrap",children:[M.readAt?null:e.jsx(S,{tone:"ok",children:"new"}),M.link?e.jsx(ee,{className:"table-row-link",to:M.link,children:"Open"}):null]})]},M.id))})]})})})]})}function rn(){const{data:s,error:n,loading:o}=J("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],r=i.flatMap(l=>l.devices??[]),c=i.filter(l=>{var d;return(d=l.recommendations)==null?void 0:d.completed}).length,t=i.filter(l=>{var d,a;return((d=l.recommendations)==null?void 0:d.prompted)&&!((a=l.recommendations)!=null&&a.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(B,{message:n}),e.jsx(me,{tone:"info",children:"This is the Memby user list, not the Emby user directory. A person appears here only after signing in to the Memby app. Removing access signs their Memby devices out and does not delete or change their Emby account."}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Memby users",value:k(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:k(r.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:k(r.filter(l=>Ne(l.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:k(c),icon:"check",tone:"ok"},{label:"setup prompts queued",value:k(t),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:i.length===0?e.jsx(G,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):i.map(l=>{var u,m;const d=l.devices??[],a=d.filter(g=>Ne(g.lastSeen)).length,v=(u=l.recommendations)!=null&&u.completed?{label:"personalised",tone:"ok"}:(m=l.recommendations)!=null&&m.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},y=Fe(l.lastSeen);return e.jsxs(ee,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(l.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:ms(l.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[l.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":y.tone,title:y.label})]}),e.jsxs("span",{className:"list-meta",children:[k(d.length)," device",d.length===1?"":"s",a?` · ${a} active now`:""," · last seen ",O(l.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(S,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},l.id)})})]})]})}function Ie(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function ln(){const{userId:s=""}=Re(),n=De(),{wrap:o}=se(),{busy:i,run:r}=Y(),c=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:t,error:l,loading:d,reload:a}=J("/admin/api/accounts",{pollMs:3e4}),[v,y]=j.useState(null),[u,m]=j.useState(null),[g,p]=j.useState(null),[b,x]=j.useState(null),[w,h]=j.useState(null),C=((t==null?void 0:t.accounts)??[]).find(R=>R.id===s),M=(t==null?void 0:t.catalogue)??[],L=(t==null?void 0:t.themes)??[];j.useEffect(()=>{var R;v===null&&C&&y({...((R=C.settings)==null?void 0:R.preferences)??{}})},[C,v]),j.useEffect(()=>{g===null&&C&&p({...C.notifications})},[C,g]),j.useEffect(()=>{if(u!==null||!C)return;const R=C.themes??[];m(R.length===0?L.map($=>$.id):R)},[C,u,L]);const H=j.useMemo(()=>{const R=[];for(const $ of M){let z=R.find(f=>f.name===$.area);z||R.push(z={name:$.area,definitions:[]}),z.definitions.push($)}return R},[M]),N=(R,$,z,f)=>r(R,async()=>{const D=await o($,z);x(null),D!==void 0&&(f==null||f()),await a()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(_,{})]});if(!C)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(B,{message:l}),e.jsx(E,{children:e.jsx(G,{children:"This user is no longer signed in to Memby."})})]});const T=C.devices??[],X=T.filter(R=>Ne(R.lastSeen)).length,re=C.settings??{},K=C.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:C.username||"Unnamed user",intro:`Memby user · ${k(T.length)} device${T.length===1?"":"s"} · last seen ${O(C.lastSeen)}`,crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:ms(C.username)}),X?e.jsxs(S,{tone:"ok",children:[X," active now"]}):e.jsx(S,{children:"idle"}),e.jsx(Q,{children:C.id})]})}),e.jsx(B,{message:l}),e.jsxs(de,{cols:"wide",children:[e.jsx(E,{title:"Devices",intro:"Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed.",icon:"tv",tone:"info",children:T.length===0?e.jsx(G,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:T.map(R=>{const $=Fe(R.lastSeen);return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":$.tone,title:$.label})," ",e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(R.id)}`,children:R.name||"Memby TV"})]}),e.jsxs("p",{children:[R.version?`Memby ${R.version}`:"Legacy Memby client"," · ",$.label," · last seen ",O(R.lastSeen)," · signed in ",O(R.signedInAt)]}),(R.versions??[]).length>0?e.jsx("div",{className:"chips",children:(R.versions??[]).map(z=>e.jsxs(Q,{tone:z.version===R.version?"ok":void 0,children:[z.version,z.version===R.version?" · now":""]},z.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(A,{size:"sm",disabled:!R.id,onClick:()=>h({id:R.id,name:R.name}),children:"Rename"}),e.jsx(A,{size:"sm",variant:"danger",disabled:!R.id,onClick:()=>x({kind:"remove-device",deviceId:R.id,name:R.name}),children:"Sign out"})]})]},R.id||R.name)})})}),e.jsxs(E,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:K.completed?e.jsx(A,{busy:i==="reset-rec",onClick:()=>x({kind:"reset-recommendations"}),children:"Clear stored choices"}):K.prompted?e.jsx(A,{onClick:()=>x({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(A,{variant:"primary",busy:i==="prompt",onClick:()=>void N("prompt",()=>F.put(`${c}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:K.completed?e.jsx(S,{tone:"ok",children:"completed"}):K.prompted?e.jsx(S,{tone:"warn",children:"prompt queued"}):e.jsx(S,{children:"not invited"})}),e.jsx(on,{prompt:K})]})]}),e.jsx(E,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:g!=null&&g.enabled?e.jsx(S,{tone:"ok",children:"enabled"}):e.jsx(S,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="notifications",onClick:()=>void N("notifications",()=>F.put(`${c}/notifications`,g),"Notification settings saved.",()=>p(null)),children:"Save notifications"}),e.jsx(A,{onClick:()=>{p(null),a()},children:"Discard changes"})]}),children:g?e.jsxs("div",{className:"checks columns",children:[e.jsx(W,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:g.enabled,onChange:R=>p($=>$&&{...$,enabled:R})}),e.jsx(W,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:g.showReturnAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,showReturnAlerts:R})}),e.jsx(W,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:g.sonarrAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,sonarrAlerts:R})}),e.jsx(W,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:g.radarrAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,radarrAlerts:R})}),e.jsx(W,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:g.updateAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,updateAlerts:R})}),e.jsx(W,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:g.libraryAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,libraryAlerts:R})}),e.jsx(W,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:g.systemAlerts,disabled:!g.enabled,onChange:R=>p($=>$&&{...$,systemAlerts:R})})]}):null}),e.jsx(E,{title:"Settings",intro:"These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off.",icon:"sliders",tone:"ok",actions:re.saved?e.jsxs(S,{tone:re.source==="admin"?"warn":"ok",children:["r",k(re.revision)," · ",re.source||"device"," · ",O(re.updatedAt)]}):e.jsx(S,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="push",onClick:()=>void N("push",()=>F.put(`${c}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>y(null)),children:"Push to their televisions"}),e.jsx(A,{onClick:()=>{y(null),a()},children:"Discard changes"}),e.jsx(A,{onClick:()=>x({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(ee,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:H.map(R=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:R.name}),R.definitions.map($=>e.jsx(cn,{definition:$,value:v==null?void 0:v[$.key],onChange:z=>y(f=>({...f??{},[$.key]:z}))},$.key))]},R.name))}),e.jsx(E,{title:"Colour schemes",intro:"Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight.",icon:"sparkle",tone:"note",actions:(C.themes??[]).length===0?e.jsx(S,{children:"all schemes"}):e.jsxs(S,{tone:"note",children:[k((C.themes??[]).length)," of ",k(L.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="themes",onClick:()=>(u??[]).length===0?x({kind:"no-themes"}):void N("themes",()=>F.put(`${c}/themes`,{themes:u??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx(A,{onClick:()=>m(L.map(R=>R.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:L.map(R=>{var $,z,f;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(u??[]).includes(R.id),onChange:D=>m(I=>D.target.checked?[...I??[],R.id]:(I??[]).filter(P=>P!==R.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":Ie(($=R.palette)==null?void 0:$.surface),"--swatch-accent":Ie((z=R.palette)==null?void 0:z.accent),"--swatch-hairline":Ie((f=R.palette)==null?void 0:f.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:R.name}),e.jsx("p",{children:R.description})]})]},R.id)})})}),e.jsx(E,{title:"Remove Memby access",intro:"Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched.",icon:"alert",tone:"bad",children:e.jsx(A,{variant:"danger",onClick:()=>x({kind:"remove-account"}),children:"Remove Memby access"})}),w?e.jsx(dn,{initial:w.name,busy:i==="rename",onCancel:()=>h(null),onConfirm:R=>void N("rename",()=>F.put(`${c}/devices/${encodeURIComponent(w.id)}`,{deviceName:R}),"Device renamed.",()=>h(null))}):null,b?e.jsx(hn,{pending:b,busy:i,username:C.username,onCancel:()=>x(null),onConfirm:()=>{switch(b.kind){case"remove-device":return void N("remove-device",()=>F.del(`${c}/devices/${encodeURIComponent(b.deviceId)}`),"Device signed out.");case"remove-account":return void N("remove-account",()=>F.del(`${c}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void N("reset-rec",()=>F.del(`${c}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void N("reset-prefs",()=>F.del(`${c}/preferences`),"Defaults restored.",()=>y(null));case"no-themes":return void N("themes",()=>F.put(`${c}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function on({prompt:s}){const n=s.ratings??[],o=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],i=[...n.map(r=>e.jsxs(Q,{tone:"warn",children:[r.title," · ",k(r.rating)," ★"]},`r:${r.title}`)),...o.flatMap(([r,c])=>(c??[]).map(t=>e.jsxs(Q,{children:[r,": ",t]},`${r}:${t}`)))];return i.length===0?e.jsx(G,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function cn({definition:s,value:n,onChange:o}){if(s.kind==="toggle")return e.jsx(W,{label:s.name,hint:s.description,checked:!!n,onChange:o});if(s.kind==="choice"||s.kind==="number"){const r=s.unit??"",c=s.kind==="number"?(s.numbers??[]).map(t=>({value:String(t),label:t===0?"No limit":r?`${t} ${r}`:String(t)})):s.options??[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:t=>o(s.kind==="number"?Number(t.target.value):t.target.value),children:c.map(t=>e.jsx("option",{value:t.value,children:t.label},t.value))})})}if(s.kind==="multi"){const r=Array.isArray(n)?n:[],c=[...r,...(s.options??[]).map(t=>t.value).filter(t=>!r.includes(t))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:c.map(t=>{const l=(s.options??[]).find(d=>d.value===t);return l?e.jsx(W,{label:l.label,checked:r.includes(t),onChange:d=>o(d?[...r,t]:r.filter(a=>a!==t))},t):null})})]})}const i=Array.isArray(n)?n:[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` -`),onChange:r=>o(r.target.value.split(` -`).map(c=>c.trim()).filter(Boolean))})})}function dn({initial:s,busy:n,onConfirm:o,onCancel:i}){const[r,c]=j.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:t=>t.target===t.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(q,{label:"Device name",children:e.jsx("input",{type:"text",value:r,autoFocus:!0,maxLength:80,onChange:t=>c(t.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:i,children:"Cancel"}),e.jsx(A,{variant:"primary",busy:n,disabled:!r.trim(),onClick:()=>o(r.trim()),children:"Rename"})]})]})})}function hn({pending:s,busy:n,username:o,onConfirm:i,onCancel:r}){const t={"remove-device":{title:"Sign this device out of Memby?",body:"Its Emby account will not be changed. The set can sign in again at any time.",label:"Sign out",destructive:!0},"remove-account":{title:`Remove Memby access for ${o||"this user"}?`,body:"Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.",label:"Remove access",destructive:!0},"reset-recommendations":{title:"Clear this person's stored recommendation choices?",body:"Viewing history remains intact; only the explicit setup answers are removed.",label:"Clear",destructive:!0},"cancel-prompt":{title:"Cancel this person's queued recommendation prompt?",body:"They will not be invited to set up recommendations on their next launch.",label:"Cancel prompt",destructive:!1},"reset-preferences":{title:"Restore the Memby defaults for this person?",body:"Their televisions will pick the change up the next time they check in.",label:"Restore defaults",destructive:!0},"no-themes":{title:"Allow this person no colour schemes?",body:"They will be left on Midnight with nothing to choose between.",label:"Save anyway",destructive:!0}}[s.kind];return e.jsx(he,{title:t.title,body:t.body,confirmLabel:t.label,destructive:t.destructive,busy:!!n,onConfirm:i,onCancel:r})}function un(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const i=(s.options??[]).find(r=>r.value===n);return i?i.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const o=Array.isArray(n)?n:[];return o.length===0?"None":o.map(i=>{var r;return((r=(s.options??[]).find(c=>c.value===i))==null?void 0:r.label)??i}).join(", ")}function mn(){const{userId:s=""}=Re(),{wrap:n}=se(),{busy:o,run:i}=Y(),r=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:c,error:t,loading:l,reload:d}=J(`${r}/preferences/history`,{pollMs:3e4}),[a,v]=j.useState(new Set),[y,u]=j.useState(null),m=(c==null?void 0:c.username)||"this account",g=(c==null?void 0:c.devices)??[],p=(c==null?void 0:c.revisions)??[],b=(c==null?void 0:c.catalogue)??[],x=h=>v(C=>{const M=new Set(C);return M.has(h)?M.delete(h):M.add(h),M}),w=h=>i("restore",async()=>{await n(()=>F.post(`${r}/preferences/revisions/${h}/restore`),`Restored r${h}.`),u(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(ee,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(B,{message:t}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"Where each device has got to",intro:"A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway.",icon:"tv",tone:"info",actions:c!=null&&c.saved?e.jsxs(S,{tone:c.currentSource==="admin"?"warn":"ok",children:["now on r",k(c.currentRevision)," · ",c.currentSource||"device"]}):e.jsx(S,{children:"defaults · never synced"}),children:g.length===0?e.jsx(G,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:g.map(h=>{const C=h.never?void 0:h.behind?"bad":"ok";return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":C})," ",h.name||"Memby TV"," ",h.signedOut?e.jsx(S,{children:"signed out"}):null]}),e.jsxs("p",{children:[h.never?"Has not fetched these settings yet":`Holding r${k(h.revision)} · taken ${O(h.ackedAt)}`,h.clientVersion?` · Memby ${h.clientVersion}`:"",h.signedOut?"":` · last seen ${O(h.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:h.never?e.jsx(S,{children:"never taken one"}):h.behind?e.jsxs(S,{tone:"bad",children:[k(h.behind)," behind"]}):e.jsx(S,{tone:"ok",children:"up to date"})})]},h.deviceId||h.name)})})}),e.jsx(E,{title:"Change history",intro:"Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{className:"num",children:"Rev"}),e.jsx("th",{children:"Changed by"}),e.jsx("th",{children:"What changed"}),e.jsx("th",{className:"num",children:"Taken by"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:p.length===0?e.jsx(te,{columns:6,children:"Nothing has been changed on this account yet."}):p.flatMap(h=>{const C=a.has(h.revision),M=h.acks??[],L=h.changes??[],H=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(h.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",k(h.revision)," ",h.current?e.jsx(S,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(S,{tone:h.source==="admin"?"warn":"ok",children:h.author}),h.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",k(h.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:h.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):L.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:L.map((N,T)=>e.jsxs(Q,{children:[N.name,": ",N.before," → ",N.after]},`${N.name}:${T}`))})}),e.jsx("td",{className:"num",children:M.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:M.map(N=>N.deviceName||N.deviceId).join(", "),children:k(M.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(A,{size:"sm",onClick:()=>x(h.revision),children:C?"Hide":"Show"}),h.current?null:e.jsx(A,{size:"sm",onClick:()=>u(h.revision),children:"Restore"})]})})]},h.revision)];return C&&H.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:b.map(N=>{var T;return e.jsxs(Q,{children:[N.name,":"," ",un(N,(T=h.preferences)==null?void 0:T[N.key])]},N.key)})})})},`${h.revision}:detail`)),H})})]})})})]}),y!==null?e.jsx(he,{title:`Restore revision ${y}?`,body:"It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to.",confirmLabel:"Restore",busy:o==="restore",onConfirm:()=>void w(y),onCancel:()=>u(null)}):null]})}function pn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(o=>e.jsx(Q,{tone:o.version===s.version?"ok":void 0,children:o.version},o.version))})}function xn(){const{status:s,error:n,loading:o}=ie(),i=(s==null?void 0:s.clients)??[],r=i.filter(t=>(t.capabilities??[]).includes("server_features_v1")),c=new Set(i.map(t=>t.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(B,{message:n}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"devices known",value:k(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:k(i.filter(t=>Ne(t.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:k(r.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:k(c.size),icon:"download",tone:"note"}]}),e.jsx(E,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(te,{columns:6,children:"No devices have signed in yet."}):i.map(t=>{const l=(t.capabilities??[]).includes("server_features_v1"),d=Fe(t.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":d.tone,title:d.label}),e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(t.deviceId)}`,children:t.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:t.username}),e.jsx("td",{className:"mono",children:t.version||"legacy"}),e.jsx("td",{children:e.jsx(pn,{client:t})}),e.jsx("td",{children:e.jsx(S,{tone:l?"ok":"warn",children:l?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:O(t.lastSeen)})]},`${t.deviceId}:${t.username}`)})})]})})})]})]})}const He={user:"",q:"",ip:"",outcome:"",from:"",to:""},jn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function vn(){var h,C,M,L,H,N;const[s,n]=j.useState("log"),[o,i]=j.useState(7),[r,c]=j.useState(He),[t,l]=j.useState(0),d=100,a=j.useMemo(()=>Se({...r,days:r.from?void 0:o||void 0,limit:d,offset:t*d}),[r,o,t]),v=J(`/admin/api/logins${a}`,{enabled:s==="log"}),y=J(`/admin/api/logins/devices${a}`,{enabled:s==="devices"}),u=((h=v.data)==null?void 0:h.users)??((C=y.data)==null?void 0:C.users)??[],m=((M=v.data)==null?void 0:M.totals)??((L=y.data)==null?void 0:L.totals),g=((H=v.data)==null?void 0:H.retentionDays)??((N=y.data)==null?void 0:N.retentionDays)??90,p=s==="log"?v.loading:y.loading,b=s==="log"?v.error:y.error,x=T=>{c(X=>({...X,...T})),l(0)},w=Object.entries(r).some(([,T])=>T!=="")||!!r.from;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Sign-in history",intro:"Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected.",actions:e.jsx(Me,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(B,{message:b}),m?e.jsx(ae,{tiles:[{label:"Successful sign-ins",value:k(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:k(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:k(m.devices),icon:"tv",tone:"info"},{label:"People",value:k(m.users),icon:"people",tone:"note"},{label:"Addresses",value:k(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${g} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:r.from?-1:o,options:jn.map(T=>({value:T.value,label:T.label})),onChange:T=>{i(T),x({from:"",to:""})}})}),e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r.user,onChange:T=>x({user:T.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),u.map(T=>e.jsx("option",{value:T.id,children:T.username||T.id},T.id))]})}),e.jsx(q,{label:"Outcome",children:e.jsxs("select",{value:r.outcome,onChange:T=>x({outcome:T.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(q,{label:"Address",children:e.jsx("input",{type:"text",value:r.ip,placeholder:"10.0.0.4",onChange:T=>x({ip:T.target.value})})}),e.jsx(q,{label:"From",children:e.jsx("input",{type:"date",value:r.from,onChange:T=>x({from:T.target.value})})}),e.jsx(q,{label:"To",children:e.jsx("input",{type:"date",value:r.to,onChange:T=>x({to:T.target.value})})}),e.jsx(q,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:r.q,placeholder:"Name, device or address",onChange:T=>x({q:T.target.value})})}),e.jsx("div",{className:"filter-actions",children:w?e.jsx(A,{variant:"quiet",size:"sm",onClick:()=>{c(He),l(0)},children:"Clear"}):null})]}),p?e.jsx(_,{}):s==="log"?e.jsx(bn,{data:v.data,page:t,limit:d,onPage:l}):e.jsx(gn,{data:y.data})]})}function bn({data:s,page:n,limit:o,onPage:i}){if(!s)return null;const r=s.events.length,c=s.total===0?0:n*o+1;return e.jsxs(e.Fragment,{children:[e.jsxs(de,{cols:"wide",children:[e.jsx(E,{title:"Attempts per day",intro:"Grouped in the household's own timezone, so an evening sign-in stays on the day it happened.",icon:"chart",tone:"info",children:e.jsx(bs,{data:s.days,labelOf:t=>t.day,valueOf:t=>t.logins+t.failures,toneOf:t=>t.failures>t.logins?"bad":void 0,title:t=>`${t.day}: ${t.logins} in, ${t.failures} refused, ${t.devices} televisions`})}),e.jsx(E,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(G,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(t=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:t.ipAddress}),e.jsxs("p",{children:[k(t.logins)," in",t.failures>0?` · ${k(t.failures)} refused`:""]})]}),t.failures>0&&t.logins===0?e.jsx(S,{tone:"bad",children:"only refused"}):null]},t.ipAddress))})})]}),e.jsx(E,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${k(c)}–${k(c+r-1)} of ${k(s.total)}`}),footer:s.total>o?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:n===0,onClick:()=>i(n-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(n+1)*o>=s.total,onClick:()=>i(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(te,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(t=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(t.occurredAt)}),e.jsx("td",{children:t.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:t.deviceId?e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(t.deviceId)}`,children:t.deviceName||t.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:t.ipAddress||"—"}),e.jsx("td",{className:"mono",children:t.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:t.success?t.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:t.failureReason||"refused"})})]},t.id))})]})})})]})}function gn({data:s}){return s?e.jsx(E,{title:"Televisions",intro:"Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Television"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Today"}),e.jsx("th",{className:"num",children:"Sign-ins"}),e.jsx("th",{className:"num",children:"Refused"}),e.jsx("th",{className:"num",children:"Addresses"}),e.jsx("th",{className:"nowrap",children:"Last address"}),e.jsx("th",{className:"nowrap",children:"Last sign-in"}),e.jsx("th",{children:"Build"})]})}),e.jsx("tbody",{children:s.devices.length===0?e.jsx(te,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?k(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:k(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:k(n.failures)}):"—"}),e.jsx("td",{className:"num",children:k(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?O(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const fn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function yn(){const{deviceId:s=""}=Re(),[n,o]=j.useState(7),i=j.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:r,error:c,loading:t}=J(i,{enabled:!!s}),l=r==null?void 0:r.summary,d=(l==null?void 0:l.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:d,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(ee,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(ee,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Me,{value:n,options:fn.map(a=>({value:a.value,label:a.label})),onChange:o})}),e.jsx(B,{message:c}),t?e.jsx(_,{}):r?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Sign-ins today",value:k((l==null?void 0:l.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:k((l==null?void 0:l.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:k((l==null?void 0:l.failures)??0),icon:"shield",tone:((l==null?void 0:l.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:k((l==null?void 0:l.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:l!=null&&l.firstLogin?O(l.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:l!=null&&l.lastLogin?O(l.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(de,{cols:"wide",children:[e.jsx(E,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(bs,{data:r.days,labelOf:a=>a.day,valueOf:a=>a.logins+a.failures,toneOf:a=>a.failures>a.logins?"bad":void 0,title:a=>`${a.day}: ${a.logins} in${a.failures?`, ${a.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(E,{title:"Identity",icon:"tv",tone:"info",children:e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Person"}),e.jsx("p",{children:(l==null?void 0:l.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:r.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(l==null?void 0:l.clientVersion)||"unknown"})]})})]})}),e.jsx(E,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:r.versions.length===0?e.jsx(G,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:r.versions.map(a=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.version}),e.jsxs("p",{children:[O(a.firstSeen)," → ",O(a.lastSeen)]})]})},a.version))})})]})]}),e.jsx(E,{title:"Addresses",icon:"globe",tone:"data",children:r.addresses.length===0?e.jsx(G,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:r.addresses.map(a=>e.jsxs(Q,{tone:a.failures>0?"warn":"data",children:[a.ipAddress," · ",k(a.logins),a.failures>0?` (+${k(a.failures)} refused)`:""]},a.ipAddress))})}),e.jsx(E,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[k(r.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:r.events.length===0?e.jsx(te,{columns:6,children:"This television has not connected in the selected window."}):r.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"muted",children:a.method}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]}):null]})}function wn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(!1),a=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),y=u=>t(u,async()=>{await r(()=>F.post("/admin/api/sync",{kind:u}),u==="full"?"Full re-import started.":"Import started."),d(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(B,{message:n}),o||!s?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"items",value:k(s.library.total),icon:"library",tone:"data"},...Object.keys(a).sort().map(u=>({label:u,value:k(a[u]),icon:"list"})),{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(E,{title:"Import the catalogue",intro:"Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live.",icon:"library",tone:"data",footer:e.jsx("span",{className:"hint",children:v?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"sync",disabled:v,busy:c==="incremental",onClick:()=>void y("incremental"),children:"Sync new items"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full re-import"})]})})]}),l?e.jsx(he,{title:"Re-import the entire library?",body:"A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout.",confirmLabel:"Re-import",busy:c==="full",onConfirm:()=>void y("full"),onCancel:()=>d(!1)}):null]})}const kn={imdb:"IMDb",tomatoes:"Rotten Tomatoes",audience:"Rotten Tomatoes Audience",metacritic:"Metacritic",letterboxd:"Letterboxd",rogerebert:"Roger Ebert",tmdb:"TMDb",trakt:"Trakt",mal:"MyAnimeList",anilist:"AniList",anidb:"AniDB",kitsu:"Kitsu",score:"MDBList Score",score_average:"MDBList Average"};function Nn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(!1),[a,v]=j.useState(""),[y,u]=j.useState(!1),[m,g]=j.useState([]),[p,b]=j.useState(!1),x=s==null?void 0:s.mdblist;j.useEffect(()=>{p||!x||(d(x.enabled),g(x.sources??[]))},[x,p]);const w=()=>t("save",async()=>{await r(()=>F.post("/admin/api/mdblist-settings",{enabled:l,apiKey:a.trim(),clearApiKey:y,sources:m}),"Ratings settings saved."),v(""),u(!1),b(!1),await i()}),h=(x==null?void 0:x.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(B,{message:n}),o||!x?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"titles stored",value:k(h),icon:"database",tone:"data"},{label:"due to be re-checked",value:k(x.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:k((x.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:x.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:x.apiKeyConfigured?"ok":void 0}]}),e.jsxs(de,{cols:"2",children:[e.jsxs(E,{title:"MDBList connection",intro:"The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title.",icon:"star",tone:"note",actions:l?e.jsxs(S,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(S,{children:x.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:l,onChange:C=>{d(C),b(!0)}}),e.jsx(q,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:a,placeholder:x.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:C=>v(C.target.value)})}),e.jsx(W,{label:"Remove the saved key",checked:y,onChange:u})]}),e.jsx(E,{title:"Sources shown on televisions",intro:"A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched.",icon:"list",tone:"data",children:(x.availableSources??[]).length===0?e.jsx(G,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(x.availableSources??[]).map(C=>e.jsx(W,{label:kn[C]??C,checked:m.includes(C),onChange:M=>{b(!0),g(L=>M?[...L,C]:L.filter(H=>H!==C))}},C))})})]}),e.jsx(E,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void w(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:h?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function Sn(){var y;const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),l=(s==null?void 0:s.requestUsers)??[],d=((y=s==null?void 0:s.requestPolicy)==null?void 0:y.allowedUserIds)??[],a=j.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(u=>[u.userId,u])),[s==null?void 0:s.requestUsage]),v=u=>t(`access-${u}`,async()=>{const m=d.includes(u)?d.filter(g=>g!==u):[...d,u];await r(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(u)?"Request access granted.":"Request access removed."),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(S,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(S,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(G,{children:"Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The button stays hidden on every television until one of them is."}):null}),e.jsx(E,{title:"Request access and activity",intro:"One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state.",icon:"people",tone:"note",children:l.length===0?e.jsx(G,{children:"No one has signed in yet."}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"User"}),e.jsx("th",{children:"Last seen"}),e.jsx("th",{className:"num",children:"Sent to services"}),e.jsx("th",{children:"Last request"}),e.jsx("th",{children:"Access"})]})}),e.jsx("tbody",{children:l.map(u=>{const m=a.get(u.id),g=d.includes(u.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:u.username})}),e.jsx("td",{className:"muted nowrap",children:O(u.lastSeen)}),e.jsx("td",{className:"num",children:(m==null?void 0:m.requests)??0}),e.jsx("td",{className:"muted nowrap",children:m!=null&&m.lastRequest?O(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx(A,{size:"sm",variant:g?"quiet":"primary",busy:c===`access-${u.id}`,onClick:()=>void v(u.id),children:g?"Remove access":"Give access"})})]},u.id)})})]})})})]})]})}function Cn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(!1),a=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),y=(u,m,g)=>t(m,async()=>{await r(()=>F.post("/admin/api/for-you",{action:u}),g),d(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tracearr sessions",value:k((a==null?void 0:a.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:k((a==null?void 0:a.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:k((a==null?void 0:a.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:O(a==null?void 0:a.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(E,{title:"Pool maintenance",intro:"Prepared pools refresh in the background; these are the manual versions of the same work. A rebuild is safe at any time — televisions read the last finished pool until a new one lands.",icon:"sparkle",tone:"note",footer:e.jsx("span",{className:"hint",children:v?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"download",disabled:v,busy:c==="import",onClick:()=>void y("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(A,{icon:"sync",disabled:v,busy:c==="rebuild",onClick:()=>void y("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(E,{title:"Reading a person's scores",intro:"The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order.",icon:"search",tone:"info",actions:e.jsx(ee,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),l?e.jsx(he,{title:"Backfill all Tracearr history?",body:"Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while.",confirmLabel:"Backfill",busy:c==="full",onConfirm:()=>void y("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const Mn=[["Genre","genres"],["Studio","studios"],["Actor","actors"],["Director","directors"],["Franchise","franchises"],["Runtime","runtimeRanges"],["Age rating","ageRatings"],["Community rating","communityRatings"],["Release period","releasePeriods"],["Content type","contentTypes"]];function Rn(s){return s?Mn.flatMap(([n,o])=>Object.entries(s[o]??{}).map(([i,r])=>({dimension:n,name:i,weight:r.weight??0,evidence:r.evidence??0}))).sort((n,o)=>Math.abs(o.weight)-Math.abs(n.weight)):[]}const Ke=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function An(){const{status:s}=ie(),{wrap:n}=se(),{busy:o,run:i}=Y(),[r,c]=j.useState(""),[t,l]=j.useState("default"),[d,a]=j.useState("0"),[v,y]=j.useState(""),[u,m]=j.useState(null),[g,p]=j.useState("Choose a person to inspect their recommendations."),[b,x]=j.useState(""),w=(s==null?void 0:s.requestUsers)??[],h=()=>i("run",async()=>{if(!r){x("Choose a person to pressure-test.");return}x(""),p("Running the permission check and the scorer…");const N=new URLSearchParams({userId:r,context:t,minutes:d||"0",limit:"100"});v&&N.set("at",new Date(v).toISOString());const T=await n(()=>F.get(`/admin/api/recommendations?${N.toString()}`));T?(m(T),p(`Scored at ${new Date().toLocaleTimeString()}.`)):p("Pressure test failed.")}),C=Rn((u==null?void 0:u.profile)??null).slice(0,24),M=(u==null?void 0:u.actions)??[],L=(u==null?void 0:u.items)??[],H=(u==null?void 0:u.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(B,{message:b}),e.jsx(E,{title:"Run a pressure test",intro:"Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose.",icon:"search",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:o==="run",onClick:()=>void h(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:g})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r,onChange:N=>c(N.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),w.map(N=>e.jsx("option",{value:N.id,children:N.username},N.id))]})}),e.jsx(q,{label:"Context",children:e.jsxs("select",{value:t,onChange:N=>l(N.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(q,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:N=>a(N.target.value)})}),e.jsx(q,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:N=>y(N.target.value)})})]})}),u?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"prepared pool",value:k(u.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:k(u.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:k(L.length),icon:"sparkle",tone:"note"},{label:"source events",value:k(H.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:H.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:O(H.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(E,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[C.length===0?e.jsx(G,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:C.map(N=>e.jsxs(Q,{tone:N.weight<0?"bad":void 0,children:[N.dimension,": ",N.name," ",Ke(N.weight)," · n=",k(N.evidence)]},`${N.dimension}:${N.name}`))}),M.length===0?e.jsx(G,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:M.map((N,T)=>e.jsxs(Q,{tone:"ok",children:[N.action,": ",N.title||N.itemId]},`${N.action}:${T}`))})]}),L.length===0?e.jsx(E,{children:e.jsx(G,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(de,{children:L.map((N,T)=>{const X=N.explanation??{},re=Object.entries(X.components??{}).sort(($,z)=>Math.abs(z[1])-Math.abs($[1])),K=N.exposure??{},R=[N.type,N.year,N.runtimeMinutes?`${N.runtimeMinutes} min`:null,...N.genres??[]].filter(Boolean).join(" · ");return e.jsxs(E,{title:`#${T+1} · ${N.title}`,intro:R,actions:e.jsx(Q,{tone:"ok",children:Number(X.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[N.preparedReason||"No legacy prepared explanation",N.compatibilityLabel?` · ${N.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(X.reasonCodes??[]).map($=>e.jsx(Q,{tone:"ok",children:$},$)),re.map(([$,z])=>e.jsxs(Q,{tone:z<0?"bad":void 0,children:[$,"=",Ke(z)]},$))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",k(N.baseRank??0)," · base ",Number(N.baseScore??0).toFixed(3)," · affinity ",Number(N.affinityScore??0).toFixed(3)," · compatibility"," ",Number(N.compatibilityScore??0).toFixed(3)," · impressions"," ",k(K.impressions??0)," · focuses ",k(K.focuses??0)," · selects"," ",k(K.selects??0)]}),e.jsx("div",{className:"chips",children:(N.eligibleRows??[]).map($=>e.jsx(Q,{tone:"ok",children:$},$))}),N.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",N.preparedEvidenceTitle]}):null]})]},`${T}:${N.title}`)})})]}):null]})}const En=4,xe=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],ge=()=>({pinnedItems:[],primeSubtitle:""}),Le=[{value:1,short:"Mon",label:"Monday"},{value:2,short:"Tue",label:"Tuesday"},{value:3,short:"Wed",label:"Wednesday"},{value:4,short:"Thu",label:"Thursday"},{value:5,short:"Fri",label:"Friday"},{value:6,short:"Sat",label:"Saturday"},{value:0,short:"Sun",label:"Sunday"}];function Ge(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Ze(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function In(s){return s.frequency??"once"}function $n(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=Le.filter(i=>(s.weekdays??[]).includes(i.value));return`${(n.length===7?"Every day":n.map(i=>i.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function Je(s,n){const o=new Date;o.setMinutes(Math.ceil(o.getMinutes()/30)*30,0,0);const i=new Date(o.getTime()+2*60*60*1e3),r=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:o.toISOString(),endAt:i.toISOString(),priority:0,enabled:!0,placements:[r?n:"home"]}}function Tn(){var R,$,z;const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r,show:c}=se(),{busy:t,run:l}=Y(),[d,a]=j.useState("home"),[v,y]=j.useState({home:ge(),movies:ge(),tv_shows:ge()}),[u,m]=j.useState(!1),[g,p]=j.useState(""),[b,x]=j.useState(null),[w,h]=j.useState([]),[C,M]=j.useState(null),L=s==null?void 0:s.heroPolicy;j.useEffect(()=>{var f,D,I;u||!L||(y({home:((f=L.placements)==null?void 0:f.home)??{pinnedItems:L.pinnedItems??[],primeSubtitle:L.primeSubtitle??""},movies:((D=L.placements)==null?void 0:D.movies)??ge(),tv_shows:((I=L.placements)==null?void 0:I.tv_shows)??ge()}),h(L.schedules??[]))},[L,u]);const H=()=>l("search",async()=>{const f=g.trim();if(!f)return;const D=await r(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(f)}`));D&&x(D.items??[])}),N=v[d],T=N.pinnedItems??[],X=f=>{y(D=>({...D,[d]:{...D[d],...f}})),m(!0)},re=f=>{if(!T.some(D=>D.id===f.id)){if(T.length>=En){c("Remove a pinned title before adding another.","bad");return}X({pinnedItems:[...T,f]})}},K=()=>l("save",async()=>{await r(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([f,D])=>[f,{pinnedItemIds:(D.pinnedItems??[]).map(I=>I.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:w}),"Hero saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(de,{children:xe.map(f=>{var le;const D=v[f.id],I=w.filter(ue=>ue.enabled&&(ue.placements??["home"]).includes(f.id)).sort((ue,ks)=>ks.priority-ue.priority)[0],P=(D.pinnedItems??[]).length?"Manual":I?"Schedule ready":"Automatic",oe=(le=L==null?void 0:L.items)==null?void 0:le.find(ue=>ue.id===(I==null?void 0:I.itemId)),ce=(D.pinnedItems??[]).map(ue=>ue.name).join(", ")||(oe==null?void 0:oe.name)||(I==null?void 0:I.itemId)||"Resolved for each viewer";return e.jsx(E,{title:f.label,intro:`${P} · ${ce}`,tone:f.id===d?"info":void 0,children:e.jsxs(A,{size:"sm",variant:"quiet",onClick:()=>a(f.id),children:["Manage ",f.label]})},f.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:xe.map(f=>e.jsx(A,{variant:d===f.id?"primary":"quiet",onClick:()=>a(f.id),children:f.label},f.id))}),e.jsxs(E,{title:`${(R=xe.find(f=>f.id===d))==null?void 0:R.label} hero`,intro:`Pinned ${($=xe.find(f=>f.id===d))==null?void 0:$.type} lead this section only. Empty places use this placement’s automatic selection.`,icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void K(),children:"Save hero"}),e.jsx(A,{onClick:()=>{X({pinnedItems:[]})},children:"Clear pins"}),u?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[T.length===0?e.jsx(G,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:T.map((f,D)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:D+1}),e.jsxs("span",{children:[e.jsx("b",{children:f.name}),e.jsxs("small",{children:[f.type,f.year?` · ${f.year}`:""]})]}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>M(Je(f,d)),children:"Schedule"}),e.jsx(A,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${f.name}`,onClick:()=>X({pinnedItems:T.filter(I=>I.id!==f.id)})})]},f.id))}),e.jsx(q,{label:"Prime-card subtitle",hint:"Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason.",children:e.jsx("input",{type:"text",maxLength:160,value:N.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:f=>{X({primeSubtitle:f.target.value})}})})]}),e.jsx(E,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(S,{tone:"info",children:(L==null?void 0:L.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void K(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:w.length===0?e.jsx(G,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...w].sort((f,D)=>Number(D.enabled)-Number(f.enabled)||D.priority-f.priority).map(f=>{const D=[...(L==null?void 0:L.items)??[],...T,...b??[]].find(I=>I.id===f.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":f.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:f.frequency==="weekly"?"Weekly":f.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:f.frequency?f.startTime:f.startAt?new Date(f.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(D==null?void 0:D.name)??f.itemId}),e.jsx(S,{tone:f.enabled?"ok":void 0,children:f.enabled?"enabled":"paused"})]}),e.jsx("p",{children:$n(f)}),e.jsxs("div",{className:"chips",children:[(f.placements??["home"]).map(I=>{var P;return e.jsx("span",{className:"chip",children:(P=xe.find(oe=>oe.id===I))==null?void 0:P.label},I)}),f.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",f.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(A,{size:"sm",onClick:()=>M({...f}),children:"Edit"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{h(I=>I.map(P=>P.id===f.id?{...P,enabled:!P.enabled}:P)),m(!0)},children:f.enabled?"Pause":"Enable"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{h(I=>I.filter(P=>P.id!==f.id)),m(!0)},children:"Remove"})]})]},f.id)})})}),e.jsxs(E,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(z=xe.find(f=>f.id===d))==null?void 0:z.label} placement; switch tabs to show it in more than one section.`,icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(q,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:g,placeholder:"Search films and television shows",onChange:f=>p(f.target.value),onKeyDown:f=>{f.key==="Enter"&&H()}})}),e.jsx(A,{busy:t==="search",icon:"search",onClick:()=>void H(),children:"Search"})]}),b===null?null:b.length===0?e.jsx(G,{children:"No playable films or series matched that search."}):e.jsx(de,{children:b.map(f=>e.jsx(E,{title:f.name,intro:`${f.type||"Title"} · ${f.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(A,{size:"sm",icon:"plus",disabled:T.some(D=>D.id===f.id)||d==="movies"&&f.type!=="Movie"||d==="tv_shows"&&f.type!=="Series",onClick:()=>re(f),children:T.some(D=>D.id===f.id)?"Pinned":"Add to hero"}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>M(Je(f,d)),children:"Schedule"})]})},f.id))})]})]}),C?e.jsx(Ln,{schedule:C,item:[...(L==null?void 0:L.items)??[],...T,...b??[]].find(f=>f.id===C.itemId),timeZone:(L==null?void 0:L.timeZone)||"server local time",isNew:!w.some(f=>f.id===C.id),onCancel:()=>M(null),onSave:f=>{h(D=>D.some(I=>I.id===f.id)?D.map(I=>I.id===f.id?f:I):[...D,f]),M(null),m(!0)}}):null]})}function Ln({schedule:s,item:n,timeZone:o,isNew:i,onSave:r,onCancel:c}){const[t,l]=j.useState({...s,weekdays:[...s.weekdays??[]]}),d=In(t),a=p=>{const b=new Date,x=new Date(b.getTime()+2*60*60*1e3);l(w=>{var h;return p==="once"?{...w,frequency:void 0,startAt:Ze(w.startAt)?w.startAt:b.toISOString(),endAt:Ze(w.endAt)?w.endAt:x.toISOString()}:{...w,frequency:p,startTime:w.startTime||"18:00",endTime:w.endTime||"22:00",weekdays:p==="weekly"?(h=w.weekdays)!=null&&h.length?w.weekdays:[1,2,3,4,5]:[]}})},v=t.placements??["home"],y=p=>p==="home"||p==="movies"&&(n==null?void 0:n.type)==="Movie"||p==="tv_shows"&&(n==null?void 0:n.type)==="Series",u=!!(t.startAt&&t.endAt&&new Date(t.endAt)>new Date(t.startAt)),m=!!(t.startTime&&t.endTime&&t.startTime!==t.endTime&&(d!=="weekly"||(t.weekdays??[]).length>0)),g=d==="once"?u:m;return e.jsx("div",{className:"scrim",onPointerDown:p=>p.target===p.currentTarget&&c(),children:e.jsxs("div",{className:"dialog hero-schedule-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"hero-schedule-title",children:[e.jsxs("div",{className:"hero-schedule-dialog-head",children:[e.jsx("span",{className:"hero-schedule-kicker",children:"Hero schedule"}),e.jsx("h2",{id:"hero-schedule-title",children:(n==null?void 0:n.name)??t.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",o,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(p=>e.jsxs("button",{type:"button","aria-pressed":d===p,onClick:()=>a(p),children:[e.jsx("b",{children:p==="once"?"One time":p==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:p==="once"?"A date range":p==="daily"?"Same time daily":"Choose days"})]},p))}),d==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:t.startAt?Ge(new Date(t.startAt)):"",onChange:p=>l(b=>({...b,startAt:p.target.value?new Date(p.target.value).toISOString():void 0}))})}),e.jsx(q,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:t.endAt?Ge(new Date(t.endAt)):"",onChange:p=>l(b=>({...b,endAt:p.target.value?new Date(p.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts each time",children:e.jsx("input",{type:"time",value:t.startTime??"",onChange:p=>l(b=>({...b,startTime:p.target.value}))})}),e.jsx(q,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:t.endTime??"",onChange:p=>l(b=>({...b,endTime:p.target.value}))})})]}),d==="weekly"?e.jsxs("div",{className:"schedule-days",children:[e.jsxs("div",{className:"schedule-days-head",children:[e.jsx("b",{children:"Days"}),e.jsxs("div",{children:[e.jsx("button",{type:"button",onClick:()=>l(p=>({...p,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>l(p=>({...p,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>l(p=>({...p,weekdays:Le.map(b=>b.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:Le.map(p=>{const b=(t.weekdays??[]).includes(p.value);return e.jsx("button",{type:"button","aria-pressed":b,title:p.label,onClick:()=>l(x=>({...x,weekdays:b?(x.weekdays??[]).filter(w=>w!==p.value):[...x.weekdays??[],p.value]})),children:p.short},p.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:xe.map(p=>e.jsx(W,{label:p.label,checked:v.includes(p.id),disabled:!y(p.id),onChange:b=>l(x=>{const w=x.placements??["home"],h=b?[...w,p.id]:w.filter(C=>C!==p.id);return{...x,placements:h.length?[...new Set(h)]:w}})},p.id))})]}),e.jsx(q,{label:"Priority",hint:"Higher rules win when schedules overlap.",children:e.jsx("input",{type:"number",min:-1e3,max:1e3,step:10,value:t.priority,onChange:p=>l(b=>({...b,priority:Number(p.target.value)}))})})]}),e.jsx(W,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:t.enabled,onChange:p=>l(b=>({...b,enabled:p}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx(A,{variant:"primary",disabled:!g,onClick:()=>r(t),children:i?"Add rule":"Save rule"})]})]})})}function Dn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(null),a=s==null?void 0:s.features,v=(a==null?void 0:a.features)??[],y=(s==null?void 0:s.clients)??[],u=(a==null?void 0:a.revision)??0,m=(x,w,h,C)=>t(w,async()=>{await r(()=>F.post("/admin/api/features",{action:x,expectedRevision:u,overrides:C??{}}),h),d(null),await i()}),g=y.filter(x=>(x.capabilities??[]).includes("server_features_v1")).length,p=!!(a!=null&&a.safeMode),b=(x,w)=>({...Object.fromEntries(v.filter(h=>h.source==="override").map(h=>[h.key,h.enabled])),[x]:w});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(B,{message:n}),o||!a?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"Control plane",intro:"Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional.",icon:"sliders",tone:"ok",actions:e.jsx(A,{variant:p?void 0:"danger",busy:c==="safe",onClick:()=>p?void m("leave-safe-mode","safe","Safe mode ended."):d({action:"safe-mode",title:"Enable safe mode?",body:"Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.",label:"Enable safe mode"}),children:p?"Leave safe mode":"Enable safe mode"}),children:e.jsx(gs,{tiles:[{label:"features active",value:`${v.filter(x=>x.enabled).length} / ${v.length}`},{label:"explicit overrides",value:k(v.filter(x=>x.source==="override").length)},{label:"televisions reporting the control plane",value:`${g} / ${y.length}`},{label:"published revision",value:`r${k(u)}`}]})}),e.jsx(de,{cols:"2",children:v.length===0?e.jsx(E,{title:"Nothing registered",icon:"sliders",children:e.jsx(G,{children:"No server features are registered."})}):v.map(x=>e.jsxs(E,{title:x.name,intro:x.description,actions:e.jsx(S,{tone:x.enabled?"ok":void 0,children:x.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",x.recovery]}),children:[e.jsx(W,{label:x.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:x.enabled,disabled:c===x.key,onChange:w=>d({action:"feature",key:x.key,enabled:w,title:`${w?"Turn on":"Turn off"} ${x.name}?`,body:`${w?"Enable":"Disable"} this feature for every compatible television. ${x.recovery}`,label:w?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(Q,{children:x.key}),e.jsxs(Q,{children:["protocol ",k(x.minimumProtocol),"+"]}),e.jsx(Q,{tone:x.compatible?"ok":"warn",children:x.compatible?"server compatible":"compatibility blocked"}),e.jsx(Q,{tone:"note",children:x.area})]})]},x.key))}),e.jsx(E,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{disabled:!a.canRollback,onClick:()=>d({action:"rollback",title:"Roll back one revision?",body:"The previous published feature revision is restored on every television.",label:"Roll back"}),children:"Roll back one revision"}),e.jsx(A,{onClick:()=>d({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),p?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["live · revision r",k(u)]})]})})]}),l?e.jsx(he,{title:l.title,body:l.body,confirmLabel:l.label,destructive:l.action!=="rollback",busy:c===l.action,onConfirm:()=>void m(l.action==="feature"?"save":l.action,l.action==="feature"?l.key??"feature":l.action,`${l.label} done.`,l.action==="feature"&&l.key?b(l.key,!!l.enabled):void 0),onCancel:()=>d(null)}):null]})}function qn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r,show:c}=se(),{busy:t,run:l}=Y(),[d,a]=j.useState(!0),[v,y]=j.useState("6.5"),[u,m]=j.useState(!1);j.useEffect(()=>{var p,b;u||!s||(a(((p=s.playbackPolicy)==null?void 0:p.prerollEnabled)!==!1),y(String((((b=s.playbackPolicy)==null?void 0:b.prerollDurationMs)??6500)/1e3)))},[s,u]);const g=()=>l("save",async()=>{const p=Number(v);if(!Number.isFinite(p)||p<1||p>30){c("The preroll duration must be between 1 and 30 seconds.","bad");return}await r(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(p*1e3)}),"Playback policy saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:1}):e.jsxs(E,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:d?e.jsxs(S,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(S,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void g(),children:"Save playback policy"}),children:[e.jsx(W,{label:"Show the preroll before a title starts",checked:d,onChange:p=>{a(p),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Duration",hint:"Between 1 and 30 seconds. The stream is already playing behind it.",children:e.jsx("input",{type:"number",min:1,max:30,step:.5,value:v,onChange:p=>{y(p.target.value),m(!0)}})})})]})]})}function Fn(){const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(null),[a,v]=j.useState(null),[y,u]=j.useState(!1),m=s==null?void 0:s.subtitles,g=m==null?void 0:m.stored;j.useEffect(()=>{l||!m||d({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,l]);const p=h=>d(C=>C&&{...C,...h}),b=()=>t("save",async()=>{l&&(await r(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:l.bazarr,openSubtitlesEnabled:l.openSubtitles,openSubtitlesApiKey:l.key.trim(),clearOpenSubtitlesApiKey:l.clearKey,openSubtitlesUsername:l.username.trim(),openSubtitlesPassword:l.password,clearOpenSubtitlesLogin:l.clearLogin}),"Subtitle settings saved."),d(null),await i())}),x=()=>t("test",async()=>{v(null);const h=await r(()=>F.post("/admin/api/subtitle-test"));v((h==null?void 0:h.results)??[])}),w=()=>t("clear",async()=>{await r(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),u(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(B,{message:n}),o||!m||!l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:k((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:k((g==null?void 0:g.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:O(g==null?void 0:g.latest),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(E,{title:"Bazarr",intro:"Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it.",icon:"wrench",tone:"data",actions:m.bazarrConfigured?l.bazarr?e.jsx(S,{tone:"ok",children:"on"}):e.jsx(S,{children:"off"}):e.jsx(S,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:m.bazarrConfigured?`Configured at ${m.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(W,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:l.bazarr,disabled:!m.bazarrConfigured,onChange:h=>p({bazarr:h})})}),e.jsxs(E,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:m.openSubtitlesEnabled?e.jsx(S,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(S,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:l.openSubtitles,onChange:h=>p({openSubtitles:h})}),e.jsx(q,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:h=>p({key:h.target.value})})}),e.jsx(W,{label:"Remove the saved key",checked:l.clearKey,onChange:h=>p({clearKey:h})}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:l.username,placeholder:"Not signed in",onChange:h=>p({username:h.target.value})})}),e.jsx(q,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l.password,onChange:h=>p({password:h.target.value})})})]}),e.jsx(W,{label:"Sign out and forget the account",checked:l.clearLogin,onChange:h=>p({clearLogin:h})})]})]}),e.jsxs(E,{children:[e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void b(),children:"Save subtitle settings"}),e.jsx(A,{busy:c==="test",icon:"pulse",onClick:()=>void x(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:m.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),a===null?null:a.length===0?e.jsx(G,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:a.map(h=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:h.provider}),e.jsx("p",{children:h.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(S,{tone:h.ok?"ok":"bad",children:h.ok?"reachable":"not reachable"})})]},h.provider))})]}),e.jsx(E,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:g!=null&&g.count?e.jsxs(S,{tone:"data",children:[k(g.count)," files · ",fe(g.bytes)]}):e.jsx(S,{children:"nothing held"}),footer:e.jsx(A,{variant:"danger",disabled:!(g!=null&&g.count),onClick:()=>u(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),y?e.jsx(he,{title:"Delete every stored subtitle?",body:"Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby.",confirmLabel:"Delete",destructive:!0,busy:c==="clear",onConfirm:()=>void w(),onCancel:()=>u(!1)}):null]})}function Pn(){var z;const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(null),[a,v]=j.useState(!1),[y,u]=j.useState(!1),[m,g]=j.useState(""),[p,b]=j.useState(""),[x,w]=j.useState(!1),[h,C]=j.useState(null),[M,L]=j.useState(""),H=j.useRef(""),N=s==null?void 0:s.updatePolicy;j.useEffect(()=>{l||!N||d({version:N.latestVersion??"",url:N.downloadUrl??"",notes:N.notes??"",retireBelow:N.retireBelowVersion??"",required:!!N.minimumVersion&&N.minimumVersion===N.latestVersion,destructive:!!N.retireBelowVersion&&N.retireBelowVersion===N.latestVersion})},[N,l]),j.useEffect(()=>{let f=!0;const D=async()=>{try{const P=await F.get("/admin/api/release-builder");if(!f)return;C(P),L(""),P.state==="succeeded"&&P.finishedAt&&H.current!==P.finishedAt&&(H.current=P.finishedAt,d(null),await i())}catch(P){if(!f)return;L(P instanceof Error?P.message:"The release builder is unavailable.")}};D();const I=window.setInterval(()=>void D(),3e3);return()=>{f=!1,window.clearInterval(I)}},[i]);const T=f=>t(f?"save":"off",async()=>{l&&(await r(()=>F.post("/admin/api/update-policy",{enabled:f,latestVersion:l.version.trim(),downloadUrl:l.url.trim(),notes:l.notes.trim(),required:l.required,destructive:l.destructive,retireBelowVersion:l.retireBelow.trim()}),f?"Update policy saved.":"Update prompts turned off."),v(!1),d(null),await i())}),X=!!(N!=null&&N.minimumVersion)&&(N==null?void 0:N.minimumVersion)===(N==null?void 0:N.latestVersion),re=!!(N!=null&&N.retireBelowVersion)&&(N==null?void 0:N.retireBelowVersion)===(N==null?void 0:N.latestVersion),K=f=>d(D=>D&&{...D,...f}),R=()=>t("release",async()=>{const f=await r(()=>F.post("/admin/api/release-builder",{tag:m.trim(),notes:p.trim(),mandatory:x}),"Memby release started.");f&&(C(f),L(""),u(!1))}),$=(h==null?void 0:h.state)==="succeeded"?"ok":(h==null?void 0:h.state)==="failed"?"bad":(h==null?void 0:h.state)==="running"?"warn":void 0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(B,{message:n}),e.jsx(B,{message:M}),e.jsxs(E,{title:"Build and publish",intro:"Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway.",icon:"download",tone:"data",actions:e.jsx(S,{tone:$,children:(h==null?void 0:h.state)??"checking"}),footer:e.jsx(A,{variant:"primary",busy:c==="release"||(h==null?void 0:h.state)==="running",disabled:!!M,onClick:()=>x?u(!0):void R(),children:m.trim()?`Build ${m.trim()}`:"Build latest release"}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"GitHub tag override",hint:"Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades.",children:e.jsx("input",{type:"text",value:m,disabled:(h==null?void 0:h.state)==="running",placeholder:"v0.2.64 (blank uses latest)",onChange:f=>g(f.target.value)})}),e.jsx(q,{label:"Release notes",hint:"Leave blank to use this version's CHANGELOG entry.",children:e.jsx("input",{type:"text",value:p,disabled:(h==null?void 0:h.state)==="running",placeholder:"What's new on the television",onChange:f=>b(f.target.value)})})]}),e.jsx(W,{label:"Make this update required",hint:"Older televisions cannot dismiss the update prompt. You will confirm before the build starts.",checked:x,disabled:(h==null?void 0:h.state)==="running",onChange:w}),h!=null&&h.message?e.jsx(me,{tone:$,children:h.message}):null,(z=h==null?void 0:h.logs)!=null&&z.length?e.jsx("pre",{className:"code",children:h.logs.join(` -`)}):null,e.jsxs(me,{children:["Command-line fallback: ",e.jsx("code",{children:(h==null?void 0:h.fallback)??"docker compose run --rm --build memby-builder release"})]})]}),o||!l?e.jsx(_,{rows:1}):e.jsxs(E,{title:"Update policy",intro:"Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works.",icon:"download",tone:"info",actions:N!=null&&N.enabled?e.jsxs(S,{tone:X?"warn":"ok",children:[re?"sign-out · ":X?"required · ":"optional · ",N.latestVersion]}):e.jsx(S,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>l.required?v(!0):void T(!0),children:"Save policy"}),e.jsx(A,{busy:c==="off",onClick:()=>void T(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Latest version",children:e.jsx("input",{type:"text",value:l.version,placeholder:"0.2.63",onChange:f=>K({version:f.target.value})})}),e.jsx(q,{label:"APK URL",children:e.jsx("input",{type:"text",value:l.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:f=>K({url:f.target.value})})})]}),e.jsx(q,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:l.notes,placeholder:"One line the viewer reads",onChange:f=>K({notes:f.target.value})})}),e.jsx(q,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:l.retireBelow,placeholder:"0.2.44",onChange:f=>K({retireBelow:f.target.value})})}),e.jsx(W,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:l.required,onChange:f=>K({required:f})}),e.jsx(W,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:l.destructive,onChange:f=>K(f?{destructive:!0,required:!0,retireBelow:l.version.trim()}:{destructive:!1,retireBelow:l.retireBelow.trim()===l.version.trim()?"":l.retireBelow})})]}),a&&l?e.jsx(he,{title:l.destructive?"Sign every older television out?":"Require this update?",body:l.destructive?"This deletes sessions on every older television and forces viewers to sign in again after updating.":"Required updates block the home screen on every television below this version until they update.",confirmLabel:"Publish",destructive:l.destructive,busy:c==="save",onConfirm:()=>void T(!0),onCancel:()=>v(!1)}):null,y?e.jsx(he,{title:"Build a required update?",body:"When this signed APK is published, every older television will be blocked until it installs the update.",confirmLabel:"Build and publish",busy:c==="release",onConfirm:()=>void R(),onCancel:()=>u(!1)}):null]})}const On=2e4,Bn=3e3;function Ye(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Vn(){const{wrap:s}=se(),{busy:n,run:o}=Y(),[i,r]=j.useState(!1),{data:c,error:t,loading:l,reload:d}=J("/admin/api/tasks?limit=60",{pollMs:i?Bn:On}),a=(c==null?void 0:c.tasks)??[],v=a.some(x=>x.running);v!==i&&r(v);const y=x=>o(x.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(x.id)}/run`),`${x.name} started.`),await d()}),u=(x,w)=>o(`${x.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(x.id)}`,{enabled:w}),w?`${x.name} switched on.`:`${x.name} switched off.`),await d()}),m=a.filter(x=>{var w;return((w=x.lastRun)==null?void 0:w.status)==="failed"}).length,g=a.filter(x=>!x.enabled).length,p=(c==null?void 0:c.groups)??[],b=a.filter(x=>!x.group);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Scheduled tasks",intro:"The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."}),e.jsx(B,{message:t}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tasks",value:k(a.length),icon:"clock",tone:"info"},{label:"Running now",value:k(a.filter(x=>x.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:k(m),icon:"alert",tone:m>0?"bad":void 0},{label:"Switched off",value:k(g),icon:"power",tone:g>0?"warn":void 0}]}),m>0?e.jsx(me,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...p,...b.length>0?[""]:[]].map(x=>{const w=a.filter(h=>h.group===x);return w.length===0?null:e.jsx(E,{title:x||"Other",icon:x==="System"?"chip":x==="Analytics"?"chart":"wrench",tone:x==="System"?"info":x==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:w.map(h=>{var C;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[h.name," ",h.running?e.jsx(S,{tone:"info",children:"running"}):null,h.enabled?null:e.jsx(S,{tone:"warn",children:"off"})]}),e.jsx("p",{children:h.description}),e.jsxs("p",{className:"quiet",children:[Hs(h.intervalSeconds),h.enabled&&h.nextRun?` · next ${be(h.nextRun).replace(" ago","")}`:"",h.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:O(h.lastRun.startedAt),children:be(h.lastRun.startedAt)}),` in ${ke(h.lastRun.durationMs)}`,h.lastRun.detail?` — ${h.lastRun.detail}`:""]}):" · never run"]}),(C=h.lastRun)!=null&&C.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(S,{tone:"bad",children:h.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[h.lastRun?e.jsx(S,{tone:Ye(h.lastRun.status),children:h.lastRun.status}):e.jsx(S,{children:"never run"}),e.jsx(W,{label:"",checked:h.enabled,disabled:n===`${h.id}:enabled`,onChange:M=>void u(h,M)}),e.jsx(A,{size:"sm",icon:"play",busy:n===h.id,disabled:h.running,onClick:()=>void y(h),children:"Run now"})]})]},h.id)})})},x||"other")}),e.jsx(E,{title:"Recent runs",intro:"Every task together and in order, which is what shows two jobs interfering with each other.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Started"}),e.jsx("th",{children:"Task"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"}),e.jsx("th",{children:"Detail"})]})}),e.jsx("tbody",{children:((c==null?void 0:c.runs.length)??0)===0?e.jsx(te,{columns:6,children:"No task has run yet."}):c==null?void 0:c.runs.map(x=>{var w;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(x.startedAt),children:be(x.startedAt)}),e.jsx("td",{children:((w=a.find(h=>h.id===x.taskId))==null?void 0:w.name)??x.taskId}),e.jsx("td",{className:"muted",children:x.trigger}),e.jsx("td",{children:e.jsx(S,{tone:Ye(x.status),children:x.status})}),e.jsx("td",{className:"num muted",children:ke(x.durationMs)}),e.jsx("td",{className:"muted",children:x.error||x.detail||"—"})]},x.id)})})]})})})]})]})}const Un={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Wn(){const{wrap:s,show:n}=se(),{busy:o,run:i}=Y(),{data:r,error:c,loading:t,reload:l}=J("/admin/api/integrations",{pollMs:6e4}),[d,a]=j.useState(null),[v,y]=j.useState(null),u=(r==null?void 0:r.catalogue)??[],m=(r==null?void 0:r.integrations)??[],g=w=>a({id:w.id,name:w.name,url:"",enabled:w.enabled,events:w.events??[]}),p=()=>i("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(a(null),await l())}),b=w=>i("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(w.id)}`),`${w.name} removed.`),y(null),await l()}),x=w=>i(`test:${w.id}`,async()=>{const h=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(w.id)}/test`));h&&n(h.message,h.ok?"ok":"bad"),await l()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Integrations",intro:"Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere.",actions:e.jsx(A,{variant:"primary",icon:"plus",onClick:()=>a(Un),children:"Add a webhook"})}),e.jsx(B,{message:c}),e.jsx(_n,{}),e.jsx(zn,{}),e.jsx(Hn,{}),((r==null?void 0:r.dropped)??0)>0?e.jsxs(me,{tone:"warn",children:[k((r==null?void 0:r.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,t?e.jsx(_,{}):m.length===0&&!d?e.jsx(E,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(G,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):m.map(w=>e.jsx(Kn,{integration:w,catalogue:u,busy:o,onEdit:()=>g(w),onTest:()=>void x(w),onRemove:()=>y(w)},w.id)),d?e.jsx(Gn,{draft:d,catalogue:u,busy:o==="save",onChange:a,onSave:()=>void p(),onCancel:()=>a(null)}):null,v?e.jsx(he,{title:`Remove ${v.name}?`,body:"The webhook address and its delivery history go with it. Events already published stay in the activity feed.",confirmLabel:"Remove",destructive:!0,busy:o==="remove",onConfirm:()=>void b(v),onCancel:()=>y(null)}):null]})}function _n(){const{wrap:s}=se(),{busy:n,run:o}=Y(),{data:i,error:r,loading:c,reload:t}=J("/admin/api/arr-integrations"),l=d=>o("arr-integrations",async()=>{i&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:d.sonarrEnabled??i.sonarrEnabled,radarrEnabled:d.radarrEnabled??i.radarrEnabled}),"Integration settings saved."),await t())});return e.jsxs(E,{title:"Sonarr and Radarr",intro:"Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests.",icon:"plug",tone:"info",children:[e.jsx(B,{message:r??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(W,{label:"Sonarr enabled",hint:i!=null&&i.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(i!=null&&i.sonarrEnabled),disabled:!(i!=null&&i.sonarrConfigured)||n==="arr-integrations",onChange:d=>void l({sonarrEnabled:d})}),e.jsx(W,{label:"Radarr enabled",hint:i!=null&&i.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(i!=null&&i.radarrEnabled),disabled:!(i!=null&&i.radarrConfigured)||n==="arr-integrations",onChange:d=>void l({radarrEnabled:d})})]})]})}function zn(){const{wrap:s}=se(),{busy:n,run:o}=Y(),{data:i,error:r,loading:c,reload:t}=J("/admin/api/sonarr-request-policy"),[l,d]=j.useState(0),[a,v]=j.useState(!1);j.useEffect(()=>{i&&(d(i.qualityProfileId),v(i.searchImmediately))},[i]);const y=()=>o("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:l,searchImmediately:a}),"Sonarr TV request policy saved."),await t()}),u=i==null?void 0:i.profiles.find(m=>m.id===l);return e.jsxs(E,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(S,{tone:"ok",children:"configured"}):e.jsx(S,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="sonarr-request-policy",disabled:c||l<=0,onClick:()=>void y(),children:"Save Sonarr policy"}),children:[e.jsx(B,{message:r??(i==null?void 0:i.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:l,onChange:m=>d(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:a,onChange:v}),u?e.jsxs(me,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), be monitored using Memby’s existing all-episodes strategy, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Hn(){const{wrap:s}=se(),{busy:n,run:o}=Y(),{data:i,error:r,loading:c,reload:t}=J("/admin/api/radarr-request-policy"),[l,d]=j.useState(0),[a,v]=j.useState(!1);j.useEffect(()=>{i&&(d(i.qualityProfileId),v(i.searchImmediately))},[i]);const y=()=>o("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:l,searchImmediately:a}),"Radarr movie request policy saved."),await t()}),u=i==null?void 0:i.profiles.find(m=>m.id===l);return e.jsxs(E,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(S,{tone:"ok",children:"configured"}):e.jsx(S,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="radarr-request-policy",disabled:c||l<=0,onClick:()=>void y(),children:"Save Radarr policy"}),children:[e.jsx(B,{message:r??(i==null?void 0:i.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:l,onChange:m=>d(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:a,onChange:v}),u?e.jsxs(me,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), remain monitored, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Kn({integration:s,catalogue:n,busy:o,onEdit:i,onTest:r,onRemove:c}){const t=s.health,l=!t.lastFailure||t.lastSuccess&&t.lastSuccess>t.lastFailure,d=s.events??[];return e.jsxs(E,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(S,{tone:"ok",children:"on"}):e.jsx(S,{tone:"warn",children:"off"}),t.deliveries>0?e.jsx(S,{tone:l?"ok":"bad",children:l?"delivering":"failing"}):e.jsx(S,{children:"never used"}),e.jsx(A,{size:"sm",icon:"pulse",busy:o===`test:${s.id}`,onClick:r,children:"Test"}),e.jsx(A,{size:"sm",onClick:i,children:"Edit"}),e.jsx(A,{size:"sm",variant:"danger",icon:"trash",onClick:c,title:"Remove"})]}),children:[e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Events sent"}),e.jsx("p",{children:d.length===0?"None selected — this destination is configured but will never post anything.":d.map(a=>{var v;return((v=n.find(y=>y.type===a))==null?void 0:v.label)??a}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:t.lastSuccess?O(t.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:t.deliveries>0?e.jsxs("span",{className:"quiet",children:[k(t.deliveries)," attempts, ",k(t.failures)," failed"]}):null})]}),t.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[O(t.lastFailure),t.lastError?` — ${t.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Attempted"}),e.jsx("th",{children:"Event"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:s.deliveries.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(a.attemptedAt),children:be(a.attemptedAt)}),e.jsx("td",{className:"muted",children:a.eventType}),e.jsx("td",{children:a.success?e.jsx(S,{tone:"ok",children:a.statusCode||"ok"}):e.jsx(S,{tone:"bad",children:a.error||a.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ke(a.durationMs)})]},a.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(te,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Gn({draft:s,catalogue:n,busy:o,onChange:i,onSave:r,onCancel:c}){const t=[...new Set(n.map(d=>d.group))],l=(d,a)=>i({...s,events:a?[...s.events,d]:s.events.filter(v=>v!==d)});return e.jsxs(E,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:o,onClick:r,children:s.id?"Save":"Add"}),e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx("span",{className:"spacer"}),s.events.length===0?e.jsx("span",{className:"quiet",children:"Nothing selected — this destination would never post."}):e.jsxs("span",{className:"quiet",children:[s.events.length," events selected"]})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>i({...s,name:d.target.value})})}),e.jsx(q,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>i({...s,url:d.target.value})})})]}),e.jsx(W,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:d=>i({...s,enabled:d})}),t.map(d=>e.jsxs("div",{children:[e.jsx("div",{className:"card-head",style:void 0,children:e.jsx("div",{className:"card-head-text",children:e.jsx("h2",{children:d})})}),n.filter(a=>a.group===d).map(a=>e.jsx(W,{label:a.label,hint:a.description,checked:s.events.includes(a.type),onChange:v=>l(a.type,v)},a.type))]},d))]})}function Zn(){var p;const{status:s,error:n,loading:o,reload:i}=ie(),{wrap:r}=se(),{busy:c,run:t}=Y(),[l,d]=j.useState(""),[a,v]=j.useState(!1),[y,u]=j.useState(!1),m=!!((p=s==null?void 0:s.maintenance)!=null&&p.enabled);j.useEffect(()=>{var b;!y&&s&&d(((b=s.maintenance)==null?void 0:b.message)??"")},[s,y]);const g=b=>t(b?"on":"off",async()=>{await r(()=>F.post("/admin/api/maintenance",{enabled:b,message:l}),b?"Memby is offline for every television.":"Memby is back online."),v(!1),u(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:1}):e.jsx(E,{title:"Gateway availability",intro:"Takes Memby offline for every television, independently of Emby. Sign-in and all content calls answer 503 with the message below, and the television shows it in place of the launcher rows. This console keeps working.",icon:"power",tone:m?"bad":"warn",actions:m?e.jsx(S,{tone:"bad",children:"offline"}):e.jsx(S,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"danger",disabled:m,onClick:()=>v(!0),children:"Go offline"}),e.jsx(A,{disabled:!m,busy:c==="off",onClick:()=>void g(!1),children:"Bring back online"})]}),children:e.jsx(q,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:l,placeholder:"Back shortly — upgrading the server",onChange:b=>{d(b.target.value),u(!0)}})})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately and show your message in place of the launcher. This console keeps working.",confirmLabel:"Go offline",destructive:!0,busy:c==="on",onConfirm:()=>void g(!0),onCancel:()=>v(!1)}):null]})}function Jn(){const{status:s,error:n,loading:o}=ie(),i=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(B,{message:n}),o?e.jsx(_,{rows:1}):e.jsx(E,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(te,{columns:8,children:"Nothing has been imported yet."}):i.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(r.startedAt)}),e.jsx("td",{children:r.kind}),e.jsx("td",{className:"muted",children:r.trigger}),e.jsx("td",{children:e.jsx(S,{tone:r.status==="success"?"ok":r.status==="running"?"warn":"bad",children:r.status})}),e.jsx("td",{className:"num",children:k(r.itemsSeen)}),e.jsx("td",{className:"num",children:k(r.itemsUpserted)}),e.jsx("td",{className:"num",children:k(r.itemsRemoved)}),e.jsx("td",{className:"muted",children:r.error||""})]},r.id||r.startedAt))})]})})})]})}const Xe={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},Qe=2e4,Yn=5e3,ye=48,Xn=31,es=8,Qn=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],ss=new Map(Qn.map((s,n)=>[s,n])),et=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ns=new WeakMap;function we(s){const n=ns.get(s);if(n)return n;const o=Object.entries(s.attributes??{}).sort((r,c)=>{const t=ss.get(r[0])??(r[0]==="error"?1e3:100),l=ss.get(c[0])??(c[0]==="error"?1e3:100);return t-l||r[0].localeCompare(c[0])}),i={fields:o,summary:o.map(([r,c])=>`${r}=${String(c)}`).join(" "),haystack:[s.message,...o.flat()].join(" ").toLowerCase(),occurred:et.format(new Date(s.occurredAt))};return ns.set(s,i),i}const st=s=>s.replace(/_/g," "),nt=j.memo(function({event:n,index:o,onInspect:i}){const r=we(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${o*ye}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[r.occurred,e.jsxs("small",{children:["#",n.sequence]})]}),e.jsx("span",{className:"lvl",children:n.level}),e.jsx("span",{className:"msg",title:n.message,children:n.message}),e.jsx("button",{type:"button",className:"logattrs-button",title:r.summary||"No structured details",onClick:()=>i(n.sequence),children:r.summary||"View record"})]})});function tt(){var D;const[s,n]=j.useState([]),[o,i]=j.useState(0),[r,c]=j.useState(!1),[t,l]=j.useState("INFO"),[d,a]=j.useState(""),[v,y]=j.useState(""),[u,m]=j.useState({top:0,height:600}),[g,p]=j.useState(null),b=j.useDeferredValue(d.trim().toLowerCase()),x=j.useRef(0),w=j.useRef(!1),h=j.useRef(0),C=j.useRef(null),M=j.useRef(!0),L=j.useRef(void 0),H=j.useCallback(async()=>{if(r||w.current||document.hidden)return;w.current=!0;const I=h.current,P=[];let oe=0;try{let ce=0,le;do le=await F.get(`/admin/api/events?after=${x.current}&limit=1000`),x.current=le.next||x.current,oe+=le.dropped||0,P.push(...le.events??[]),ce+=1;while(le.hasMore&&ce<20);y("")}catch(ce){y(ce instanceof Error?ce.message:String(ce))}finally{P.length>0&&I===h.current&&n(ce=>{const le=ce.concat(P);return le.length>Qe?le.slice(le.length-Qe):le}),oe>0&&I===h.current&&i(ce=>ce+oe),w.current=!1}},[r]);j.useEffect(()=>{if(r)return;let I;const P=()=>{window.clearInterval(I),I=document.hidden?void 0:window.setInterval(()=>void H(),Yn)},oe=()=>{P(),document.hidden||H()};return H(),P(),document.addEventListener("visibilitychange",oe),()=>{window.clearInterval(I),document.removeEventListener("visibilitychange",oe)}},[H,r]);const N=j.useMemo(()=>{const I=Xe[t]??20;return s.filter(P=>(Xe[P.level]??0)>=I&&(!b||we(P).haystack.includes(b)))},[s,t,b]),T=((D=N.at(-1))==null?void 0:D.sequence)??0,X=Math.max(0,u.top-Xn),re=Math.ceil(u.height/ye)+es*2,K=Math.min(Math.max(0,Math.floor(X/ye)-es),Math.max(0,N.length-re)),R=N.slice(K,K+re),$=j.useMemo(()=>s.find(I=>I.sequence===g),[s,g]);j.useLayoutEffect(()=>{const I=C.current;!I||!M.current||(I.scrollTop=I.scrollHeight,m({top:I.scrollTop,height:I.clientHeight}))},[T,b,t]),j.useEffect(()=>()=>window.cancelAnimationFrame(L.current??0),[]);const z=()=>{const I=C.current;I&&(M.current=I.scrollHeight-I.scrollTop-I.clientHeight{m({top:I.scrollTop,height:I.clientHeight})}))},f=()=>{const I=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),P=document.createElement("a");P.href=URL.createObjectURL(I),P.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,P.click(),window.setTimeout(()=>URL.revokeObjectURL(P.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(B,{message:v}),e.jsxs(E,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Level",children:e.jsxs("select",{value:t,onChange:I=>l(I.target.value),children:[e.jsx("option",{value:"DEBUG",children:"Debug and above"}),e.jsx("option",{value:"INFO",children:"Info and above"}),e.jsx("option",{value:"WARN",children:"Warnings and errors"}),e.jsx("option",{value:"ERROR",children:"Errors only"})]})}),e.jsx(q,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:d,placeholder:"Person, television, title, component, path…",onChange:I=>a(I.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(A,{onClick:()=>c(I=>!I),icon:r?"play":"clock",children:r?"Resume":"Pause"}),e.jsx(A,{onClick:()=>{h.current+=1,n([]),i(0),p(null)},children:"Clear view"}),e.jsx(A,{onClick:f,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:C,onScroll:z,role:"log","aria-label":"Server events",children:[e.jsxs("div",{className:"loghead","aria-hidden":"true",children:[e.jsx("span",{children:"Time"}),e.jsx("span",{children:"Level"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Details"})]}),N.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match this filter."}):e.jsx("div",{className:"logbody",style:{height:`${N.length*ye}px`},children:R.map((I,P)=>e.jsx(nt,{event:I,index:K+P,onInspect:p},I.sequence))})]}),e.jsxs("p",{className:"hint",children:[k(s.length)," retained · ",k(N.length)," matching",N.length?` · ${k(R.length)} rows mounted`:"",o?` · ${k(o)} overwritten before delivery`:"",r?" · paused":""]}),$?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${$.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:$.message}),e.jsxs("span",{children:[we($).occurred," · ",$.level," · record #",$.sequence]})]}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>p(null),children:"Close"})]}),we($).fields.length?e.jsx("dl",{children:we($).fields.map(([I,P])=>e.jsxs(j.Fragment,{children:[e.jsx("dt",{children:st(I)}),e.jsx("dd",{children:String(P)})]},I))}):e.jsx(me,{children:"No structured details were attached to this record."})]}):null]})]})}const ts=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","notifications","profiles","settings"];function pe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function it(){const[s,n]=j.useState(30),[o,i]=j.useState(""),r=De(),c=j.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:o})}`,[s,o]),{data:t,error:l,loading:d}=J(c),a=t==null?void 0:t.stats,v=(t==null?void 0:t.users)??[],y=(t==null?void 0:t.actions)??[],u=(t==null?void 0:t.paths)??[],m=u[0],g=j.useMemo(()=>{const p=new Map(((t==null?void 0:t.features)??[]).map(x=>[x.feature,x])),b=new Map(ts.map((x,w)=>[x,w]));return[...new Set([...ts,...p.keys()])].map(x=>({name:x,stat:p.get(x)})).sort((x,w)=>{var C,M;const h=(((C=w.stat)==null?void 0:C.uses)??0)-(((M=x.stat)==null?void 0:M.uses)??0);return h||(b.get(x.name)??Number.MAX_SAFE_INTEGER)-(b.get(w.name)??Number.MAX_SAFE_INTEGER)})},[t==null?void 0:t.features]);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(B,{message:l}),e.jsx(E,{title:"Journey health",intro:"Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored.",icon:"people",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:p=>n(Number(p.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(q,{label:"User",children:e.jsxs("select",{value:o,onChange:p=>{const b=p.target.value;i(b),b&&r(`/admin/journeys/${encodeURIComponent(b)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(p=>e.jsx("option",{value:p.userId,children:p.username||p.userId},p.userId))]})})]}),children:d?e.jsx(_,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"journeys",value:k(a==null?void 0:a.journeys),icon:"list",tone:"data"},{label:"viewers",value:k(a==null?void 0:a.viewers),icon:"people",tone:"info"},{label:"completion",value:Te(a==null?void 0:a.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:k(a==null?void 0:a.abandoned),icon:"alert",tone:"note"},{label:"active now",value:k(a==null?void 0:a.active),icon:"pulse",tone:"info"},{label:"average steps",value:((a==null?void 0:a.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ke(a==null?void 0:a.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(t==null?void 0:t.retentionDays)??90} days`,small:!0,icon:"clock"}]}),e.jsxs("div",{className:"summary-grid",children:[e.jsxs("div",{className:"summary",children:[e.jsxs("div",{className:"summary-head",children:[e.jsx("b",{children:"Visit completion"}),e.jsx("strong",{children:Te(a==null?void 0:a.completionRate)})]}),e.jsx(Ys,{value:(a==null?void 0:a.completed)??0,total:(a==null?void 0:a.journeys)??0}),e.jsxs("p",{children:[k(a==null?void 0:a.completed)," completed · ",k(a==null?void 0:a.abandoned)," abandoned ·"," ",k(a==null?void 0:a.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:m?`${pe(m.from)} → ${pe(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${k(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(de,{cols:"2",children:[e.jsx(E,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Action"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{className:"num",children:"Visits"})]})}),e.jsx("tbody",{children:y.length===0?e.jsx(te,{columns:3,children:"No significant actions in this window."}):y.map(p=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:pe(p.action)}),e.jsx("span",{className:"table-sub",children:pe(p.category)})]}),e.jsx("td",{className:"num",children:k(p.events)}),e.jsx("td",{className:"num",children:k(p.journeys)})]},`${p.category}:${p.action}`))})]})})}),e.jsx(E,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(te,{columns:2,children:"No repeated paths in this window."}):u.map((p,b)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[pe(p.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",pe(p.to)]}),e.jsx("td",{className:"num",children:k(p.count)})]},`${p.from}:${p.to}:${b}`))})]})})})]}),e.jsx(E,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Feature"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{children:"Last used"}),e.jsx("th",{children:"Status"})]})}),e.jsx("tbody",{children:g.map(({name:p,stat:b})=>{const x=(b==null?void 0:b.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:pe(p)}),e.jsx("td",{className:"num",children:k(x)}),e.jsx("td",{className:"muted nowrap",children:b?O(b.lastUsedAt):"—"}),e.jsx("td",{children:x===0?e.jsx(S,{tone:"warn",children:"not used"}):x<3?e.jsx(S,{tone:"note",children:"rare"}):e.jsx(S,{tone:"ok",children:"used"})})]},p)})})]})})}),o?null:e.jsx(E,{title:"Inspect a viewer",intro:"Choose a person above to open their dedicated session and viewing-journey timeline.",icon:"journey",tone:"info",children:e.jsx("p",{className:"empty",children:"A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys."})})]})}const je=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},ys=s=>je((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),is=s=>s.itemName?`${je(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${je(s.source)} → ${je(s.target)}`:ys(s),at=s=>({journey_start:"Opened Memby",home_open:"Opened Memby",journey_end:"Finished session",screen_view:"Viewed",select:"Selected",open:"Opened",close:"Closed",request:s.category==="playback"?"Started watching":"Requested",stop:"Stopped watching",start:"Started",complete:"Completed"})[s.action]??je(s.action);function rt(s){var o;const n=(o=[...s].reverse().find(i=>i.outcome))==null?void 0:o.outcome;return n==="success"||n==="completed"?{label:je(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:je(n),tone:"note"}:s.some(i=>i.action==="stop"&&i.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function lt(s){const n=s.reduce((o,i,r)=>(i.category==="playback"&&i.action==="request"&&o.push(r),o),[]);return n.length===0?[s]:n.map((o,i)=>s.slice(i===0?0:o,n[i+1]??s.length))}function ot(){var d,a;const{userId:s=""}=Re(),n=j.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:o,error:i,loading:r}=J(n),c=((a=(d=o==null?void 0:o.users)==null?void 0:d.find(v=>v.userId===s))==null?void 0:a.username)||s,t=j.useMemo(()=>{const v=new Map;for(const y of(o==null?void 0:o.events)??[])v.set(y.journeyId,[...v.get(y.journeyId)??[],y]);return[...v.values()].map(y=>y.sort((u,m)=>u.sequence-m.sequence)).sort((y,u)=>{var m,g;return(((m=u[0])==null?void 0:m.occurredAt)??"").localeCompare(((g=y[0])==null?void 0:g.occurredAt)??"")})},[o==null?void 0:o.events]),l=t.flatMap(v=>lt(v).map((y,u)=>{var m;return{events:y,key:`${(m=v[0])==null?void 0:m.journeyId}:${u}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:`${c}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(ee,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(B,{message:i}),r?e.jsx(_,{}):e.jsx(E,{title:"Viewing journeys",intro:`${t.length} app session${t.length===1?"":"s"} · ${l.length} viewing journey${l.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:l.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):l.map((v,y)=>{const u=v.events,m=u[0],g=[...u].reverse().find(b=>b.itemName||b.action==="select"||b.category==="playback"&&b.action==="request"),p=rt(u);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:O(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",y+1," · ",u.length," recorded steps"]})]}),e.jsx(S,{tone:p.tone,children:p.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(ne,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:ys(m)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(ne,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:g?is(g):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(ne,{name:p.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:p.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:u.map(b=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":b.action}),e.jsxs("div",{children:[e.jsx("b",{children:at(b)}),e.jsx("span",{children:is(b)})]}),e.jsx("time",{children:O(b.occurredAt)})]},`${b.journeyId}:${b.sequence}`))})]},v.key)})})})]})}function as(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function ct(){const[s,n]=j.useState(30),{data:o,error:i,loading:r}=J(`/admin/api/analytics?days=${s}`),c=(o==null?void 0:o.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(B,{message:i}),e.jsx(E,{title:"Launcher rows",intro:"Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth.",icon:"chart",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:t=>n(Number(t.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:r?e.jsx(_,{rows:1}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Row"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{className:"num",children:"Dwell"}),e.jsx("th",{className:"num",children:"Impressions"}),e.jsx("th",{className:"num",children:"Focuses"}),e.jsx("th",{className:"num",children:"Opened"}),e.jsx("th",{className:"num",children:"Open rate"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:8,children:"No events in this window."}):c.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:as(t.rowId)}),e.jsx("td",{className:"muted",children:as(t.rowKind)}),e.jsx("td",{className:"num",children:ke(t.dwellMs)}),e.jsx("td",{className:"num",children:k(t.impressions)}),e.jsx("td",{className:"num",children:k(t.focuses)}),e.jsx("td",{className:"num",children:k(t.selects)}),e.jsx("td",{className:"num",children:Te(t.selectRate)}),e.jsx("td",{className:"num",children:k(t.viewers)})]},`${t.rowId}:${t.rowKind}`))})]})})})]})}function dt(){const[s,n]=j.useState(7),{data:o,error:i,loading:r}=J(`/admin/api/searches?days=${s}`),c=(o==null?void 0:o.terms)??[],t=(o==null?void 0:o.recent)??[],l=o==null?void 0:o.totals;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(B,{message:i}),e.jsx(ae,{tiles:[{label:"searches",value:k((l==null?void 0:l.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:k((l==null?void 0:l.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:k((l==null?void 0:l.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(o==null?void 0:o.retentionDays)??30} days`,small:!0,icon:"clock"}]}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(E,{title:"What the house looks for",intro:"Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too.",icon:"search",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:d=>n(Number(d.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Query"}),e.jsx("th",{className:"num",children:"Searches"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Last searched"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:4,children:"Nothing searched in this window."}):c.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.query}),e.jsx("td",{className:"num",children:k(d.searches)}),e.jsx("td",{className:"num",children:k(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:O(d.lastAt)})]},d.query))})]})})}),e.jsx(E,{title:"As it happened",intro:"The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:3,children:"No searches in this window."}):t.map((d,a)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:O(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(S,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${a}`))})]})})})]})]})}function rs(s,n){if(n===0)return s>0?"new this week":"no change";const o=Math.round((s-n)/n*100);return`${o>0?"+":""}${o}% vs last week`}function ht(){const{data:s,error:n,loading:o}=J("/admin/api/views",{pollMs:6e4}),i=(s==null?void 0:s.daily)??[],r=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(B,{message:n}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:rs((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:k(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:rs((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:k(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(E,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet."}):i.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:k(c.visits)}),e.jsx("td",{className:"num",children:k(c.viewers)})]},c.label))})]})})}),e.jsx(E,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:r.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet today."}):r.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:k(c.visits)}),e.jsx("td",{className:"num",children:k(c.viewers)})]},c.label))})]})})})]})]})}const ut=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function mt(){var r;const s=J("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:o}=Y(),i=(c,t)=>o(`${c.id}-${t}`,async()=>{await F.post(`/admin/api/media-reports/${c.id}/status`,{status:t}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(_,{rows:4}):e.jsx(E,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(r=s.data)!=null&&r.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(c=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ut(c)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.title})]}),e.jsxs("td",{children:[c.reason.replaceAll("_"," "),c.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.comment})]}):null]}),e.jsxs("td",{children:[c.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(S,{tone:c.replacementRequested?"note":void 0,children:c.replacementRequested?c.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(S,{tone:c.status==="resolved"?"ok":c.status==="dismissed"?void 0:"warn",children:c.status})}),e.jsx("td",{className:"nowrap muted",children:O(c.createdAt)}),e.jsxs("td",{children:[e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-acknowledged`,onClick:()=>void i(c,"acknowledged"),children:"Acknowledge"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-resolved`,onClick:()=>void i(c,"resolved"),children:"Resolve"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-dismissed`,onClick:()=>void i(c,"dismissed"),children:"Dismiss"})]})]},c.id))})]})}):e.jsx(G,{children:"No media problems have been reported."})})]})}function pt(){return e.jsx(V,{title:"No such page",intro:"That address is not part of the console. Use the search in the bar above, or the sections on the left."})}function xt(){return e.jsx(Ms,{children:e.jsx(Us,{children:e.jsx(Zs,{children:e.jsx(sn,{children:e.jsx(Rs,{children:e.jsxs(U,{path:"/admin",element:e.jsx(Qs,{}),children:[e.jsx(U,{index:!0,element:e.jsx(nn,{})}),e.jsx(U,{path:"activity",element:e.jsx(an,{})}),e.jsx(U,{path:"accounts",element:e.jsx(rn,{})}),e.jsx(U,{path:"accounts/:userId",element:e.jsx(ln,{})}),e.jsx(U,{path:"accounts/:userId/settings",element:e.jsx(mn,{})}),e.jsx(U,{path:"clients",element:e.jsx(xn,{})}),e.jsx(U,{path:"logins",element:e.jsx(vn,{})}),e.jsx(U,{path:"devices/:deviceId",element:e.jsx(yn,{})}),e.jsx(U,{path:"library",element:e.jsx(wn,{})}),e.jsx(U,{path:"ratings",element:e.jsx(Nn,{})}),e.jsx(U,{path:"requests",element:e.jsx(Sn,{})}),e.jsx(U,{path:"recommendations",element:e.jsx(Cn,{})}),e.jsx(U,{path:"inspector",element:e.jsx(An,{})}),e.jsx(U,{path:"hero",element:e.jsx(Tn,{})}),e.jsx(U,{path:"features",element:e.jsx(Dn,{})}),e.jsx(U,{path:"playback",element:e.jsx(qn,{})}),e.jsx(U,{path:"subtitles",element:e.jsx(Fn,{})}),e.jsx(U,{path:"updates",element:e.jsx(Pn,{})}),e.jsx(U,{path:"tasks",element:e.jsx(Vn,{})}),e.jsx(U,{path:"integrations",element:e.jsx(Wn,{})}),e.jsx(U,{path:"maintenance",element:e.jsx(Zn,{})}),e.jsx(U,{path:"imports",element:e.jsx(Jn,{})}),e.jsx(U,{path:"logs",element:e.jsx(tt,{})}),e.jsx(U,{path:"journeys",element:e.jsx(it,{})}),e.jsx(U,{path:"journeys/:userId",element:e.jsx(ot,{})}),e.jsx(U,{path:"views",element:e.jsx(ht,{})}),e.jsx(U,{path:"engagement",element:e.jsx(ct,{})}),e.jsx(U,{path:"searches",element:e.jsx(dt,{})}),e.jsx(U,{path:"media-reports",element:e.jsx(mt,{})}),e.jsx(U,{path:"overview",element:e.jsx(As,{to:"/admin",replace:!0})}),e.jsx(U,{path:"*",element:e.jsx(pt,{})})]})})})})})})}const ws=document.getElementById("root");if(!ws)throw new Error("the console has no root element to render into");cs(ws).render(e.jsx(j.StrictMode,{children:e.jsx(xt,{})})); diff --git a/admin-ui/dist/assets/index-B-5op1DD.js b/admin-ui/dist/assets/index-B-5op1DD.js new file mode 100644 index 0000000..4a9669a --- /dev/null +++ b/admin-ui/dist/assets/index-B-5op1DD.js @@ -0,0 +1,12 @@ +import{r as p,a as Ss,u as qe,L as ee,b as Fe,m as $e,O as Cs,N as Ms,c as Re,B as Rs,R as Es,d as U,e as As}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))t(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const i of c.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&t(i)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function t(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();var os={exports:{}},Ee={};/** + * @license React + * react-jsx-runtime.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Is=p,$s=Symbol.for("react.element"),Ts=Symbol.for("react.fragment"),Ls=Object.prototype.hasOwnProperty,Ds=Is.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,qs={key:!0,ref:!0,__self:!0,__source:!0};function cs(s,n,r){var t,l={},c=null,i=null;r!==void 0&&(c=""+r),n.key!==void 0&&(c=""+n.key),n.ref!==void 0&&(i=n.ref);for(t in n)Ls.call(n,t)&&!qs.hasOwnProperty(t)&&(l[t]=n[t]);if(s&&s.defaultProps)for(t in n=s.defaultProps,n)l[t]===void 0&&(l[t]=n[t]);return{$$typeof:$s,type:s,key:c,ref:i,props:l,_owner:Ds.current}}Ee.Fragment=Ts;Ee.jsx=cs;Ee.jsxs=cs;os.exports=Ee;var e=os.exports,ds,Be=Ss;ds=Be.createRoot,Be.hydrateRoot;const hs={overview:"M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",library:"M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4",people:"M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2",person:"M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",tv:"M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5",sliders:"M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8",pulse:"M3 12h3.5L9 19l5-14 2.5 7H21",chart:"M4 19V9m5 10V5m5 14v-7m5 7V3",chip:"M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22",database:"M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6",download:"M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15",upload:"M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",sync:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",search:"M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",star:"m12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z",sparkle:"m10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z",bell:"M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0",shield:"m12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6",wrench:"m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",play:"M8 5.2v13.6L19 12 8 5.2ZM4 5v14",list:"M4 7h16M4 12h16M4 17h10",inbox:"M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",history:"M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8",check:"m5 12.5 4.5 4.5L19 7.5",alert:"M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z",power:"M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0",key:"M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z",captions:"M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",journey:"M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",plug:"M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3",calendar:"M4 6h16v15H4zM8 3v5M16 3v5M4 11h16",trash:"M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5",plus:"M12 5v14M5 12h14",close:"M6 6l12 12M18 6 6 18",caret:"m6 9 6 6 6-6",external:"M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10",refresh:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",filter:"M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z",menu:"M4 7h16M4 12h16M4 17h16",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z",logout:"M15 17l5-5-5-5M20 12H9M12 4H5v16h7"};function ne({name:s,className:n}){const r=hs[s];return r?e.jsx("svg",{className:n??"ico",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",preserveAspectRatio:"xMidYMid meet","aria-hidden":"true",children:e.jsx("path",{d:r})}):null}function Ae({name:s,tone:n}){return hs[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(ne,{name:s})}):null}const ve=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],Fs=ve.flatMap(s=>s.items),Ps=ve.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Ve extends Error{constructor(n,r){super(n),this.status=r,this.name="ApiError"}}const Os=5*60*1e3;let us=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{us=Date.now()},{passive:!0});const Bs=()=>Date.now()-us({}));throw new Ve(t.error??`Request failed (${r.status})`,r.status)}if(r.status!==204)return await r.json()}function Se(s){const n=new URLSearchParams;for(const[t,l]of Object.entries(s))l==null||l===""||l===!1||n.set(t,String(l));const r=n.toString();return r?`?${r}`:""}const F={get:s=>Ce(s),post:(s,n)=>Ce(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Ce(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Ce(s,{method:"DELETE"})},Us=3e4,ms=p.createContext(null);function Ws({children:s}){var u;const[n,r]=p.useState(),[t,l]=p.useState(!0),[c,i]=p.useState(""),[o,d]=p.useState(""),[a,v]=p.useState(!0),k=p.useRef(0),h=p.useCallback(async()=>{const b=++k.current;try{const j=await F.get("/admin/api/status");if(b!==k.current)return;r(j),l(!0),d("")}catch(j){if(b!==k.current)return;l(!1),d(j instanceof Error?j.message:String(j))}finally{b===k.current&&(i(new Date().toISOString()),v(!1))}},[]),m=p.useCallback(async b=>{var j;await F.post("/admin/api/maintenance",{enabled:b,message:((j=n==null?void 0:n.maintenance)==null?void 0:j.message)??""}),await h()},[h,(u=n==null?void 0:n.maintenance)==null?void 0:u.message]);p.useEffect(()=>{h();let b;const j=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void h(),Us)},S=()=>{j(),document.hidden||h()};return j(),document.addEventListener("visibilitychange",S),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",S)}},[h]);const g=p.useMemo(()=>({status:n,version:(n==null?void 0:n.serverVersion)??"",online:t,checkedAt:c,error:o,loading:a,reload:h,setMaintenance:m}),[n,t,c,o,a,h,m]);return e.jsx(ms.Provider,{value:g,children:s})}function ie(){const s=p.useContext(ms);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function _s(s,n){const r=s.label.toLowerCase(),t=s.group.toLowerCase();return r.startsWith(n)?4:r.includes(n)?3:t.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function zs(){const s=qe(),{status:n}=ie(),[r,t]=p.useState(!1),[l,c]=p.useState(""),[i,o]=p.useState(0),d=p.useRef(null),a=p.useRef(null),v=p.useMemo(()=>{const h=l.trim().toLowerCase();return[...Ps,...((n==null?void 0:n.requestUsers)??[]).map(g=>({id:`user-${g.id}`,path:`/admin/accounts/${encodeURIComponent(g.id)}`,label:g.username||"Unnamed user",title:`User: ${g.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(g=>({id:`device-${g.deviceId}`,path:`/admin/devices/${encodeURIComponent(g.deviceId)}`,label:g.deviceName||"Unnamed device",title:`Device: ${g.deviceName||"Unnamed device"}`,intro:`${g.username||"Unknown user"} · ${g.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((g,u)=>({item:g,rank:h?_s(g,h):1,index:u})).filter(g=>g.rank>0).sort((g,u)=>u.rank-g.rank||g.index-u.index).map(({item:g,rank:u})=>({item:g,rank:u}))},[l]);p.useEffect(()=>o(0),[l]),p.useEffect(()=>{const h=m=>{var g;(g=d.current)!=null&&g.contains(m.target)||t(!1)};return document.addEventListener("pointerdown",h),()=>document.removeEventListener("pointerdown",h)},[]),p.useEffect(()=>{const h=m=>{var u,b;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const g=document.activeElement;g&&(g.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(g.tagName))||(m.preventDefault(),(u=a.current)==null||u.focus(),(b=a.current)==null||b.select())};return window.addEventListener("keydown",h),()=>window.removeEventListener("keydown",h)},[]);const k=h=>{var m;t(!1),c(""),(m=a.current)==null||m.blur(),s(h)};return e.jsxs("div",{className:"omni",ref:d,"data-open":r||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(ne,{name:"search"}),e.jsx("input",{ref:a,type:"search",value:l,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":r,onFocus:()=>t(!0),onChange:h=>{c(h.target.value),t(!0)},onKeyDown:h=>{var m;if(h.key==="Escape")c(""),t(!1),(m=a.current)==null||m.blur();else if(h.key==="ArrowDown")h.preventDefault(),o(g=>Math.min(g+1,v.length-1));else if(h.key==="ArrowUp")h.preventDefault(),o(g=>Math.max(g-1,0));else if(h.key==="Enter"){const g=v[i];if(!g)return;h.preventDefault(),k(g.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),r?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((h,m)=>e.jsxs("a",{className:m===i?"omni-item on":"omni-item",href:h.item.path,role:"option","aria-selected":m===i,onPointerEnter:()=>o(m),onClick:g=>{g.preventDefault(),k(h.item.path)},children:[h.item.icon?e.jsx(ne,{name:h.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:h.item.label}),e.jsx("small",{children:h.item.intro})]}),e.jsx("span",{className:"omni-group",children:h.item.group})]},h.item.id))}):null]})}const N=s=>(s??0).toLocaleString(),O=s=>s?new Date(s).toLocaleString():"—",Hs=s=>s?new Date(s).toLocaleTimeString():"—";function ke(s){if(!s)return"0s";if(s<1e3)return`${Math.round(s)}ms`;const n=Math.round(s/1e3);if(n<60)return`${n}s`;const r=Math.floor(n/60);return r<60?`${r}m ${n%60}s`:`${Math.floor(r/60)}h ${r%60}m`}function Te(s){if(!s||s<=0)return"on request only";if(s<60)return`every ${s}s`;const n=Math.round(s/60);if(n<60)return`every ${n} min`;const r=Math.round(n/60);return r<48?r===1?"hourly":`every ${r} hours`:`every ${Math.round(r/24)} days`}function fe(s){const n=["B","KB","MB","GB"];let r=Number(s??0),t=0;for(;r>=1024&&tString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Le=s=>`${Math.round((s??0)*100)}%`;function be(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const r=Math.floor(n/1e3);if(r<45)return"just now";const t=Math.floor(r/60);if(t<60)return`${t} min ago`;const l=Math.floor(t/60);if(l<24)return`${l}h ago`;const c=Math.floor(l/24);return c<30?`${c}d ago`:new Date(s).toLocaleDateString()}const xs=15*60*1e3,Ks=3*60*60*1e3,Ne=s=>!!s&&Date.now()-new Date(s).getTime(){var j;try{const S=await F.get(`/admin/api/notifications?limit=${We}`);r(S.events),l(S.unread),i(S.types),k.current=Math.max(k.current,((j=S.events[0])==null?void 0:j.id)??0),v("")}catch(S){v(S instanceof Error?S.message:String(S))}},[]),m=p.useCallback(j=>{k.current=Math.max(k.current,j.id),r(S=>S.some(x=>x.id===j.id)?S:[j,...S].sort((x,y)=>y.id-x.id).slice(0,We)),j.readAt||l(S=>S+1),i(S=>S.some(x=>x.type===j.type)?S.map(x=>x.type===j.type?{...x,count:x.count+1}:x):[...S,{type:j.type,count:1}])},[]);p.useEffect(()=>{h()},[h]),p.useEffect(()=>{let j=null,S,x=!1;return(()=>{x||(j=new EventSource(`/admin/api/notifications/stream?after=${k.current}`),j.addEventListener("open",()=>{d(!0),window.clearInterval(S),S=void 0}),j.addEventListener("admin",R=>{try{m(JSON.parse(R.data))}catch{}}),j.addEventListener("error",()=>{d(!1),S===void 0&&(S=window.setInterval(()=>void h(),Gs))}))})(),()=>{x=!0,j==null||j.close(),window.clearInterval(S)}},[m,h]);const g=p.useCallback(async j=>{const S=j.filter(x=>x>0);if(S.length!==0){r(x=>x.map(y=>S.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const x=await F.post("/admin/api/notifications/read",{ids:S});l(x.unread)}catch{h()}}},[h]),u=p.useCallback(async()=>{l(0),r(j=>j.map(S=>S.readAt?S:{...S,readAt:new Date().toISOString()}));try{const j=await F.post("/admin/api/notifications/read",{all:!0});l(j.unread)}catch{h()}},[h]),b=p.useMemo(()=>({events:n,unread:t,types:c,connected:o,error:a,markRead:g,markAllRead:u,reload:h}),[n,t,c,o,a,g,u,h]);return e.jsx(js.Provider,{value:b,children:s})}function Oe(){const s=p.useContext(js);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function vs(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function bs(s){return s.startsWith("auth.")?"key":s.startsWith("device.")?"tv":s.startsWith("admin.")?"shield":s.startsWith("task.")?"clock":s.startsWith("integration.")?"plug":s.startsWith("library.")?"library":s.startsWith("emby.")?"globe":s.startsWith("server.")?"power":"bell"}function _e(s){return{"auth.login":"Signed in","auth.login_failed":"Sign-in refused","auth.logout":"Signed out","device.registered":"New device","device.removed":"Device removed","device.renamed":"Device renamed","admin.sign_in":"Admin sign-in","server.started":"Server started","server.maintenance":"Maintenance","task.completed":"Task finished","task.failed":"Task failed","integration.failed":"Integration failed","integration.test":"Integration test","library.sync":"Library sync","emby.unreachable":"Emby unreachable","emby.recovered":"Emby recovered"}[s]??s.replace(/[._]/g," ")}const ze=99;function Js(){const{events:s,unread:n,connected:r,markRead:t,markAllRead:l}=Oe(),[c,i]=p.useState(!1),o=p.useRef(null);return p.useEffect(()=>{const d=v=>{var k;(k=o.current)!=null&&k.contains(v.target)||i(!1)},a=v=>{v.key==="Escape"&&i(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",a),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",a)}},[]),p.useEffect(()=>{if(!c)return;const d=s.filter(a=>!a.readAt).map(a=>a.id);d.length>0&&t(d)},[c]),e.jsxs("div",{className:"bell",ref:o,"data-open":c||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":c,onClick:()=>i(d=>!d),children:[e.jsx(ne,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>ze?`${ze}+`:n}):null]}),c?e.jsxs("div",{className:"bell-panel",children:[e.jsxs("div",{className:"bell-head",children:[e.jsx("b",{children:"Activity"}),e.jsxs("div",{className:"row tight",children:[r?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void l(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(d=>{const a=e.jsxs(e.Fragment,{children:[e.jsx(Ae,{name:bs(d.type),tone:vs(d)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:d.title||d.type}),d.summary?e.jsx("p",{children:d.summary}):null,e.jsx("time",{dateTime:d.occurredAt,children:be(d.occurredAt)})]})]});return d.link?e.jsx(ee,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>i(!1),children:a},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:a},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(ee,{to:"/admin/activity",onClick:()=>i(!1),children:"All activity"})})]}):null]})}function V({title:s,intro:n,actions:r,crumbs:t,icon:l}){var o;const c=Fe(),i=l??((o=Fs.find(d=>$e({path:d.path,end:!0},c.pathname)))==null?void 0:o.icon);return e.jsxs("header",{className:"page-head",children:[t?e.jsx("nav",{className:"crumbs",children:t}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[i?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(ne,{name:i})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),r?e.jsx("div",{className:"page-head-actions",children:r}):null]})]})}function I({title:s,intro:n,icon:r,tone:t,actions:l,footer:c,children:i}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[r?e.jsx(Ae,{name:r,tone:t}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),l?e.jsx("div",{className:"card-head-actions",children:l}):null]}):null,i,c?e.jsx("div",{className:"card-foot",children:c}):null]})}function de({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function ae({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Ae,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function C({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function Q({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function G({children:s}){return e.jsx("p",{className:"empty",children:s})}function te({columns:s,children:n}){return e.jsx("tr",{children:e.jsx("td",{colSpan:s,className:"muted",children:e.jsx("p",{className:"empty",children:n})})})}function me({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function A({children:s,onClick:n,variant:r,size:t,disabled:l,busy:c,icon:i,type:o="button",title:d}){return e.jsxs("button",{type:o,className:"","data-variant":r,"data-size":t,disabled:l||c,onClick:n,title:d,children:[c?e.jsx("span",{className:"spinner"}):i?e.jsx(ne,{name:i}):null,s]})}function q({label:s,hint:n,children:r,grow:t}){return e.jsxs("label",{className:t?"field grow":"field",children:[e.jsx("span",{children:s}),r,n?e.jsx("small",{children:n}):null]})}function W({label:s,hint:n,checked:r,onChange:t,disabled:l}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:r,disabled:l,onChange:c=>t(c.target.checked)}),e.jsx("span",{className:"switch"}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:s}),n?e.jsx("p",{children:n}):null]})]})}function Me({value:s,options:n,onChange:r}){return e.jsx("div",{className:"segments",role:"group",children:n.map(t=>e.jsx("button",{type:"button","aria-pressed":t.value===s,onClick:()=>r(t.value),children:t.label},String(t.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function gs({data:s,labelOf:n,valueOf:r,toneOf:t,title:l}){if(s.length===0)return e.jsx(G,{children:"Nothing in this window."});const c=s.map(o=>r(o)),i=Math.max(1,...c);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((o,d)=>{const a=r(o);return e.jsx("div",{className:"bar","data-tone":t==null?void 0:t(o),"data-empty":a===0||void 0,style:{height:`${Math.max(2,a/i*100)}%`},title:l?l(o):`${n(o,d)}: ${a}`},d)})}),e.jsxs("div",{className:"bars-axis",children:[e.jsx("span",{children:n(s[0],0)}),e.jsx("span",{children:n(s[s.length-1],s.length-1)})]})]})}function Ys({value:s,total:n,tone:r}){const t=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":r,children:e.jsx("div",{style:{width:`${t*100}%`}})})}function B({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(ne,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})}):null]}):null}function _({rows:s=3}){return e.jsxs("div",{className:"loading-page","aria-busy":"true","aria-label":"Loading",children:[e.jsxs("div",{className:"loading-heading",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]}),e.jsx("div",{className:"loading-tiles",children:Array.from({length:4},(n,r)=>e.jsx("span",{className:"skeleton"},r))}),Array.from({length:s},(n,r)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},r))]})}function he({title:s,body:n,confirmLabel:r="Confirm",destructive:t,busy:l,onConfirm:c,onCancel:i}){const o=p.useId(),d=p.useRef(null);return p.useEffect(()=>{var v;(v=d.current)==null||v.focus();const a=k=>{k.key==="Escape"&&i()};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[i]),e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":o,ref:d,tabIndex:-1,children:[e.jsx("h2",{id:o,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{onClick:i,variant:"quiet",children:"Cancel"}),e.jsx(A,{onClick:c,variant:t?"danger":"primary",busy:l,children:r})]})]})})}function He({rows:s}){return e.jsx("div",{className:"kv",children:s.map(n=>e.jsxs("div",{className:"kv-row",children:[e.jsx("span",{children:n.label}),e.jsx("span",{children:n.value})]},n.label))})}function fs({tiles:s}){return e.jsx("div",{className:"tiles plain",children:s.map(n=>e.jsxs("div",{className:"tile",children:[e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function Xs({open:s,onNavigate:n}){const{unread:r}=Oe(),t=Fe(),[l,c]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),i=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};p.useEffect(()=>{const d=ve.find(a=>a.items.some(v=>$e({path:v.path,end:!0},t.pathname)));d&&c(a=>{const v={...a};return ve.forEach(k=>{k.collapsible!==!1&&(v[k.id]=k.id!==d.id)}),i(v),v})},[t.pathname]);const o=(d,a=!1)=>{c(v=>{const k={...v},h=!(v[d]??a);return ve.forEach(m=>{m.collapsible!==!1&&(k[m.id]=m.id===d?h:!0)}),i(k),k})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:ve.map(d=>{const a=d.items.filter(m=>!m.hidden);if(a.length===0)return null;const v=a.some(m=>$e({path:m.path,end:!0},t.pathname)),k=d.collapsible!==!1,h=v||!k||!(l[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&k?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":h,onClick:()=>o(d.id,d.defaultCollapsed),children:[d.label,e.jsx(ne,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,h?a.map(m=>e.jsx(Ms,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:g})=>g?"on":"","aria-current":void 0,children:({isActive:g})=>e.jsxs("span",{style:{display:"contents"},ref:u=>{const b=u==null?void 0:u.parentElement;b&&(g?b.setAttribute("aria-current","page"):b.removeAttribute("aria-current"))},children:[m.icon?e.jsx(ne,{name:m.icon}):null,m.label,m.badge==="notifications"&&r>0?e.jsx("span",{className:"rail-badge",children:r>99?"99+":r}):null]})},m.id)):null]},d.id)})})}function Qs(){var g;const{version:s,online:n,checkedAt:r,status:t,setMaintenance:l}=ie(),[c,i]=p.useState(!1),[o,d]=p.useState(!1),[a,v]=p.useState(!1),k=Fe(),h=!!((g=t==null?void 0:t.maintenance)!=null&&g.enabled),m=async()=>{if(!(o||!t)){d(!0);try{await l(!h)}finally{d(!1)}}};return p.useEffect(()=>i(!1),[k.pathname]),p.useEffect(()=>{if(!c)return;const u=document.body.style.overflow;document.body.style.overflow="hidden";const b=j=>{j.key==="Escape"&&i(!1)};return window.addEventListener("keydown",b),()=>{document.body.style.overflow=u,window.removeEventListener("keydown",b)}},[c]),e.jsxs(e.Fragment,{children:[e.jsx("a",{className:"skip",href:"#main",children:"Skip to content"}),e.jsxs("header",{className:"topbar",children:[e.jsxs("a",{className:"topbar-brand",href:"/admin",children:[e.jsx("span",{className:"brand-mark","aria-hidden":"true",children:"M"}),e.jsx("span",{className:"brand-word",children:"Memby Gateway"})]}),e.jsx("button",{type:"button",className:"rail-toggle","aria-label":"Sections","aria-expanded":c,"aria-controls":"rail",onClick:()=>i(u=>!u),children:e.jsx(ne,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(zs,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":n&&!h?"ok":"bad","aria-pressed":h,disabled:!t||o,title:h?"Bring Memby back online":"Take Memby offline",onClick:()=>h?void m():v(!0),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:h?"offline":n?"online":"not responding"}),e.jsx("span",{children:h?"click to bring back online":r?`updated ${Hs(r)}`:""})]}),e.jsx(Js,{}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsx("button",{type:"submit","data-variant":"quiet","data-size":"sm",title:"Sign out",children:e.jsx(ne,{name:"logout"})})})]})]}),c?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>i(!1)}):null,e.jsx(Xs,{open:c,onNavigate:()=>i(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Cs,{})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available.",confirmLabel:"Go offline",destructive:!0,busy:o,onConfirm:()=>{v(!1),m()},onCancel:()=>v(!1)}):null]})}const ys=p.createContext(null),en=5e3;function sn({children:s}){const[n,r]=p.useState([]),t=p.useRef(1),l=p.useCallback(d=>{r(a=>a.filter(v=>v.id!==d))},[]),c=p.useCallback((d,a="ok")=>{const v=t.current++;r(k=>[...k,{id:v,message:d,tone:a}]),window.setTimeout(()=>l(v),en)},[l]),i=p.useCallback(async(d,a)=>{try{const v=await d();return a&&c(a,"ok"),v}catch(v){c(v instanceof Error?v.message:String(v),"bad");return}},[c]),o=p.useMemo(()=>({show:c,wrap:i}),[c,i]);return e.jsxs(ys.Provider,{value:o,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(d=>e.jsxs("div",{className:"toast","data-tone":d.tone,children:[e.jsx(ne,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>l(d.id),"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})})]},d.id))})]})}function se(){const s=p.useContext(ys);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:r,enabled:t=!0}=n,[l,c]=p.useState(),[i,o]=p.useState(""),[d,a]=p.useState(t),[v,k]=p.useState(!1),h=p.useRef(0),m=p.useRef(!1),g=p.useCallback(async()=>{if(!t)return;const u=++h.current;m.current&&k(!0);try{const b=await F.get(s);if(u!==h.current)return;c(b),o(""),m.current=!0}catch(b){if(u!==h.current)return;o(b instanceof Error?b.message:String(b))}finally{u===h.current&&(a(!1),k(!1))}},[s,t]);return p.useEffect(()=>(m.current=!1,a(!0),g(),()=>{h.current+=1}),[g]),p.useEffect(()=>{if(!r||!t)return;let u;const b=()=>{window.clearInterval(u),u=document.hidden?void 0:window.setInterval(()=>void g(),r)},j=()=>{b(),document.hidden||g()};return b(),document.addEventListener("visibilitychange",j),()=>{window.clearInterval(u),document.removeEventListener("visibilitychange",j)}},[r,t,g]),{data:l,error:i,loading:d,refreshing:v,reload:g,set:c}}function Y(){const[s,n]=p.useState(null),r=p.useRef(!0);p.useEffect(()=>()=>{r.current=!1},[]);const t=p.useCallback(async(l,c)=>{n(l);try{return await c(),!0}finally{r.current&&n(null)}},[]);return{busy:s,run:t}}function nn(){var b,j,S,x,y;const{status:s,error:n,loading:r}=ie(),t=J("/admin/api/runtime",{pollMs:3e4}),l=J("/admin/api/views",{pollMs:6e4});if(r||!s)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(_,{})]});const c=s.features??{features:[],revision:0,safeMode:!1},i=c.features??[],o=s.clients??[],d=o.filter(R=>Ne(R.lastSeen)).length,a=s.updatePolicy??{},v=!!a.minimumVersion&&a.minimumVersion===a.latestVersion,k=s.playbackPolicy,h=s.mdblist,m=s.forYou,g=(s.runs??[]).slice(0,5),u=t.data;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(ae,{tiles:[{label:"items in the library",value:N(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:N((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:N(o.length),icon:"tv",tone:"info"},{label:`visits today · ${((b=l.data)==null?void 0:b.lastWeek.visits)??0} this time last week`,value:N((j=l.data)==null?void 0:j.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((S=l.data)==null?void 0:S.lastWeek.viewers)??0} this time last week`,value:N((x=l.data)==null?void 0:x.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${i.filter(R=>R.enabled).length} / ${i.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(He,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(C,{tone:"bad",children:"offline for maintenance"}):e.jsx(C,{tone:"ok",children:"online"})},{label:"Feature control plane",value:c.safeMode?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["revision r",N(c.revision)]})},{label:"App update prompt",value:a.enabled?e.jsxs(C,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",a.latestVersion]}):e.jsx(C,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(C,{tone:"warn",children:"running"}):e.jsxs(C,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(k==null?void 0:k.prerollEnabled)===!1?e.jsx(C,{children:"off"}):e.jsxs(C,{tone:"ok",children:[((k==null?void 0:k.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(I,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(He,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"MDBList ratings",value:h!=null&&h.enabled?e.jsxs(C,{tone:"ok",children:[N(h.cachedTitles)," titles stored"]}):e.jsx(C,{children:h!=null&&h.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(C,{tone:"warn",children:"rebuilding"}):e.jsxs(C,{children:[N((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:N((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(ee,{to:"/admin/imports",children:"All imports"}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Written"})]})}),e.jsx("tbody",{children:g.length===0?e.jsx(te,{columns:4,children:"No imports have run yet."}):g.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(R.startedAt)}),e.jsx("td",{children:R.kind}),e.jsx("td",{children:e.jsx(C,{tone:R.status==="success"?"ok":R.status==="running"?"warn":"bad",children:R.status})}),e.jsx("td",{className:"num",children:N(R.itemsUpserted)})]},R.id||R.startedAt))})]})})}),e.jsx(I,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:u?e.jsxs(e.Fragment,{children:[e.jsx(fs,{tiles:[{label:"goroutines",value:N(u.goroutines)},{label:"heap in use",value:fe(u.heapInuse)},{label:"reserved",value:fe(u.sys)},{label:"collections",value:N(u.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",fe(u.nextGc)," · memory limit"," ",u.memoryLimit>0&&u.memoryLimit`/admin/api/notifications${Se({days:l,type:i,severity:d,unread:v,limit:g,offset:h*g})}`,[l,i,d,v,h]),{data:b,error:j,loading:S,reload:x}=J(u),y=async()=>{await r(),await x()};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Activity",intro:"Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from.",actions:s>0?e.jsx(A,{onClick:()=>void y(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(B,{message:j}),e.jsx(ae,{tiles:[{label:"Events in window",value:N((b==null?void 0:b.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:N(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:N((b==null?void 0:b.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:l,options:tn.map(R=>({value:R.value,label:R.label})),onChange:R=>{c(R),m(0)}})}),e.jsx(q,{label:"Kind",children:e.jsxs("select",{value:i,onChange:R=>{o(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((b==null?void 0:b.types)??[]).map(R=>e.jsxs("option",{value:R.type,children:[_e(R.type)," (",R.count,")"]},R.type))]})}),e.jsx(q,{label:"Severity",children:e.jsxs("select",{value:d,onChange:R=>{a(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Any"}),e.jsx("option",{value:"info",children:"Information"}),e.jsx("option",{value:"warning",children:"Warning"}),e.jsx("option",{value:"error",children:"Error"})]})}),e.jsx(q,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:R=>{k(R.target.value==="unread"),m(0)},children:[e.jsx("option",{value:"",children:"All"}),e.jsx("option",{value:"unread",children:"Unread only"})]})}),e.jsx("div",{className:"filter-actions",children:e.jsx(A,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{x(),t()},children:"Refresh"})})]}),S?e.jsx(_,{}):e.jsx(I,{title:"Events",icon:"bell",tone:"info",footer:((b==null?void 0:b.total)??0)>g?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:h===0,onClick:()=>m(h-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(h+1)*g>=((b==null?void 0:b.total)??0),onClick:()=>m(h+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((b==null?void 0:b.events.length)??0)===0?e.jsx(te,{columns:6,children:"Nothing has happened in this window."}):b==null?void 0:b.events.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(R.occurredAt),children:be(R.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Ae,{name:bs(R.type),tone:vs(R)}),_e(R.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:R.title}),R.summary?e.jsx("div",{className:"muted",children:R.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:R.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:R.target||"—"}),e.jsxs("td",{className:"nowrap",children:[R.readAt?null:e.jsx(C,{tone:"ok",children:"new"}),R.link?e.jsx(ee,{className:"table-row-link",to:R.link,children:"Open"}):null]})]},R.id))})]})})})]})}function rn(){const{data:s,error:n,loading:r}=J("/admin/api/accounts",{pollMs:6e4}),t=(s==null?void 0:s.accounts)??[],l=t.flatMap(o=>o.devices??[]),c=t.filter(o=>{var d;return(d=o.recommendations)==null?void 0:d.completed}).length,i=t.filter(o=>{var d,a;return((d=o.recommendations)==null?void 0:d.prompted)&&!((a=o.recommendations)!=null&&a.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(B,{message:n}),e.jsx(me,{tone:"info",children:"This is the Memby user list, not the Emby user directory. A person appears here only after signing in to the Memby app. Removing access signs their Memby devices out and does not delete or change their Emby account."}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Memby users",value:N(t.length),icon:"people",tone:"note"},{label:"signed-in devices",value:N(l.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(l.filter(o=>Ne(o.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:N(c),icon:"check",tone:"ok"},{label:"setup prompts queued",value:N(i),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:t.length===0?e.jsx(G,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):t.map(o=>{var h,m;const d=o.devices??[],a=d.filter(g=>Ne(g.lastSeen)).length,v=(h=o.recommendations)!=null&&h.completed?{label:"personalised",tone:"ok"}:(m=o.recommendations)!=null&&m.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},k=Pe(o.lastSeen);return e.jsxs(ee,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(o.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:ps(o.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[o.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":k.tone,title:k.label})]}),e.jsxs("span",{className:"list-meta",children:[N(d.length)," device",d.length===1?"":"s",a?` · ${a} active now`:""," · last seen ",O(o.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(C,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},o.id)})})]})]})}function Ie(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function ln(){const{userId:s=""}=Re(),n=qe(),{wrap:r}=se(),{busy:t,run:l}=Y(),c=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:i,error:o,loading:d,reload:a}=J("/admin/api/accounts",{pollMs:3e4}),[v,k]=p.useState(null),[h,m]=p.useState(null),[g,u]=p.useState(null),[b,j]=p.useState(null),[S,x]=p.useState(null),y=((i==null?void 0:i.accounts)??[]).find(E=>E.id===s),R=(i==null?void 0:i.catalogue)??[],M=(i==null?void 0:i.themes)??[];p.useEffect(()=>{var E;v===null&&y&&k({...((E=y.settings)==null?void 0:E.preferences)??{}})},[y,v]),p.useEffect(()=>{g===null&&y&&u({...y.notifications})},[y,g]),p.useEffect(()=>{if(h!==null||!y)return;const E=y.themes??[];m(E.length===0?M.map(T=>T.id):E)},[y,h,M]);const H=p.useMemo(()=>{const E=[];for(const T of R){let z=E.find(f=>f.name===T.area);z||E.push(z={name:T.area,definitions:[]}),z.definitions.push(T)}return E},[R]),w=(E,T,z,f)=>l(E,async()=>{const D=await r(T,z);j(null),D!==void 0&&(f==null||f()),await a()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(_,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(B,{message:o}),e.jsx(I,{children:e.jsx(G,{children:"This user is no longer signed in to Memby."})})]});const L=y.devices??[],X=L.filter(E=>Ne(E.lastSeen)).length,re=y.settings??{},K=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:y.username||"Unnamed user",intro:`Memby user · ${N(L.length)} device${L.length===1?"":"s"} · last seen ${O(y.lastSeen)}`,crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:ps(y.username)}),X?e.jsxs(C,{tone:"ok",children:[X," active now"]}):e.jsx(C,{children:"idle"}),e.jsx(Q,{children:y.id})]})}),e.jsx(B,{message:o}),e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Devices",intro:"Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed.",icon:"tv",tone:"info",children:L.length===0?e.jsx(G,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:L.map(E=>{const T=Pe(E.lastSeen);return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":T.tone,title:T.label})," ",e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(E.id)}`,children:E.name||"Memby TV"})]}),e.jsxs("p",{children:[E.version?`Memby ${E.version}`:"Legacy Memby client"," · ",T.label," · last seen ",O(E.lastSeen)," · signed in ",O(E.signedInAt)]}),(E.versions??[]).length>0?e.jsx("div",{className:"chips",children:(E.versions??[]).map(z=>e.jsxs(Q,{tone:z.version===E.version?"ok":void 0,children:[z.version,z.version===E.version?" · now":""]},z.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(A,{size:"sm",disabled:!E.id,onClick:()=>x({id:E.id,name:E.name}),children:"Rename"}),e.jsx(A,{size:"sm",variant:"danger",disabled:!E.id,onClick:()=>j({kind:"remove-device",deviceId:E.id,name:E.name}),children:"Sign out"})]})]},E.id||E.name)})})}),e.jsxs(I,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:K.completed?e.jsx(A,{busy:t==="reset-rec",onClick:()=>j({kind:"reset-recommendations"}),children:"Clear stored choices"}):K.prompted?e.jsx(A,{onClick:()=>j({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(A,{variant:"primary",busy:t==="prompt",onClick:()=>void w("prompt",()=>F.put(`${c}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:K.completed?e.jsx(C,{tone:"ok",children:"completed"}):K.prompted?e.jsx(C,{tone:"warn",children:"prompt queued"}):e.jsx(C,{children:"not invited"})}),e.jsx(on,{prompt:K})]})]}),e.jsx(I,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:g!=null&&g.enabled?e.jsx(C,{tone:"ok",children:"enabled"}):e.jsx(C,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="notifications",onClick:()=>void w("notifications",()=>F.put(`${c}/notifications`,g),"Notification settings saved.",()=>u(null)),children:"Save notifications"}),e.jsx(A,{onClick:()=>{u(null),a()},children:"Discard changes"})]}),children:g?e.jsxs("div",{className:"checks columns",children:[e.jsx(W,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:g.enabled,onChange:E=>u(T=>T&&{...T,enabled:E})}),e.jsx(W,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:g.showReturnAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,showReturnAlerts:E})}),e.jsx(W,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:g.sonarrAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,sonarrAlerts:E})}),e.jsx(W,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:g.radarrAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,radarrAlerts:E})}),e.jsx(W,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:g.updateAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,updateAlerts:E})}),e.jsx(W,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:g.libraryAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,libraryAlerts:E})}),e.jsx(W,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:g.systemAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,systemAlerts:E})})]}):null}),e.jsx(I,{title:"Settings",intro:"These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off.",icon:"sliders",tone:"ok",actions:re.saved?e.jsxs(C,{tone:re.source==="admin"?"warn":"ok",children:["r",N(re.revision)," · ",re.source||"device"," · ",O(re.updatedAt)]}):e.jsx(C,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="push",onClick:()=>void w("push",()=>F.put(`${c}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>k(null)),children:"Push to their televisions"}),e.jsx(A,{onClick:()=>{k(null),a()},children:"Discard changes"}),e.jsx(A,{onClick:()=>j({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(ee,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:H.map(E=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:E.name}),E.definitions.map(T=>e.jsx(cn,{definition:T,value:v==null?void 0:v[T.key],onChange:z=>k(f=>({...f??{},[T.key]:z}))},T.key))]},E.name))}),e.jsx(I,{title:"Colour schemes",intro:"Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight.",icon:"sparkle",tone:"note",actions:(y.themes??[]).length===0?e.jsx(C,{children:"all schemes"}):e.jsxs(C,{tone:"note",children:[N((y.themes??[]).length)," of ",N(M.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="themes",onClick:()=>(h??[]).length===0?j({kind:"no-themes"}):void w("themes",()=>F.put(`${c}/themes`,{themes:h??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx(A,{onClick:()=>m(M.map(E=>E.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:M.map(E=>{var T,z,f;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(h??[]).includes(E.id),onChange:D=>m($=>D.target.checked?[...$??[],E.id]:($??[]).filter(P=>P!==E.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":Ie((T=E.palette)==null?void 0:T.surface),"--swatch-accent":Ie((z=E.palette)==null?void 0:z.accent),"--swatch-hairline":Ie((f=E.palette)==null?void 0:f.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:E.name}),e.jsx("p",{children:E.description})]})]},E.id)})})}),e.jsx(I,{title:"Remove Memby access",intro:"Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched.",icon:"alert",tone:"bad",children:e.jsx(A,{variant:"danger",onClick:()=>j({kind:"remove-account"}),children:"Remove Memby access"})}),S?e.jsx(dn,{initial:S.name,busy:t==="rename",onCancel:()=>x(null),onConfirm:E=>void w("rename",()=>F.put(`${c}/devices/${encodeURIComponent(S.id)}`,{deviceName:E}),"Device renamed.",()=>x(null))}):null,b?e.jsx(hn,{pending:b,busy:t,username:y.username,onCancel:()=>j(null),onConfirm:()=>{switch(b.kind){case"remove-device":return void w("remove-device",()=>F.del(`${c}/devices/${encodeURIComponent(b.deviceId)}`),"Device signed out.");case"remove-account":return void w("remove-account",()=>F.del(`${c}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void w("reset-rec",()=>F.del(`${c}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void w("reset-prefs",()=>F.del(`${c}/preferences`),"Defaults restored.",()=>k(null));case"no-themes":return void w("themes",()=>F.put(`${c}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function on({prompt:s}){const n=s.ratings??[],r=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],t=[...n.map(l=>e.jsxs(Q,{tone:"warn",children:[l.title," · ",N(l.rating)," ★"]},`r:${l.title}`)),...r.flatMap(([l,c])=>(c??[]).map(i=>e.jsxs(Q,{children:[l,": ",i]},`${l}:${i}`)))];return t.length===0?e.jsx(G,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:t})}function cn({definition:s,value:n,onChange:r}){if(s.kind==="toggle")return e.jsx(W,{label:s.name,hint:s.description,checked:!!n,onChange:r});if(s.kind==="choice"||s.kind==="number"){const l=s.unit??"",c=s.kind==="number"?(s.numbers??[]).map(i=>({value:String(i),label:i===0?"No limit":l?`${i} ${l}`:String(i)})):s.options??[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:i=>r(s.kind==="number"?Number(i.target.value):i.target.value),children:c.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})})}if(s.kind==="multi"){const l=Array.isArray(n)?n:[],c=[...l,...(s.options??[]).map(i=>i.value).filter(i=>!l.includes(i))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:c.map(i=>{const o=(s.options??[]).find(d=>d.value===i);return o?e.jsx(W,{label:o.label,checked:l.includes(i),onChange:d=>r(d?[...l,i]:l.filter(a=>a!==i))},i):null})})]})}const t=Array.isArray(n)?n:[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:t.join(` +`),onChange:l=>r(l.target.value.split(` +`).map(c=>c.trim()).filter(Boolean))})})}function dn({initial:s,busy:n,onConfirm:r,onCancel:t}){const[l,c]=p.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:i=>i.target===i.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(q,{label:"Device name",children:e.jsx("input",{type:"text",value:l,autoFocus:!0,maxLength:80,onChange:i=>c(i.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:t,children:"Cancel"}),e.jsx(A,{variant:"primary",busy:n,disabled:!l.trim(),onClick:()=>r(l.trim()),children:"Rename"})]})]})})}function hn({pending:s,busy:n,username:r,onConfirm:t,onCancel:l}){const i={"remove-device":{title:"Sign this device out of Memby?",body:"Its Emby account will not be changed. The set can sign in again at any time.",label:"Sign out",destructive:!0},"remove-account":{title:`Remove Memby access for ${r||"this user"}?`,body:"Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.",label:"Remove access",destructive:!0},"reset-recommendations":{title:"Clear this person's stored recommendation choices?",body:"Viewing history remains intact; only the explicit setup answers are removed.",label:"Clear",destructive:!0},"cancel-prompt":{title:"Cancel this person's queued recommendation prompt?",body:"They will not be invited to set up recommendations on their next launch.",label:"Cancel prompt",destructive:!1},"reset-preferences":{title:"Restore the Memby defaults for this person?",body:"Their televisions will pick the change up the next time they check in.",label:"Restore defaults",destructive:!0},"no-themes":{title:"Allow this person no colour schemes?",body:"They will be left on Midnight with nothing to choose between.",label:"Save anyway",destructive:!0}}[s.kind];return e.jsx(he,{title:i.title,body:i.body,confirmLabel:i.label,destructive:i.destructive,busy:!!n,onConfirm:t,onCancel:l})}function un(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const t=(s.options??[]).find(l=>l.value===n);return t?t.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const r=Array.isArray(n)?n:[];return r.length===0?"None":r.map(t=>{var l;return((l=(s.options??[]).find(c=>c.value===t))==null?void 0:l.label)??t}).join(", ")}function mn(){const{userId:s=""}=Re(),{wrap:n}=se(),{busy:r,run:t}=Y(),l=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:c,error:i,loading:o,reload:d}=J(`${l}/preferences/history`,{pollMs:3e4}),[a,v]=p.useState(new Set),[k,h]=p.useState(null),m=(c==null?void 0:c.username)||"this account",g=(c==null?void 0:c.devices)??[],u=(c==null?void 0:c.revisions)??[],b=(c==null?void 0:c.catalogue)??[],j=x=>v(y=>{const R=new Set(y);return R.has(x)?R.delete(x):R.add(x),R}),S=x=>t("restore",async()=>{await n(()=>F.post(`${l}/preferences/revisions/${x}/restore`),`Restored r${x}.`),h(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(ee,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Where each device has got to",intro:"A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway.",icon:"tv",tone:"info",actions:c!=null&&c.saved?e.jsxs(C,{tone:c.currentSource==="admin"?"warn":"ok",children:["now on r",N(c.currentRevision)," · ",c.currentSource||"device"]}):e.jsx(C,{children:"defaults · never synced"}),children:g.length===0?e.jsx(G,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:g.map(x=>{const y=x.never?void 0:x.behind?"bad":"ok";return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":y})," ",x.name||"Memby TV"," ",x.signedOut?e.jsx(C,{children:"signed out"}):null]}),e.jsxs("p",{children:[x.never?"Has not fetched these settings yet":`Holding r${N(x.revision)} · taken ${O(x.ackedAt)}`,x.clientVersion?` · Memby ${x.clientVersion}`:"",x.signedOut?"":` · last seen ${O(x.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:x.never?e.jsx(C,{children:"never taken one"}):x.behind?e.jsxs(C,{tone:"bad",children:[N(x.behind)," behind"]}):e.jsx(C,{tone:"ok",children:"up to date"})})]},x.deviceId||x.name)})})}),e.jsx(I,{title:"Change history",intro:"Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{className:"num",children:"Rev"}),e.jsx("th",{children:"Changed by"}),e.jsx("th",{children:"What changed"}),e.jsx("th",{className:"num",children:"Taken by"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(te,{columns:6,children:"Nothing has been changed on this account yet."}):u.flatMap(x=>{const y=a.has(x.revision),R=x.acks??[],M=x.changes??[],H=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(x.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",N(x.revision)," ",x.current?e.jsx(C,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(C,{tone:x.source==="admin"?"warn":"ok",children:x.author}),x.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",N(x.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:x.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):M.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:M.map((w,L)=>e.jsxs(Q,{children:[w.name,": ",w.before," → ",w.after]},`${w.name}:${L}`))})}),e.jsx("td",{className:"num",children:R.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:R.map(w=>w.deviceName||w.deviceId).join(", "),children:N(R.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(A,{size:"sm",onClick:()=>j(x.revision),children:y?"Hide":"Show"}),x.current?null:e.jsx(A,{size:"sm",onClick:()=>h(x.revision),children:"Restore"})]})})]},x.revision)];return y&&H.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:b.map(w=>{var L;return e.jsxs(Q,{children:[w.name,":"," ",un(w,(L=x.preferences)==null?void 0:L[w.key])]},w.key)})})})},`${x.revision}:detail`)),H})})]})})})]}),k!==null?e.jsx(he,{title:`Restore revision ${k}?`,body:"It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to.",confirmLabel:"Restore",busy:r==="restore",onConfirm:()=>void S(k),onCancel:()=>h(null)}):null]})}function pn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(r=>e.jsx(Q,{tone:r.version===s.version?"ok":void 0,children:r.version},r.version))})}function xn(){const{status:s,error:n,loading:r}=ie(),t=(s==null?void 0:s.clients)??[],l=t.filter(i=>(i.capabilities??[]).includes("server_features_v1")),c=new Set(t.map(i=>i.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(B,{message:n}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"devices known",value:N(t.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(t.filter(i=>Ne(i.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:N(l.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:N(c.size),icon:"download",tone:"note"}]}),e.jsx(I,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:6,children:"No devices have signed in yet."}):t.map(i=>{const o=(i.capabilities??[]).includes("server_features_v1"),d=Pe(i.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":d.tone,title:d.label}),e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:i.username}),e.jsx("td",{className:"mono",children:i.version||"legacy"}),e.jsx("td",{children:e.jsx(pn,{client:i})}),e.jsx("td",{children:e.jsx(C,{tone:o?"ok":"warn",children:o?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:O(i.lastSeen)})]},`${i.deviceId}:${i.username}`)})})]})})})]})]})}const Ke={user:"",q:"",ip:"",outcome:"",from:"",to:""},jn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function vn(){var x,y,R,M,H,w;const[s,n]=p.useState("log"),[r,t]=p.useState(7),[l,c]=p.useState(Ke),[i,o]=p.useState(0),d=100,a=p.useMemo(()=>Se({...l,days:l.from?void 0:r||void 0,limit:d,offset:i*d}),[l,r,i]),v=J(`/admin/api/logins${a}`,{enabled:s==="log"}),k=J(`/admin/api/logins/devices${a}`,{enabled:s==="devices"}),h=((x=v.data)==null?void 0:x.users)??((y=k.data)==null?void 0:y.users)??[],m=((R=v.data)==null?void 0:R.totals)??((M=k.data)==null?void 0:M.totals),g=((H=v.data)==null?void 0:H.retentionDays)??((w=k.data)==null?void 0:w.retentionDays)??90,u=s==="log"?v.loading:k.loading,b=s==="log"?v.error:k.error,j=L=>{c(X=>({...X,...L})),o(0)},S=Object.entries(l).some(([,L])=>L!=="")||!!l.from;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Sign-in history",intro:"Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected.",actions:e.jsx(Me,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(B,{message:b}),m?e.jsx(ae,{tiles:[{label:"Successful sign-ins",value:N(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:N(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:N(m.devices),icon:"tv",tone:"info"},{label:"People",value:N(m.users),icon:"people",tone:"note"},{label:"Addresses",value:N(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${g} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:l.from?-1:r,options:jn.map(L=>({value:L.value,label:L.label})),onChange:L=>{t(L),j({from:"",to:""})}})}),e.jsx(q,{label:"Person",children:e.jsxs("select",{value:l.user,onChange:L=>j({user:L.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),h.map(L=>e.jsx("option",{value:L.id,children:L.username||L.id},L.id))]})}),e.jsx(q,{label:"Outcome",children:e.jsxs("select",{value:l.outcome,onChange:L=>j({outcome:L.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(q,{label:"Address",children:e.jsx("input",{type:"text",value:l.ip,placeholder:"10.0.0.4",onChange:L=>j({ip:L.target.value})})}),e.jsx(q,{label:"From",children:e.jsx("input",{type:"date",value:l.from,onChange:L=>j({from:L.target.value})})}),e.jsx(q,{label:"To",children:e.jsx("input",{type:"date",value:l.to,onChange:L=>j({to:L.target.value})})}),e.jsx(q,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:l.q,placeholder:"Name, device or address",onChange:L=>j({q:L.target.value})})}),e.jsx("div",{className:"filter-actions",children:S?e.jsx(A,{variant:"quiet",size:"sm",onClick:()=>{c(Ke),o(0)},children:"Clear"}):null})]}),u?e.jsx(_,{}):s==="log"?e.jsx(bn,{data:v.data,page:i,limit:d,onPage:o}):e.jsx(gn,{data:k.data})]})}function bn({data:s,page:n,limit:r,onPage:t}){if(!s)return null;const l=s.events.length,c=s.total===0?0:n*r+1;return e.jsxs(e.Fragment,{children:[e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Attempts per day",intro:"Grouped in the household's own timezone, so an evening sign-in stays on the day it happened.",icon:"chart",tone:"info",children:e.jsx(gs,{data:s.days,labelOf:i=>i.day,valueOf:i=>i.logins+i.failures,toneOf:i=>i.failures>i.logins?"bad":void 0,title:i=>`${i.day}: ${i.logins} in, ${i.failures} refused, ${i.devices} televisions`})}),e.jsx(I,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(G,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(i=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:i.ipAddress}),e.jsxs("p",{children:[N(i.logins)," in",i.failures>0?` · ${N(i.failures)} refused`:""]})]}),i.failures>0&&i.logins===0?e.jsx(C,{tone:"bad",children:"only refused"}):null]},i.ipAddress))})})]}),e.jsx(I,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${N(c)}–${N(c+l-1)} of ${N(s.total)}`}),footer:s.total>r?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:n===0,onClick:()=>t(n-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(n+1)*r>=s.total,onClick:()=>t(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(te,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(i.occurredAt)}),e.jsx("td",{children:i.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:i.deviceId?e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||i.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:i.ipAddress||"—"}),e.jsx("td",{className:"mono",children:i.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:i.success?i.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:i.failureReason||"refused"})})]},i.id))})]})})})]})}function gn({data:s}){return s?e.jsx(I,{title:"Televisions",intro:"Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Television"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Today"}),e.jsx("th",{className:"num",children:"Sign-ins"}),e.jsx("th",{className:"num",children:"Refused"}),e.jsx("th",{className:"num",children:"Addresses"}),e.jsx("th",{className:"nowrap",children:"Last address"}),e.jsx("th",{className:"nowrap",children:"Last sign-in"}),e.jsx("th",{children:"Build"})]})}),e.jsx("tbody",{children:s.devices.length===0?e.jsx(te,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?N(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:N(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:N(n.failures)}):"—"}),e.jsx("td",{className:"num",children:N(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?O(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const fn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function yn(){const{deviceId:s=""}=Re(),[n,r]=p.useState(7),t=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:l,error:c,loading:i}=J(t,{enabled:!!s}),o=l==null?void 0:l.summary,d=(o==null?void 0:o.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:d,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(ee,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(ee,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Me,{value:n,options:fn.map(a=>({value:a.value,label:a.label})),onChange:r})}),e.jsx(B,{message:c}),i?e.jsx(_,{}):l?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Sign-ins today",value:N((o==null?void 0:o.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:N((o==null?void 0:o.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:N((o==null?void 0:o.failures)??0),icon:"shield",tone:((o==null?void 0:o.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:N((o==null?void 0:o.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:o!=null&&o.firstLogin?O(o.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:o!=null&&o.lastLogin?O(o.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(gs,{data:l.days,labelOf:a=>a.day,valueOf:a=>a.logins+a.failures,toneOf:a=>a.failures>a.logins?"bad":void 0,title:a=>`${a.day}: ${a.logins} in${a.failures?`, ${a.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(I,{title:"Identity",icon:"tv",tone:"info",children:e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Person"}),e.jsx("p",{children:(o==null?void 0:o.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:l.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(o==null?void 0:o.clientVersion)||"unknown"})]})})]})}),e.jsx(I,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:l.versions.length===0?e.jsx(G,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:l.versions.map(a=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.version}),e.jsxs("p",{children:[O(a.firstSeen)," → ",O(a.lastSeen)]})]})},a.version))})})]})]}),e.jsx(I,{title:"Addresses",icon:"globe",tone:"data",children:l.addresses.length===0?e.jsx(G,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:l.addresses.map(a=>e.jsxs(Q,{tone:a.failures>0?"warn":"data",children:[a.ipAddress," · ",N(a.logins),a.failures>0?` (+${N(a.failures)} refused)`:""]},a.ipAddress))})}),e.jsx(I,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[N(l.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:l.events.length===0?e.jsx(te,{columns:6,children:"This television has not connected in the selected window."}):l.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"muted",children:a.method}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]}):null]})}function wn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),a=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),k=h=>i(h,async()=>{await l(()=>F.post("/admin/api/sync",{kind:h}),h==="full"?"Full re-import started.":"Import started."),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(B,{message:n}),r||!s?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"items",value:N(s.library.total),icon:"library",tone:"data"},...Object.keys(a).sort().map(h=>({label:h,value:N(a[h]),icon:"list"})),{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(I,{title:"Import the catalogue",intro:"Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live.",icon:"library",tone:"data",footer:e.jsx("span",{className:"hint",children:v?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"sync",disabled:v,busy:c==="incremental",onClick:()=>void k("incremental"),children:"Sync new items"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full re-import"})]})})]}),o?e.jsx(he,{title:"Re-import the entire library?",body:"A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout.",confirmLabel:"Re-import",busy:c==="full",onConfirm:()=>void k("full"),onCancel:()=>d(!1)}):null]})}const kn={imdb:"IMDb",tomatoes:"Rotten Tomatoes",audience:"Rotten Tomatoes Audience",metacritic:"Metacritic",letterboxd:"Letterboxd",rogerebert:"Roger Ebert",tmdb:"TMDb",trakt:"Trakt",mal:"MyAnimeList",anilist:"AniList",anidb:"AniDB",kitsu:"Kitsu",score:"MDBList Score",score_average:"MDBList Average"};function Nn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),[a,v]=p.useState(""),[k,h]=p.useState(!1),[m,g]=p.useState([]),[u,b]=p.useState(!1),j=s==null?void 0:s.mdblist;p.useEffect(()=>{u||!j||(d(j.enabled),g(j.sources??[]))},[j,u]);const S=()=>i("save",async()=>{await l(()=>F.post("/admin/api/mdblist-settings",{enabled:o,apiKey:a.trim(),clearApiKey:k,sources:m}),"Ratings settings saved."),v(""),h(!1),b(!1),await t()}),x=(j==null?void 0:j.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(B,{message:n}),r||!j?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"titles stored",value:N(x),icon:"database",tone:"data"},{label:"due to be re-checked",value:N(j.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:N((j.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:j.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:j.apiKeyConfigured?"ok":void 0}]}),e.jsxs(de,{cols:"2",children:[e.jsxs(I,{title:"MDBList connection",intro:"The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title.",icon:"star",tone:"note",actions:o?e.jsxs(C,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(C,{children:j.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:o,onChange:y=>{d(y),b(!0)}}),e.jsx(q,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:a,placeholder:j.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>v(y.target.value)})}),e.jsx(W,{label:"Remove the saved key",checked:k,onChange:h})]}),e.jsx(I,{title:"Sources shown on televisions",intro:"A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched.",icon:"list",tone:"data",children:(j.availableSources??[]).length===0?e.jsx(G,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(j.availableSources??[]).map(y=>e.jsx(W,{label:kn[y]??y,checked:m.includes(y),onChange:R=>{b(!0),g(M=>R?[...M,y]:M.filter(H=>H!==y))}},y))})})]}),e.jsx(I,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void S(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:x?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function Sn(){var k;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),o=(s==null?void 0:s.requestUsers)??[],d=((k=s==null?void 0:s.requestPolicy)==null?void 0:k.allowedUserIds)??[],a=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(h=>[h.userId,h])),[s==null?void 0:s.requestUsage]),v=h=>i(`access-${h}`,async()=>{const m=d.includes(h)?d.filter(g=>g!==h):[...d,h];await l(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(h)?"Request access granted.":"Request access removed."),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(C,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(C,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(G,{children:"Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The button stays hidden on every television until one of them is."}):null}),e.jsx(I,{title:"Request access and activity",intro:"One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state.",icon:"people",tone:"note",children:o.length===0?e.jsx(G,{children:"No one has signed in yet."}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"User"}),e.jsx("th",{children:"Last seen"}),e.jsx("th",{className:"num",children:"Sent to services"}),e.jsx("th",{children:"Last request"}),e.jsx("th",{children:"Access"})]})}),e.jsx("tbody",{children:o.map(h=>{const m=a.get(h.id),g=d.includes(h.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:h.username})}),e.jsx("td",{className:"muted nowrap",children:O(h.lastSeen)}),e.jsx("td",{className:"num",children:(m==null?void 0:m.requests)??0}),e.jsx("td",{className:"muted nowrap",children:m!=null&&m.lastRequest?O(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx(A,{size:"sm",variant:g?"quiet":"primary",busy:c===`access-${h.id}`,onClick:()=>void v(h.id),children:g?"Remove access":"Give access"})})]},h.id)})})]})})})]})]})}function Cn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),a=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),k=(h,m,g)=>i(m,async()=>{await l(()=>F.post("/admin/api/for-you",{action:h}),g),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tracearr sessions",value:N((a==null?void 0:a.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:N((a==null?void 0:a.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:N((a==null?void 0:a.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:O(a==null?void 0:a.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(I,{title:"Pool maintenance",intro:"Prepared pools refresh in the background; these are the manual versions of the same work. A rebuild is safe at any time — televisions read the last finished pool until a new one lands.",icon:"sparkle",tone:"note",footer:e.jsx("span",{className:"hint",children:v?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"download",disabled:v,busy:c==="import",onClick:()=>void k("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(A,{icon:"sync",disabled:v,busy:c==="rebuild",onClick:()=>void k("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(I,{title:"Reading a person's scores",intro:"The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order.",icon:"search",tone:"info",actions:e.jsx(ee,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),o?e.jsx(he,{title:"Backfill all Tracearr history?",body:"Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while.",confirmLabel:"Backfill",busy:c==="full",onConfirm:()=>void k("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const Mn=[["Genre","genres"],["Studio","studios"],["Actor","actors"],["Director","directors"],["Franchise","franchises"],["Runtime","runtimeRanges"],["Age rating","ageRatings"],["Community rating","communityRatings"],["Release period","releasePeriods"],["Content type","contentTypes"]];function Rn(s){return s?Mn.flatMap(([n,r])=>Object.entries(s[r]??{}).map(([t,l])=>({dimension:n,name:t,weight:l.weight??0,evidence:l.evidence??0}))).sort((n,r)=>Math.abs(r.weight)-Math.abs(n.weight)):[]}const Ge=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function En(){const{status:s}=ie(),{wrap:n}=se(),{busy:r,run:t}=Y(),[l,c]=p.useState(""),[i,o]=p.useState("default"),[d,a]=p.useState("0"),[v,k]=p.useState(""),[h,m]=p.useState(null),[g,u]=p.useState("Choose a person to inspect their recommendations."),[b,j]=p.useState(""),S=(s==null?void 0:s.requestUsers)??[],x=()=>t("run",async()=>{if(!l){j("Choose a person to pressure-test.");return}j(""),u("Running the permission check and the scorer…");const w=new URLSearchParams({userId:l,context:i,minutes:d||"0",limit:"100"});v&&w.set("at",new Date(v).toISOString());const L=await n(()=>F.get(`/admin/api/recommendations?${w.toString()}`));L?(m(L),u(`Scored at ${new Date().toLocaleTimeString()}.`)):u("Pressure test failed.")}),y=Rn((h==null?void 0:h.profile)??null).slice(0,24),R=(h==null?void 0:h.actions)??[],M=(h==null?void 0:h.items)??[],H=(h==null?void 0:h.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(B,{message:b}),e.jsx(I,{title:"Run a pressure test",intro:"Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose.",icon:"search",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:r==="run",onClick:()=>void x(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:g})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Person",children:e.jsxs("select",{value:l,onChange:w=>c(w.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),S.map(w=>e.jsx("option",{value:w.id,children:w.username},w.id))]})}),e.jsx(q,{label:"Context",children:e.jsxs("select",{value:i,onChange:w=>o(w.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(q,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:w=>a(w.target.value)})}),e.jsx(q,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:w=>k(w.target.value)})})]})}),h?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"prepared pool",value:N(h.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:N(h.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:N(M.length),icon:"sparkle",tone:"note"},{label:"source events",value:N(H.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:H.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:O(H.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(I,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[y.length===0?e.jsx(G,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(w=>e.jsxs(Q,{tone:w.weight<0?"bad":void 0,children:[w.dimension,": ",w.name," ",Ge(w.weight)," · n=",N(w.evidence)]},`${w.dimension}:${w.name}`))}),R.length===0?e.jsx(G,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:R.map((w,L)=>e.jsxs(Q,{tone:"ok",children:[w.action,": ",w.title||w.itemId]},`${w.action}:${L}`))})]}),M.length===0?e.jsx(I,{children:e.jsx(G,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(de,{children:M.map((w,L)=>{const X=w.explanation??{},re=Object.entries(X.components??{}).sort((T,z)=>Math.abs(z[1])-Math.abs(T[1])),K=w.exposure??{},E=[w.type,w.year,w.runtimeMinutes?`${w.runtimeMinutes} min`:null,...w.genres??[]].filter(Boolean).join(" · ");return e.jsxs(I,{title:`#${L+1} · ${w.title}`,intro:E,actions:e.jsx(Q,{tone:"ok",children:Number(X.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[w.preparedReason||"No legacy prepared explanation",w.compatibilityLabel?` · ${w.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(X.reasonCodes??[]).map(T=>e.jsx(Q,{tone:"ok",children:T},T)),re.map(([T,z])=>e.jsxs(Q,{tone:z<0?"bad":void 0,children:[T,"=",Ge(z)]},T))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",N(w.baseRank??0)," · base ",Number(w.baseScore??0).toFixed(3)," · affinity ",Number(w.affinityScore??0).toFixed(3)," · compatibility"," ",Number(w.compatibilityScore??0).toFixed(3)," · impressions"," ",N(K.impressions??0)," · focuses ",N(K.focuses??0)," · selects"," ",N(K.selects??0)]}),e.jsx("div",{className:"chips",children:(w.eligibleRows??[]).map(T=>e.jsx(Q,{tone:"ok",children:T},T))}),w.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",w.preparedEvidenceTitle]}):null]})]},`${L}:${w.title}`)})})]}):null]})}const An=4,xe=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],ge=()=>({pinnedItems:[],primeSubtitle:""}),De=[{value:1,short:"Mon",label:"Monday"},{value:2,short:"Tue",label:"Tuesday"},{value:3,short:"Wed",label:"Wednesday"},{value:4,short:"Thu",label:"Thursday"},{value:5,short:"Fri",label:"Friday"},{value:6,short:"Sat",label:"Saturday"},{value:0,short:"Sun",label:"Sunday"}];function Ze(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Je(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function In(s){return s.frequency??"once"}function $n(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=De.filter(t=>(s.weekdays??[]).includes(t.value));return`${(n.length===7?"Every day":n.map(t=>t.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function Ye(s,n){const r=new Date;r.setMinutes(Math.ceil(r.getMinutes()/30)*30,0,0);const t=new Date(r.getTime()+2*60*60*1e3),l=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:r.toISOString(),endAt:t.toISOString(),priority:0,enabled:!0,placements:[l?n:"home"]}}function Tn(){var E,T,z;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l,show:c}=se(),{busy:i,run:o}=Y(),[d,a]=p.useState("home"),[v,k]=p.useState({home:ge(),movies:ge(),tv_shows:ge()}),[h,m]=p.useState(!1),[g,u]=p.useState(""),[b,j]=p.useState(null),[S,x]=p.useState([]),[y,R]=p.useState(null),M=s==null?void 0:s.heroPolicy;p.useEffect(()=>{var f,D,$;h||!M||(k({home:((f=M.placements)==null?void 0:f.home)??{pinnedItems:M.pinnedItems??[],primeSubtitle:M.primeSubtitle??""},movies:((D=M.placements)==null?void 0:D.movies)??ge(),tv_shows:(($=M.placements)==null?void 0:$.tv_shows)??ge()}),x(M.schedules??[]))},[M,h]);const H=()=>o("search",async()=>{const f=g.trim();if(!f)return;const D=await l(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(f)}`));D&&j(D.items??[])}),w=v[d],L=w.pinnedItems??[],X=f=>{k(D=>({...D,[d]:{...D[d],...f}})),m(!0)},re=f=>{if(!L.some(D=>D.id===f.id)){if(L.length>=An){c("Remove a pinned title before adding another.","bad");return}X({pinnedItems:[...L,f]})}},K=()=>o("save",async()=>{await l(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([f,D])=>[f,{pinnedItemIds:(D.pinnedItems??[]).map($=>$.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:S}),"Hero saved."),m(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(de,{children:xe.map(f=>{var le;const D=v[f.id],$=S.filter(ue=>ue.enabled&&(ue.placements??["home"]).includes(f.id)).sort((ue,Ns)=>Ns.priority-ue.priority)[0],P=(D.pinnedItems??[]).length?"Manual":$?"Schedule ready":"Automatic",oe=(le=M==null?void 0:M.items)==null?void 0:le.find(ue=>ue.id===($==null?void 0:$.itemId)),ce=(D.pinnedItems??[]).map(ue=>ue.name).join(", ")||(oe==null?void 0:oe.name)||($==null?void 0:$.itemId)||"Resolved for each viewer";return e.jsx(I,{title:f.label,intro:`${P} · ${ce}`,tone:f.id===d?"info":void 0,children:e.jsxs(A,{size:"sm",variant:"quiet",onClick:()=>a(f.id),children:["Manage ",f.label]})},f.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:xe.map(f=>e.jsx(A,{variant:d===f.id?"primary":"quiet",onClick:()=>a(f.id),children:f.label},f.id))}),e.jsxs(I,{title:`${(E=xe.find(f=>f.id===d))==null?void 0:E.label} hero`,intro:`Pinned ${(T=xe.find(f=>f.id===d))==null?void 0:T.type} lead this section only. Empty places use this placement’s automatic selection.`,icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void K(),children:"Save hero"}),e.jsx(A,{onClick:()=>{X({pinnedItems:[]})},children:"Clear pins"}),h?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[L.length===0?e.jsx(G,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:L.map((f,D)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:D+1}),e.jsxs("span",{children:[e.jsx("b",{children:f.name}),e.jsxs("small",{children:[f.type,f.year?` · ${f.year}`:""]})]}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>R(Ye(f,d)),children:"Schedule"}),e.jsx(A,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${f.name}`,onClick:()=>X({pinnedItems:L.filter($=>$.id!==f.id)})})]},f.id))}),e.jsx(q,{label:"Prime-card subtitle",hint:"Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason.",children:e.jsx("input",{type:"text",maxLength:160,value:w.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:f=>{X({primeSubtitle:f.target.value})}})})]}),e.jsx(I,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(C,{tone:"info",children:(M==null?void 0:M.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void K(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:S.length===0?e.jsx(G,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...S].sort((f,D)=>Number(D.enabled)-Number(f.enabled)||D.priority-f.priority).map(f=>{const D=[...(M==null?void 0:M.items)??[],...L,...b??[]].find($=>$.id===f.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":f.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:f.frequency==="weekly"?"Weekly":f.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:f.frequency?f.startTime:f.startAt?new Date(f.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(D==null?void 0:D.name)??f.itemId}),e.jsx(C,{tone:f.enabled?"ok":void 0,children:f.enabled?"enabled":"paused"})]}),e.jsx("p",{children:$n(f)}),e.jsxs("div",{className:"chips",children:[(f.placements??["home"]).map($=>{var P;return e.jsx("span",{className:"chip",children:(P=xe.find(oe=>oe.id===$))==null?void 0:P.label},$)}),f.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",f.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(A,{size:"sm",onClick:()=>R({...f}),children:"Edit"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{x($=>$.map(P=>P.id===f.id?{...P,enabled:!P.enabled}:P)),m(!0)},children:f.enabled?"Pause":"Enable"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{x($=>$.filter(P=>P.id!==f.id)),m(!0)},children:"Remove"})]})]},f.id)})})}),e.jsxs(I,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(z=xe.find(f=>f.id===d))==null?void 0:z.label} placement; switch tabs to show it in more than one section.`,icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(q,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:g,placeholder:"Search films and television shows",onChange:f=>u(f.target.value),onKeyDown:f=>{f.key==="Enter"&&H()}})}),e.jsx(A,{busy:i==="search",icon:"search",onClick:()=>void H(),children:"Search"})]}),b===null?null:b.length===0?e.jsx(G,{children:"No playable films or series matched that search."}):e.jsx(de,{children:b.map(f=>e.jsx(I,{title:f.name,intro:`${f.type||"Title"} · ${f.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(A,{size:"sm",icon:"plus",disabled:L.some(D=>D.id===f.id)||d==="movies"&&f.type!=="Movie"||d==="tv_shows"&&f.type!=="Series",onClick:()=>re(f),children:L.some(D=>D.id===f.id)?"Pinned":"Add to hero"}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>R(Ye(f,d)),children:"Schedule"})]})},f.id))})]})]}),y?e.jsx(Ln,{schedule:y,item:[...(M==null?void 0:M.items)??[],...L,...b??[]].find(f=>f.id===y.itemId),timeZone:(M==null?void 0:M.timeZone)||"server local time",isNew:!S.some(f=>f.id===y.id),onCancel:()=>R(null),onSave:f=>{x(D=>D.some($=>$.id===f.id)?D.map($=>$.id===f.id?f:$):[...D,f]),R(null),m(!0)}}):null]})}function Ln({schedule:s,item:n,timeZone:r,isNew:t,onSave:l,onCancel:c}){const[i,o]=p.useState({...s,weekdays:[...s.weekdays??[]]}),d=In(i),a=u=>{const b=new Date,j=new Date(b.getTime()+2*60*60*1e3);o(S=>{var x;return u==="once"?{...S,frequency:void 0,startAt:Je(S.startAt)?S.startAt:b.toISOString(),endAt:Je(S.endAt)?S.endAt:j.toISOString()}:{...S,frequency:u,startTime:S.startTime||"18:00",endTime:S.endTime||"22:00",weekdays:u==="weekly"?(x=S.weekdays)!=null&&x.length?S.weekdays:[1,2,3,4,5]:[]}})},v=i.placements??["home"],k=u=>u==="home"||u==="movies"&&(n==null?void 0:n.type)==="Movie"||u==="tv_shows"&&(n==null?void 0:n.type)==="Series",h=!!(i.startAt&&i.endAt&&new Date(i.endAt)>new Date(i.startAt)),m=!!(i.startTime&&i.endTime&&i.startTime!==i.endTime&&(d!=="weekly"||(i.weekdays??[]).length>0)),g=d==="once"?h:m;return e.jsx("div",{className:"scrim",onPointerDown:u=>u.target===u.currentTarget&&c(),children:e.jsxs("div",{className:"dialog hero-schedule-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"hero-schedule-title",children:[e.jsxs("div",{className:"hero-schedule-dialog-head",children:[e.jsx("span",{className:"hero-schedule-kicker",children:"Hero schedule"}),e.jsx("h2",{id:"hero-schedule-title",children:(n==null?void 0:n.name)??i.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",r,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(u=>e.jsxs("button",{type:"button","aria-pressed":d===u,onClick:()=>a(u),children:[e.jsx("b",{children:u==="once"?"One time":u==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:u==="once"?"A date range":u==="daily"?"Same time daily":"Choose days"})]},u))}),d==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:i.startAt?Ze(new Date(i.startAt)):"",onChange:u=>o(b=>({...b,startAt:u.target.value?new Date(u.target.value).toISOString():void 0}))})}),e.jsx(q,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:i.endAt?Ze(new Date(i.endAt)):"",onChange:u=>o(b=>({...b,endAt:u.target.value?new Date(u.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts each time",children:e.jsx("input",{type:"time",value:i.startTime??"",onChange:u=>o(b=>({...b,startTime:u.target.value}))})}),e.jsx(q,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:i.endTime??"",onChange:u=>o(b=>({...b,endTime:u.target.value}))})})]}),d==="weekly"?e.jsxs("div",{className:"schedule-days",children:[e.jsxs("div",{className:"schedule-days-head",children:[e.jsx("b",{children:"Days"}),e.jsxs("div",{children:[e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:De.map(b=>b.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:De.map(u=>{const b=(i.weekdays??[]).includes(u.value);return e.jsx("button",{type:"button","aria-pressed":b,title:u.label,onClick:()=>o(j=>({...j,weekdays:b?(j.weekdays??[]).filter(S=>S!==u.value):[...j.weekdays??[],u.value]})),children:u.short},u.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:xe.map(u=>e.jsx(W,{label:u.label,checked:v.includes(u.id),disabled:!k(u.id),onChange:b=>o(j=>{const S=j.placements??["home"],x=b?[...S,u.id]:S.filter(y=>y!==u.id);return{...j,placements:x.length?[...new Set(x)]:S}})},u.id))})]}),e.jsx(q,{label:"Priority",hint:"Higher rules win when schedules overlap.",children:e.jsx("input",{type:"number",min:-1e3,max:1e3,step:10,value:i.priority,onChange:u=>o(b=>({...b,priority:Number(u.target.value)}))})})]}),e.jsx(W,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:i.enabled,onChange:u=>o(b=>({...b,enabled:u}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx(A,{variant:"primary",disabled:!g,onClick:()=>l(i),children:t?"Add rule":"Save rule"})]})]})})}function Dn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),a=s==null?void 0:s.features,v=(a==null?void 0:a.features)??[],k=(s==null?void 0:s.clients)??[],h=(a==null?void 0:a.revision)??0,m=(j,S,x,y)=>i(S,async()=>{await l(()=>F.post("/admin/api/features",{action:j,expectedRevision:h,overrides:y??{}}),x),d(null),await t()}),g=k.filter(j=>(j.capabilities??[]).includes("server_features_v1")).length,u=!!(a!=null&&a.safeMode),b=(j,S)=>({...Object.fromEntries(v.filter(x=>x.source==="override").map(x=>[x.key,x.enabled])),[j]:S});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(B,{message:n}),r||!a?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Control plane",intro:"Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional.",icon:"sliders",tone:"ok",actions:e.jsx(A,{variant:u?void 0:"danger",busy:c==="safe",onClick:()=>u?void m("leave-safe-mode","safe","Safe mode ended."):d({action:"safe-mode",title:"Enable safe mode?",body:"Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.",label:"Enable safe mode"}),children:u?"Leave safe mode":"Enable safe mode"}),children:e.jsx(fs,{tiles:[{label:"features active",value:`${v.filter(j=>j.enabled).length} / ${v.length}`},{label:"explicit overrides",value:N(v.filter(j=>j.source==="override").length)},{label:"televisions reporting the control plane",value:`${g} / ${k.length}`},{label:"published revision",value:`r${N(h)}`}]})}),e.jsx(de,{cols:"2",children:v.length===0?e.jsx(I,{title:"Nothing registered",icon:"sliders",children:e.jsx(G,{children:"No server features are registered."})}):v.map(j=>e.jsxs(I,{title:j.name,intro:j.description,actions:e.jsx(C,{tone:j.enabled?"ok":void 0,children:j.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",j.recovery]}),children:[e.jsx(W,{label:j.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:j.enabled,disabled:c===j.key,onChange:S=>d({action:"feature",key:j.key,enabled:S,title:`${S?"Turn on":"Turn off"} ${j.name}?`,body:`${S?"Enable":"Disable"} this feature for every compatible television. ${j.recovery}`,label:S?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(Q,{children:j.key}),e.jsxs(Q,{children:["protocol ",N(j.minimumProtocol),"+"]}),e.jsx(Q,{tone:j.compatible?"ok":"warn",children:j.compatible?"server compatible":"compatibility blocked"}),e.jsx(Q,{tone:"note",children:j.area})]})]},j.key))}),e.jsx(I,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{disabled:!a.canRollback,onClick:()=>d({action:"rollback",title:"Roll back one revision?",body:"The previous published feature revision is restored on every television.",label:"Roll back"}),children:"Roll back one revision"}),e.jsx(A,{onClick:()=>d({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),u?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["live · revision r",N(h)]})]})})]}),o?e.jsx(he,{title:o.title,body:o.body,confirmLabel:o.label,destructive:o.action!=="rollback",busy:c===o.action,onConfirm:()=>void m(o.action==="feature"?"save":o.action,o.action==="feature"?o.key??"feature":o.action,`${o.label} done.`,o.action==="feature"&&o.key?b(o.key,!!o.enabled):void 0),onCancel:()=>d(null)}):null]})}function qn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l,show:c}=se(),{busy:i,run:o}=Y(),[d,a]=p.useState(!0),[v,k]=p.useState("6.5"),[h,m]=p.useState(!1);p.useEffect(()=>{var u,b;h||!s||(a(((u=s.playbackPolicy)==null?void 0:u.prerollEnabled)!==!1),k(String((((b=s.playbackPolicy)==null?void 0:b.prerollDurationMs)??6500)/1e3)))},[s,h]);const g=()=>o("save",async()=>{const u=Number(v);if(!Number.isFinite(u)||u<1||u>30){c("The preroll duration must be between 1 and 30 seconds.","bad");return}await l(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(u*1e3)}),"Playback policy saved."),m(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsxs(I,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:d?e.jsxs(C,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(C,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void g(),children:"Save playback policy"}),children:[e.jsx(W,{label:"Show the preroll before a title starts",checked:d,onChange:u=>{a(u),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Duration",hint:"Between 1 and 30 seconds. The stream is already playing behind it.",children:e.jsx("input",{type:"number",min:1,max:30,step:.5,value:v,onChange:u=>{k(u.target.value),m(!0)}})})})]})]})}function Fn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),[a,v]=p.useState(null),[k,h]=p.useState(!1),m=s==null?void 0:s.subtitles,g=m==null?void 0:m.stored;p.useEffect(()=>{o||!m||d({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,o]);const u=x=>d(y=>y&&{...y,...x}),b=()=>i("save",async()=>{o&&(await l(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:o.bazarr,openSubtitlesEnabled:o.openSubtitles,openSubtitlesApiKey:o.key.trim(),clearOpenSubtitlesApiKey:o.clearKey,openSubtitlesUsername:o.username.trim(),openSubtitlesPassword:o.password,clearOpenSubtitlesLogin:o.clearLogin}),"Subtitle settings saved."),d(null),await t())}),j=()=>i("test",async()=>{v(null);const x=await l(()=>F.post("/admin/api/subtitle-test"));v((x==null?void 0:x.results)??[])}),S=()=>i("clear",async()=>{await l(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(B,{message:n}),r||!m||!o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:N((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:N((g==null?void 0:g.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:O(g==null?void 0:g.latest),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"Bazarr",intro:"Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it.",icon:"wrench",tone:"data",actions:m.bazarrConfigured?o.bazarr?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{children:"off"}):e.jsx(C,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:m.bazarrConfigured?`Configured at ${m.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(W,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:o.bazarr,disabled:!m.bazarrConfigured,onChange:x=>u({bazarr:x})})}),e.jsxs(I,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:m.openSubtitlesEnabled?e.jsx(C,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(C,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:o.openSubtitles,onChange:x=>u({openSubtitles:x})}),e.jsx(q,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:x=>u({key:x.target.value})})}),e.jsx(W,{label:"Remove the saved key",checked:o.clearKey,onChange:x=>u({clearKey:x})}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:o.username,placeholder:"Not signed in",onChange:x=>u({username:x.target.value})})}),e.jsx(q,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.password,onChange:x=>u({password:x.target.value})})})]}),e.jsx(W,{label:"Sign out and forget the account",checked:o.clearLogin,onChange:x=>u({clearLogin:x})})]})]}),e.jsxs(I,{children:[e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void b(),children:"Save subtitle settings"}),e.jsx(A,{busy:c==="test",icon:"pulse",onClick:()=>void j(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:m.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),a===null?null:a.length===0?e.jsx(G,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:a.map(x=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:x.provider}),e.jsx("p",{children:x.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(C,{tone:x.ok?"ok":"bad",children:x.ok?"reachable":"not reachable"})})]},x.provider))})]}),e.jsx(I,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:g!=null&&g.count?e.jsxs(C,{tone:"data",children:[N(g.count)," files · ",fe(g.bytes)]}):e.jsx(C,{children:"nothing held"}),footer:e.jsx(A,{variant:"danger",disabled:!(g!=null&&g.count),onClick:()=>h(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),k?e.jsx(he,{title:"Delete every stored subtitle?",body:"Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby.",confirmLabel:"Delete",destructive:!0,busy:c==="clear",onConfirm:()=>void S(),onCancel:()=>h(!1)}):null]})}function Pn(){var z;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),[a,v]=p.useState(!1),[k,h]=p.useState(!1),[m,g]=p.useState(""),[u,b]=p.useState(""),[j,S]=p.useState(!1),[x,y]=p.useState(null),[R,M]=p.useState(""),H=p.useRef(""),w=s==null?void 0:s.updatePolicy;p.useEffect(()=>{o||!w||d({version:w.latestVersion??"",url:w.downloadUrl??"",notes:w.notes??"",retireBelow:w.retireBelowVersion??"",required:!!w.minimumVersion&&w.minimumVersion===w.latestVersion,destructive:!!w.retireBelowVersion&&w.retireBelowVersion===w.latestVersion})},[w,o]),p.useEffect(()=>{let f=!0;const D=async()=>{try{const P=await F.get("/admin/api/release-builder");if(!f)return;y(P),M(""),P.state==="succeeded"&&P.finishedAt&&H.current!==P.finishedAt&&(H.current=P.finishedAt,d(null),await t())}catch(P){if(!f)return;M(P instanceof Error?P.message:"The release builder is unavailable.")}};D();const $=window.setInterval(()=>void D(),3e3);return()=>{f=!1,window.clearInterval($)}},[t]);const L=f=>i(f?"save":"off",async()=>{o&&(await l(()=>F.post("/admin/api/update-policy",{enabled:f,latestVersion:o.version.trim(),downloadUrl:o.url.trim(),notes:o.notes.trim(),required:o.required,destructive:o.destructive,retireBelowVersion:o.retireBelow.trim()}),f?"Update policy saved.":"Update prompts turned off."),v(!1),d(null),await t())}),X=!!(w!=null&&w.minimumVersion)&&(w==null?void 0:w.minimumVersion)===(w==null?void 0:w.latestVersion),re=!!(w!=null&&w.retireBelowVersion)&&(w==null?void 0:w.retireBelowVersion)===(w==null?void 0:w.latestVersion),K=f=>d(D=>D&&{...D,...f}),E=()=>i("release",async()=>{const f=await l(()=>F.post("/admin/api/release-builder",{tag:m.trim(),notes:u.trim(),mandatory:j}),"Memby release started.");f&&(y(f),M(""),h(!1))}),T=(x==null?void 0:x.state)==="succeeded"?"ok":(x==null?void 0:x.state)==="failed"?"bad":(x==null?void 0:x.state)==="running"?"warn":void 0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(B,{message:n}),e.jsx(B,{message:R}),e.jsxs(I,{title:"Build and publish",intro:"Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway.",icon:"download",tone:"data",actions:e.jsx(C,{tone:T,children:(x==null?void 0:x.state)??"checking"}),footer:e.jsx(A,{variant:"primary",busy:c==="release"||(x==null?void 0:x.state)==="running",disabled:!!R,onClick:()=>j?h(!0):void E(),children:m.trim()?`Build ${m.trim()}`:"Build latest release"}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"GitHub tag override",hint:"Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades.",children:e.jsx("input",{type:"text",value:m,disabled:(x==null?void 0:x.state)==="running",placeholder:"v0.2.64 (blank uses latest)",onChange:f=>g(f.target.value)})}),e.jsx(q,{label:"Release notes",hint:"Leave blank to use this version's CHANGELOG entry.",children:e.jsx("input",{type:"text",value:u,disabled:(x==null?void 0:x.state)==="running",placeholder:"What's new on the television",onChange:f=>b(f.target.value)})})]}),e.jsx(W,{label:"Make this update required",hint:"Older televisions cannot dismiss the update prompt. You will confirm before the build starts.",checked:j,disabled:(x==null?void 0:x.state)==="running",onChange:S}),x!=null&&x.message?e.jsx(me,{tone:T,children:x.message}):null,(z=x==null?void 0:x.logs)!=null&&z.length?e.jsx("pre",{className:"code",children:x.logs.join(` +`)}):null,e.jsxs(me,{children:["Command-line fallback: ",e.jsx("code",{children:(x==null?void 0:x.fallback)??"docker compose run --rm --build memby-builder release"})]})]}),r||!o?e.jsx(_,{rows:1}):e.jsxs(I,{title:"Update policy",intro:"Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works.",icon:"download",tone:"info",actions:w!=null&&w.enabled?e.jsxs(C,{tone:X?"warn":"ok",children:[re?"sign-out · ":X?"required · ":"optional · ",w.latestVersion]}):e.jsx(C,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>o.required?v(!0):void L(!0),children:"Save policy"}),e.jsx(A,{busy:c==="off",onClick:()=>void L(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Latest version",children:e.jsx("input",{type:"text",value:o.version,placeholder:"0.2.63",onChange:f=>K({version:f.target.value})})}),e.jsx(q,{label:"APK URL",children:e.jsx("input",{type:"text",value:o.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:f=>K({url:f.target.value})})})]}),e.jsx(q,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:o.notes,placeholder:"One line the viewer reads",onChange:f=>K({notes:f.target.value})})}),e.jsx(q,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:o.retireBelow,placeholder:"0.2.44",onChange:f=>K({retireBelow:f.target.value})})}),e.jsx(W,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:o.required,onChange:f=>K({required:f})}),e.jsx(W,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:o.destructive,onChange:f=>K(f?{destructive:!0,required:!0,retireBelow:o.version.trim()}:{destructive:!1,retireBelow:o.retireBelow.trim()===o.version.trim()?"":o.retireBelow})})]}),a&&o?e.jsx(he,{title:o.destructive?"Sign every older television out?":"Require this update?",body:o.destructive?"This deletes sessions on every older television and forces viewers to sign in again after updating.":"Required updates block the home screen on every television below this version until they update.",confirmLabel:"Publish",destructive:o.destructive,busy:c==="save",onConfirm:()=>void L(!0),onCancel:()=>v(!1)}):null,k?e.jsx(he,{title:"Build a required update?",body:"When this signed APK is published, every older television will be blocked until it installs the update.",confirmLabel:"Build and publish",busy:c==="release",onConfirm:()=>void E(),onCancel:()=>h(!1)}):null]})}const On=2e4,Bn=3e3,Vn=[{value:60,label:"Every minute"},{value:300,label:"Every 5 minutes"},{value:600,label:"Every 10 minutes"},{value:900,label:"Every 15 minutes"},{value:1800,label:"Every 30 minutes"},{value:3600,label:"Hourly"},{value:10800,label:"Every 3 hours"},{value:21600,label:"Every 6 hours"},{value:43200,label:"Every 12 hours"},{value:86400,label:"Daily"},{value:604800,label:"Weekly"}];function Un(s){const n=[...Vn];for(const r of[s.defaultIntervalSeconds,s.intervalSeconds])r>0&&!n.some(t=>t.value===r)&&n.push({value:r,label:Te(r).replace(/^every /,"Every ")});return n.sort((r,t)=>r.value-t.value)}function Xe(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Wn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),[t,l]=p.useState(!1),{data:c,error:i,loading:o,reload:d}=J("/admin/api/tasks?limit=60",{pollMs:t?Bn:On}),a=(c==null?void 0:c.tasks)??[],v=a.some(y=>y.running);v!==t&&l(v);const k=y=>r(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await d()}),h=(y,R)=>r(`${y.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{enabled:R}),R?`${y.name} switched on.`:`${y.name} switched off.`),await d()}),m=(y,R)=>r(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:R}),`${y.name} now runs ${Te(R)}.`),await d()}),g=y=>r(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:0}),`${y.name} back to its default cadence.`),await d()}),u=a.filter(y=>{var R;return((R=y.lastRun)==null?void 0:R.status)==="failed"}).length,b=a.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,j=a.filter(y=>!y.enabled).length,S=(c==null?void 0:c.groups)??[],x=a.filter(y=>!y.group);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Scheduled tasks",intro:"The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tasks",value:N(a.length),icon:"clock",tone:"info"},{label:"Running now",value:N(a.filter(y=>y.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:N(u),icon:"alert",tone:u>0?"bad":void 0},{label:"Switched off",value:N(j),icon:"power",tone:j>0?"warn":void 0},{label:"Retimed",value:N(b),icon:"clock",tone:b>0?"note":void 0}]}),u>0?e.jsx(me,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...S,...x.length>0?[""]:[]].map(y=>{const R=a.filter(M=>M.group===y);return R.length===0?null:e.jsx(I,{title:y||"Other",icon:y==="System"?"chip":y==="Analytics"?"chart":"wrench",tone:y==="System"?"info":y==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:R.map(M=>{var H;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[M.name," ",M.running?e.jsx(C,{tone:"info",children:"running"}):null,M.enabled?null:e.jsx(C,{tone:"warn",children:"off"}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(C,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:M.description}),e.jsxs("p",{className:"quiet",children:[Te(M.intervalSeconds),M.enabled&&M.nextRun?` · next ${be(M.nextRun).replace(" ago","")}`:"",M.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:O(M.lastRun.startedAt),children:be(M.lastRun.startedAt)}),` in ${ke(M.lastRun.durationMs)}`,M.lastRun.detail?` — ${M.lastRun.detail}`:""]}):" · never run"]}),(H=M.lastRun)!=null&&H.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(C,{tone:"bad",children:M.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[M.lastRun?e.jsx(C,{tone:Xe(M.lastRun.status),children:M.lastRun.status}):e.jsx(C,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${M.name} runs`,value:M.intervalSeconds,disabled:n===`${M.id}:interval`||M.running,onChange:w=>void m(M,Number(w.target.value)),children:Un(M).map(w=>e.jsxs("option",{value:w.value,children:[w.label,w.value===M.defaultIntervalSeconds?" (default)":""]},w.value))}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(A,{size:"sm",icon:"refresh",busy:n===`${M.id}:interval`,onClick:()=>void g(M),children:"Default"}):null,e.jsx(W,{label:"",checked:M.enabled,disabled:n===`${M.id}:enabled`,onChange:w=>void h(M,w)}),e.jsx(A,{size:"sm",icon:"play",busy:n===M.id,disabled:M.running,onClick:()=>void k(M),children:"Run now"})]})]},M.id)})})},y||"other")}),e.jsx(I,{title:"Recent runs",intro:"Every task together and in order, which is what shows two jobs interfering with each other.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Started"}),e.jsx("th",{children:"Task"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"}),e.jsx("th",{children:"Detail"})]})}),e.jsx("tbody",{children:((c==null?void 0:c.runs.length)??0)===0?e.jsx(te,{columns:6,children:"No task has run yet."}):c==null?void 0:c.runs.map(y=>{var R;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(y.startedAt),children:be(y.startedAt)}),e.jsx("td",{children:((R=a.find(M=>M.id===y.taskId))==null?void 0:R.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(C,{tone:Xe(y.status),children:y.status})}),e.jsx("td",{className:"num muted",children:ke(y.durationMs)}),e.jsx("td",{className:"muted",children:y.error||y.detail||"—"})]},y.id)})})]})})})]})]})}const _n={id:"",name:"Discord",url:"",enabled:!0,events:[]};function zn(){const{wrap:s,show:n}=se(),{busy:r,run:t}=Y(),{data:l,error:c,loading:i,reload:o}=J("/admin/api/integrations",{pollMs:6e4}),[d,a]=p.useState(null),[v,k]=p.useState(null),h=(l==null?void 0:l.catalogue)??[],m=(l==null?void 0:l.integrations)??[],g=S=>a({id:S.id,name:S.name,url:"",enabled:S.enabled,events:S.events??[]}),u=()=>t("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(a(null),await o())}),b=S=>t("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(S.id)}`),`${S.name} removed.`),k(null),await o()}),j=S=>t(`test:${S.id}`,async()=>{const x=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(S.id)}/test`));x&&n(x.message,x.ok?"ok":"bad"),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Integrations",intro:"Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere.",actions:e.jsx(A,{variant:"primary",icon:"plus",onClick:()=>a(_n),children:"Add a webhook"})}),e.jsx(B,{message:c}),e.jsx(Hn,{}),e.jsx(Kn,{}),e.jsx(Gn,{}),((l==null?void 0:l.dropped)??0)>0?e.jsxs(me,{tone:"warn",children:[N((l==null?void 0:l.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,i?e.jsx(_,{}):m.length===0&&!d?e.jsx(I,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(G,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):m.map(S=>e.jsx(Zn,{integration:S,catalogue:h,busy:r,onEdit:()=>g(S),onTest:()=>void j(S),onRemove:()=>k(S)},S.id)),d?e.jsx(Jn,{draft:d,catalogue:h,busy:r==="save",onChange:a,onSave:()=>void u(),onCancel:()=>a(null)}):null,v?e.jsx(he,{title:`Remove ${v.name}?`,body:"The webhook address and its delivery history go with it. Events already published stay in the activity feed.",confirmLabel:"Remove",destructive:!0,busy:r==="remove",onConfirm:()=>void b(v),onCancel:()=>k(null)}):null]})}function Hn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/arr-integrations"),o=d=>r("arr-integrations",async()=>{t&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:d.sonarrEnabled??t.sonarrEnabled,radarrEnabled:d.radarrEnabled??t.radarrEnabled}),"Integration settings saved."),await i())});return e.jsxs(I,{title:"Sonarr and Radarr",intro:"Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests.",icon:"plug",tone:"info",children:[e.jsx(B,{message:l??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(W,{label:"Sonarr enabled",hint:t!=null&&t.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(t!=null&&t.sonarrEnabled),disabled:!(t!=null&&t.sonarrConfigured)||n==="arr-integrations",onChange:d=>void o({sonarrEnabled:d})}),e.jsx(W,{label:"Radarr enabled",hint:t!=null&&t.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(t!=null&&t.radarrEnabled),disabled:!(t!=null&&t.radarrConfigured)||n==="arr-integrations",onChange:d=>void o({radarrEnabled:d})})]})]})}function Kn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/sonarr-request-policy"),[o,d]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const k=()=>r("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:o,searchImmediately:a}),"Sonarr TV request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(m=>m.id===o);return e.jsxs(I,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="sonarr-request-policy",disabled:c||o<=0,onClick:()=>void k(),children:"Save Sonarr policy"}),children:[e.jsx(B,{message:l??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:m=>d(Number(m.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:a,onChange:v}),h?e.jsxs(me,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), be monitored using Memby’s existing all-episodes strategy, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Gn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/radarr-request-policy"),[o,d]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const k=()=>r("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:o,searchImmediately:a}),"Radarr movie request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(m=>m.id===o);return e.jsxs(I,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="radarr-request-policy",disabled:c||o<=0,onClick:()=>void k(),children:"Save Radarr policy"}),children:[e.jsx(B,{message:l??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:m=>d(Number(m.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:a,onChange:v}),h?e.jsxs(me,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), remain monitored, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Zn({integration:s,catalogue:n,busy:r,onEdit:t,onTest:l,onRemove:c}){const i=s.health,o=!i.lastFailure||i.lastSuccess&&i.lastSuccess>i.lastFailure,d=s.events??[];return e.jsxs(I,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{tone:"warn",children:"off"}),i.deliveries>0?e.jsx(C,{tone:o?"ok":"bad",children:o?"delivering":"failing"}):e.jsx(C,{children:"never used"}),e.jsx(A,{size:"sm",icon:"pulse",busy:r===`test:${s.id}`,onClick:l,children:"Test"}),e.jsx(A,{size:"sm",onClick:t,children:"Edit"}),e.jsx(A,{size:"sm",variant:"danger",icon:"trash",onClick:c,title:"Remove"})]}),children:[e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Events sent"}),e.jsx("p",{children:d.length===0?"None selected — this destination is configured but will never post anything.":d.map(a=>{var v;return((v=n.find(k=>k.type===a))==null?void 0:v.label)??a}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:i.lastSuccess?O(i.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:i.deliveries>0?e.jsxs("span",{className:"quiet",children:[N(i.deliveries)," attempts, ",N(i.failures)," failed"]}):null})]}),i.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[O(i.lastFailure),i.lastError?` — ${i.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Attempted"}),e.jsx("th",{children:"Event"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:s.deliveries.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(a.attemptedAt),children:be(a.attemptedAt)}),e.jsx("td",{className:"muted",children:a.eventType}),e.jsx("td",{children:a.success?e.jsx(C,{tone:"ok",children:a.statusCode||"ok"}):e.jsx(C,{tone:"bad",children:a.error||a.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ke(a.durationMs)})]},a.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(te,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Jn({draft:s,catalogue:n,busy:r,onChange:t,onSave:l,onCancel:c}){const i=[...new Set(n.map(d=>d.group))],o=(d,a)=>t({...s,events:a?[...s.events,d]:s.events.filter(v=>v!==d)});return e.jsxs(I,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:r,onClick:l,children:s.id?"Save":"Add"}),e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx("span",{className:"spacer"}),s.events.length===0?e.jsx("span",{className:"quiet",children:"Nothing selected — this destination would never post."}):e.jsxs("span",{className:"quiet",children:[s.events.length," events selected"]})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>t({...s,name:d.target.value})})}),e.jsx(q,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>t({...s,url:d.target.value})})})]}),e.jsx(W,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:d=>t({...s,enabled:d})}),i.map(d=>e.jsxs("div",{children:[e.jsx("div",{className:"card-head",style:void 0,children:e.jsx("div",{className:"card-head-text",children:e.jsx("h2",{children:d})})}),n.filter(a=>a.group===d).map(a=>e.jsx(W,{label:a.label,hint:a.description,checked:s.events.includes(a.type),onChange:v=>o(a.type,v)},a.type))]},d))]})}function Yn(){var u;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(""),[a,v]=p.useState(!1),[k,h]=p.useState(!1),m=!!((u=s==null?void 0:s.maintenance)!=null&&u.enabled);p.useEffect(()=>{var b;!k&&s&&d(((b=s.maintenance)==null?void 0:b.message)??"")},[s,k]);const g=b=>i(b?"on":"off",async()=>{await l(()=>F.post("/admin/api/maintenance",{enabled:b,message:o}),b?"Memby is offline for every television.":"Memby is back online."),v(!1),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsx(I,{title:"Gateway availability",intro:"Takes Memby offline for every television, independently of Emby. Sign-in and all content calls answer 503 with the message below, and the television shows it in place of the launcher rows. This console keeps working.",icon:"power",tone:m?"bad":"warn",actions:m?e.jsx(C,{tone:"bad",children:"offline"}):e.jsx(C,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"danger",disabled:m,onClick:()=>v(!0),children:"Go offline"}),e.jsx(A,{disabled:!m,busy:c==="off",onClick:()=>void g(!1),children:"Bring back online"})]}),children:e.jsx(q,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:o,placeholder:"Back shortly — upgrading the server",onChange:b=>{d(b.target.value),h(!0)}})})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately and show your message in place of the launcher. This console keeps working.",confirmLabel:"Go offline",destructive:!0,busy:c==="on",onConfirm:()=>void g(!0),onCancel:()=>v(!1)}):null]})}function Xn(){const{status:s,error:n,loading:r}=ie(),t=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsx(I,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:8,children:"Nothing has been imported yet."}):t.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(l.startedAt)}),e.jsx("td",{children:l.kind}),e.jsx("td",{className:"muted",children:l.trigger}),e.jsx("td",{children:e.jsx(C,{tone:l.status==="success"?"ok":l.status==="running"?"warn":"bad",children:l.status})}),e.jsx("td",{className:"num",children:N(l.itemsSeen)}),e.jsx("td",{className:"num",children:N(l.itemsUpserted)}),e.jsx("td",{className:"num",children:N(l.itemsRemoved)}),e.jsx("td",{className:"muted",children:l.error||""})]},l.id||l.startedAt))})]})})})]})}const Qe={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},es=2e4,Qn=5e3,ye=48,et=31,ss=8,st=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],ns=new Map(st.map((s,n)=>[s,n])),nt=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ts=new WeakMap;function we(s){const n=ts.get(s);if(n)return n;const r=Object.entries(s.attributes??{}).sort((l,c)=>{const i=ns.get(l[0])??(l[0]==="error"?1e3:100),o=ns.get(c[0])??(c[0]==="error"?1e3:100);return i-o||l[0].localeCompare(c[0])}),t={fields:r,summary:r.map(([l,c])=>`${l}=${String(c)}`).join(" "),haystack:[s.message,...r.flat()].join(" ").toLowerCase(),occurred:nt.format(new Date(s.occurredAt))};return ts.set(s,t),t}const tt=s=>s.replace(/_/g," "),it=p.memo(function({event:n,index:r,onInspect:t}){const l=we(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${r*ye}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[l.occurred,e.jsxs("small",{children:["#",n.sequence]})]}),e.jsx("span",{className:"lvl",children:n.level}),e.jsx("span",{className:"msg",title:n.message,children:n.message}),e.jsx("button",{type:"button",className:"logattrs-button",title:l.summary||"No structured details",onClick:()=>t(n.sequence),children:l.summary||"View record"})]})});function at(){var D;const[s,n]=p.useState([]),[r,t]=p.useState(0),[l,c]=p.useState(!1),[i,o]=p.useState("INFO"),[d,a]=p.useState(""),[v,k]=p.useState(""),[h,m]=p.useState({top:0,height:600}),[g,u]=p.useState(null),b=p.useDeferredValue(d.trim().toLowerCase()),j=p.useRef(0),S=p.useRef(!1),x=p.useRef(0),y=p.useRef(null),R=p.useRef(!0),M=p.useRef(void 0),H=p.useCallback(async()=>{if(l||S.current||document.hidden)return;S.current=!0;const $=x.current,P=[];let oe=0;try{let ce=0,le;do le=await F.get(`/admin/api/events?after=${j.current}&limit=1000`),j.current=le.next||j.current,oe+=le.dropped||0,P.push(...le.events??[]),ce+=1;while(le.hasMore&&ce<20);k("")}catch(ce){k(ce instanceof Error?ce.message:String(ce))}finally{P.length>0&&$===x.current&&n(ce=>{const le=ce.concat(P);return le.length>es?le.slice(le.length-es):le}),oe>0&&$===x.current&&t(ce=>ce+oe),S.current=!1}},[l]);p.useEffect(()=>{if(l)return;let $;const P=()=>{window.clearInterval($),$=document.hidden?void 0:window.setInterval(()=>void H(),Qn)},oe=()=>{P(),document.hidden||H()};return H(),P(),document.addEventListener("visibilitychange",oe),()=>{window.clearInterval($),document.removeEventListener("visibilitychange",oe)}},[H,l]);const w=p.useMemo(()=>{const $=Qe[i]??20;return s.filter(P=>(Qe[P.level]??0)>=$&&(!b||we(P).haystack.includes(b)))},[s,i,b]),L=((D=w.at(-1))==null?void 0:D.sequence)??0,X=Math.max(0,h.top-et),re=Math.ceil(h.height/ye)+ss*2,K=Math.min(Math.max(0,Math.floor(X/ye)-ss),Math.max(0,w.length-re)),E=w.slice(K,K+re),T=p.useMemo(()=>s.find($=>$.sequence===g),[s,g]);p.useLayoutEffect(()=>{const $=y.current;!$||!R.current||($.scrollTop=$.scrollHeight,m({top:$.scrollTop,height:$.clientHeight}))},[L,b,i]),p.useEffect(()=>()=>window.cancelAnimationFrame(M.current??0),[]);const z=()=>{const $=y.current;$&&(R.current=$.scrollHeight-$.scrollTop-$.clientHeight{m({top:$.scrollTop,height:$.clientHeight})}))},f=()=>{const $=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),P=document.createElement("a");P.href=URL.createObjectURL($),P.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,P.click(),window.setTimeout(()=>URL.revokeObjectURL(P.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(B,{message:v}),e.jsxs(I,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Level",children:e.jsxs("select",{value:i,onChange:$=>o($.target.value),children:[e.jsx("option",{value:"DEBUG",children:"Debug and above"}),e.jsx("option",{value:"INFO",children:"Info and above"}),e.jsx("option",{value:"WARN",children:"Warnings and errors"}),e.jsx("option",{value:"ERROR",children:"Errors only"})]})}),e.jsx(q,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:d,placeholder:"Person, television, title, component, path…",onChange:$=>a($.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(A,{onClick:()=>c($=>!$),icon:l?"play":"clock",children:l?"Resume":"Pause"}),e.jsx(A,{onClick:()=>{x.current+=1,n([]),t(0),u(null)},children:"Clear view"}),e.jsx(A,{onClick:f,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:y,onScroll:z,role:"log","aria-label":"Server events",children:[e.jsxs("div",{className:"loghead","aria-hidden":"true",children:[e.jsx("span",{children:"Time"}),e.jsx("span",{children:"Level"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Details"})]}),w.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match this filter."}):e.jsx("div",{className:"logbody",style:{height:`${w.length*ye}px`},children:E.map(($,P)=>e.jsx(it,{event:$,index:K+P,onInspect:u},$.sequence))})]}),e.jsxs("p",{className:"hint",children:[N(s.length)," retained · ",N(w.length)," matching",w.length?` · ${N(E.length)} rows mounted`:"",r?` · ${N(r)} overwritten before delivery`:"",l?" · paused":""]}),T?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${T.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:T.message}),e.jsxs("span",{children:[we(T).occurred," · ",T.level," · record #",T.sequence]})]}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>u(null),children:"Close"})]}),we(T).fields.length?e.jsx("dl",{children:we(T).fields.map(([$,P])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:tt($)}),e.jsx("dd",{children:String(P)})]},$))}):e.jsx(me,{children:"No structured details were attached to this record."})]}):null]})]})}const is=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","notifications","profiles","settings"];function pe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function rt(){const[s,n]=p.useState(30),[r,t]=p.useState(""),l=qe(),c=p.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:r})}`,[s,r]),{data:i,error:o,loading:d}=J(c),a=i==null?void 0:i.stats,v=(i==null?void 0:i.users)??[],k=(i==null?void 0:i.actions)??[],h=(i==null?void 0:i.paths)??[],m=h[0],g=p.useMemo(()=>{const u=new Map(((i==null?void 0:i.features)??[]).map(j=>[j.feature,j])),b=new Map(is.map((j,S)=>[j,S]));return[...new Set([...is,...u.keys()])].map(j=>({name:j,stat:u.get(j)})).sort((j,S)=>{var y,R;const x=(((y=S.stat)==null?void 0:y.uses)??0)-(((R=j.stat)==null?void 0:R.uses)??0);return x||(b.get(j.name)??Number.MAX_SAFE_INTEGER)-(b.get(S.name)??Number.MAX_SAFE_INTEGER)})},[i==null?void 0:i.features]);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(B,{message:o}),e.jsx(I,{title:"Journey health",intro:"Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored.",icon:"people",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:u=>n(Number(u.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(q,{label:"User",children:e.jsxs("select",{value:r,onChange:u=>{const b=u.target.value;t(b),b&&l(`/admin/journeys/${encodeURIComponent(b)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(u=>e.jsx("option",{value:u.userId,children:u.username||u.userId},u.userId))]})})]}),children:d?e.jsx(_,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"journeys",value:N(a==null?void 0:a.journeys),icon:"list",tone:"data"},{label:"viewers",value:N(a==null?void 0:a.viewers),icon:"people",tone:"info"},{label:"completion",value:Le(a==null?void 0:a.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:N(a==null?void 0:a.abandoned),icon:"alert",tone:"note"},{label:"active now",value:N(a==null?void 0:a.active),icon:"pulse",tone:"info"},{label:"average steps",value:((a==null?void 0:a.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ke(a==null?void 0:a.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(i==null?void 0:i.retentionDays)??90} days`,small:!0,icon:"clock"}]}),e.jsxs("div",{className:"summary-grid",children:[e.jsxs("div",{className:"summary",children:[e.jsxs("div",{className:"summary-head",children:[e.jsx("b",{children:"Visit completion"}),e.jsx("strong",{children:Le(a==null?void 0:a.completionRate)})]}),e.jsx(Ys,{value:(a==null?void 0:a.completed)??0,total:(a==null?void 0:a.journeys)??0}),e.jsxs("p",{children:[N(a==null?void 0:a.completed)," completed · ",N(a==null?void 0:a.abandoned)," abandoned ·"," ",N(a==null?void 0:a.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:m?`${pe(m.from)} → ${pe(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${N(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Action"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{className:"num",children:"Visits"})]})}),e.jsx("tbody",{children:k.length===0?e.jsx(te,{columns:3,children:"No significant actions in this window."}):k.map(u=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:pe(u.action)}),e.jsx("span",{className:"table-sub",children:pe(u.category)})]}),e.jsx("td",{className:"num",children:N(u.events)}),e.jsx("td",{className:"num",children:N(u.journeys)})]},`${u.category}:${u.action}`))})]})})}),e.jsx(I,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:h.length===0?e.jsx(te,{columns:2,children:"No repeated paths in this window."}):h.map((u,b)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[pe(u.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",pe(u.to)]}),e.jsx("td",{className:"num",children:N(u.count)})]},`${u.from}:${u.to}:${b}`))})]})})})]}),e.jsx(I,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Feature"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{children:"Last used"}),e.jsx("th",{children:"Status"})]})}),e.jsx("tbody",{children:g.map(({name:u,stat:b})=>{const j=(b==null?void 0:b.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:pe(u)}),e.jsx("td",{className:"num",children:N(j)}),e.jsx("td",{className:"muted nowrap",children:b?O(b.lastUsedAt):"—"}),e.jsx("td",{children:j===0?e.jsx(C,{tone:"warn",children:"not used"}):j<3?e.jsx(C,{tone:"note",children:"rare"}):e.jsx(C,{tone:"ok",children:"used"})})]},u)})})]})})}),r?null:e.jsx(I,{title:"Inspect a viewer",intro:"Choose a person above to open their dedicated session and viewing-journey timeline.",icon:"journey",tone:"info",children:e.jsx("p",{className:"empty",children:"A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys."})})]})}const je=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},ws=s=>je((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),as=s=>s.itemName?`${je(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${je(s.source)} → ${je(s.target)}`:ws(s),lt=s=>({journey_start:"Opened Memby",home_open:"Opened Memby",journey_end:"Finished session",screen_view:"Viewed",select:"Selected",open:"Opened",close:"Closed",request:s.category==="playback"?"Started watching":"Requested",stop:"Stopped watching",start:"Started",complete:"Completed"})[s.action]??je(s.action);function ot(s){var r;const n=(r=[...s].reverse().find(t=>t.outcome))==null?void 0:r.outcome;return n==="success"||n==="completed"?{label:je(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:je(n),tone:"note"}:s.some(t=>t.action==="stop"&&t.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function ct(s){const n=s.reduce((r,t,l)=>(t.category==="playback"&&t.action==="request"&&r.push(l),r),[]);return n.length===0?[s]:n.map((r,t)=>s.slice(t===0?0:r,n[t+1]??s.length))}function dt(){var d,a;const{userId:s=""}=Re(),n=p.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:r,error:t,loading:l}=J(n),c=((a=(d=r==null?void 0:r.users)==null?void 0:d.find(v=>v.userId===s))==null?void 0:a.username)||s,i=p.useMemo(()=>{const v=new Map;for(const k of(r==null?void 0:r.events)??[])v.set(k.journeyId,[...v.get(k.journeyId)??[],k]);return[...v.values()].map(k=>k.sort((h,m)=>h.sequence-m.sequence)).sort((k,h)=>{var m,g;return(((m=h[0])==null?void 0:m.occurredAt)??"").localeCompare(((g=k[0])==null?void 0:g.occurredAt)??"")})},[r==null?void 0:r.events]),o=i.flatMap(v=>ct(v).map((k,h)=>{var m;return{events:k,key:`${(m=v[0])==null?void 0:m.journeyId}:${h}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:`${c}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(ee,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(B,{message:t}),l?e.jsx(_,{}):e.jsx(I,{title:"Viewing journeys",intro:`${i.length} app session${i.length===1?"":"s"} · ${o.length} viewing journey${o.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:o.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):o.map((v,k)=>{const h=v.events,m=h[0],g=[...h].reverse().find(b=>b.itemName||b.action==="select"||b.category==="playback"&&b.action==="request"),u=ot(h);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:O(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",k+1," · ",h.length," recorded steps"]})]}),e.jsx(C,{tone:u.tone,children:u.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(ne,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:ws(m)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(ne,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:g?as(g):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(ne,{name:u.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:u.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:h.map(b=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":b.action}),e.jsxs("div",{children:[e.jsx("b",{children:lt(b)}),e.jsx("span",{children:as(b)})]}),e.jsx("time",{children:O(b.occurredAt)})]},`${b.journeyId}:${b.sequence}`))})]},v.key)})})})]})}function rs(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function ht(){const[s,n]=p.useState(30),{data:r,error:t,loading:l}=J(`/admin/api/analytics?days=${s}`),c=(r==null?void 0:r.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(B,{message:t}),e.jsx(I,{title:"Launcher rows",intro:"Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth.",icon:"chart",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:i=>n(Number(i.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:l?e.jsx(_,{rows:1}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Row"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{className:"num",children:"Dwell"}),e.jsx("th",{className:"num",children:"Impressions"}),e.jsx("th",{className:"num",children:"Focuses"}),e.jsx("th",{className:"num",children:"Opened"}),e.jsx("th",{className:"num",children:"Open rate"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:8,children:"No events in this window."}):c.map(i=>e.jsxs("tr",{children:[e.jsx("td",{children:rs(i.rowId)}),e.jsx("td",{className:"muted",children:rs(i.rowKind)}),e.jsx("td",{className:"num",children:ke(i.dwellMs)}),e.jsx("td",{className:"num",children:N(i.impressions)}),e.jsx("td",{className:"num",children:N(i.focuses)}),e.jsx("td",{className:"num",children:N(i.selects)}),e.jsx("td",{className:"num",children:Le(i.selectRate)}),e.jsx("td",{className:"num",children:N(i.viewers)})]},`${i.rowId}:${i.rowKind}`))})]})})})]})}function ut(){const[s,n]=p.useState(7),{data:r,error:t,loading:l}=J(`/admin/api/searches?days=${s}`),c=(r==null?void 0:r.terms)??[],i=(r==null?void 0:r.recent)??[],o=r==null?void 0:r.totals;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(B,{message:t}),e.jsx(ae,{tiles:[{label:"searches",value:N((o==null?void 0:o.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:N((o==null?void 0:o.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:N((o==null?void 0:o.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(r==null?void 0:r.retentionDays)??30} days`,small:!0,icon:"clock"}]}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"What the house looks for",intro:"Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too.",icon:"search",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:d=>n(Number(d.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Query"}),e.jsx("th",{className:"num",children:"Searches"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Last searched"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:4,children:"Nothing searched in this window."}):c.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.query}),e.jsx("td",{className:"num",children:N(d.searches)}),e.jsx("td",{className:"num",children:N(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:O(d.lastAt)})]},d.query))})]})})}),e.jsx(I,{title:"As it happened",intro:"The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(te,{columns:3,children:"No searches in this window."}):i.map((d,a)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:O(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(C,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${a}`))})]})})})]})]})}function ls(s,n){if(n===0)return s>0?"new this week":"no change";const r=Math.round((s-n)/n*100);return`${r>0?"+":""}${r}% vs last week`}function mt(){const{data:s,error:n,loading:r}=J("/admin/api/views",{pollMs:6e4}),t=(s==null?void 0:s.daily)??[],l=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(B,{message:n}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:ls((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:N(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:ls((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:N(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(I,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet."}):t.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})}),e.jsx(I,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:l.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet today."}):l.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})})]})]})}const pt=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function xt(){var l;const s=J("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:r}=Y(),t=(c,i)=>r(`${c.id}-${i}`,async()=>{await F.post(`/admin/api/media-reports/${c.id}/status`,{status:i}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(_,{rows:4}):e.jsx(I,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(l=s.data)!=null&&l.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(c=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:pt(c)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.title})]}),e.jsxs("td",{children:[c.reason.replaceAll("_"," "),c.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.comment})]}):null]}),e.jsxs("td",{children:[c.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(C,{tone:c.replacementRequested?"note":void 0,children:c.replacementRequested?c.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(C,{tone:c.status==="resolved"?"ok":c.status==="dismissed"?void 0:"warn",children:c.status})}),e.jsx("td",{className:"nowrap muted",children:O(c.createdAt)}),e.jsxs("td",{children:[e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-acknowledged`,onClick:()=>void t(c,"acknowledged"),children:"Acknowledge"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-resolved`,onClick:()=>void t(c,"resolved"),children:"Resolve"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-dismissed`,onClick:()=>void t(c,"dismissed"),children:"Dismiss"})]})]},c.id))})]})}):e.jsx(G,{children:"No media problems have been reported."})})]})}function jt(){return e.jsx(V,{title:"No such page",intro:"That address is not part of the console. Use the search in the bar above, or the sections on the left."})}function vt(){return e.jsx(Rs,{children:e.jsx(Ws,{children:e.jsx(Zs,{children:e.jsx(sn,{children:e.jsx(Es,{children:e.jsxs(U,{path:"/admin",element:e.jsx(Qs,{}),children:[e.jsx(U,{index:!0,element:e.jsx(nn,{})}),e.jsx(U,{path:"activity",element:e.jsx(an,{})}),e.jsx(U,{path:"accounts",element:e.jsx(rn,{})}),e.jsx(U,{path:"accounts/:userId",element:e.jsx(ln,{})}),e.jsx(U,{path:"accounts/:userId/settings",element:e.jsx(mn,{})}),e.jsx(U,{path:"clients",element:e.jsx(xn,{})}),e.jsx(U,{path:"logins",element:e.jsx(vn,{})}),e.jsx(U,{path:"devices/:deviceId",element:e.jsx(yn,{})}),e.jsx(U,{path:"library",element:e.jsx(wn,{})}),e.jsx(U,{path:"ratings",element:e.jsx(Nn,{})}),e.jsx(U,{path:"requests",element:e.jsx(Sn,{})}),e.jsx(U,{path:"recommendations",element:e.jsx(Cn,{})}),e.jsx(U,{path:"inspector",element:e.jsx(En,{})}),e.jsx(U,{path:"hero",element:e.jsx(Tn,{})}),e.jsx(U,{path:"features",element:e.jsx(Dn,{})}),e.jsx(U,{path:"playback",element:e.jsx(qn,{})}),e.jsx(U,{path:"subtitles",element:e.jsx(Fn,{})}),e.jsx(U,{path:"updates",element:e.jsx(Pn,{})}),e.jsx(U,{path:"tasks",element:e.jsx(Wn,{})}),e.jsx(U,{path:"integrations",element:e.jsx(zn,{})}),e.jsx(U,{path:"maintenance",element:e.jsx(Yn,{})}),e.jsx(U,{path:"imports",element:e.jsx(Xn,{})}),e.jsx(U,{path:"logs",element:e.jsx(at,{})}),e.jsx(U,{path:"journeys",element:e.jsx(rt,{})}),e.jsx(U,{path:"journeys/:userId",element:e.jsx(dt,{})}),e.jsx(U,{path:"views",element:e.jsx(mt,{})}),e.jsx(U,{path:"engagement",element:e.jsx(ht,{})}),e.jsx(U,{path:"searches",element:e.jsx(ut,{})}),e.jsx(U,{path:"media-reports",element:e.jsx(xt,{})}),e.jsx(U,{path:"overview",element:e.jsx(As,{to:"/admin",replace:!0})}),e.jsx(U,{path:"*",element:e.jsx(jt,{})})]})})})})})})}const ks=document.getElementById("root");if(!ks)throw new Error("the console has no root element to render into");ds(ks).render(e.jsx(p.StrictMode,{children:e.jsx(vt,{})})); diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index 24c5069..bdf9c52 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -13,7 +13,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" /> - + diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 450ce71..a9af90f 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -355,6 +355,8 @@ export interface ScheduledTask { description: string; group: string; intervalSeconds: number; + /** The cadence declared in code. Differs from intervalSeconds only when overridden. */ + defaultIntervalSeconds: number; enabled: boolean; running: boolean; nextRun?: string; diff --git a/admin-ui/src/pages/Tasks.tsx b/admin-ui/src/pages/Tasks.tsx index 5d3291e..82ebbe7 100644 --- a/admin-ui/src/pages/Tasks.tsx +++ b/admin-ui/src/pages/Tasks.tsx @@ -28,6 +28,48 @@ import type { Tone } from '../lib/format'; const IDLE_POLL_MS = 20_000; const BUSY_POLL_MS = 3_000; +/* The cadences an operator may choose from. + * + * A fixed list rather than a free-text duration, because the useful range here spans three + * orders of magnitude and the two ways to get it wrong are both silent: a number typed in + * the wrong unit, and a cadence so tight the job never finishes before it is due again. The + * floor matches the scheduler's own — it clamps anything under a minute — so the console + * cannot offer a value the server would quietly change underneath it. + * + * Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than + * as "never", so a "run by hand only" entry here would appear to do nothing on any task that + * declares an interval. That is a server-side limitation and it belongs in the server, not + * in a control that lies about it. */ +const CADENCE_CHOICES: { value: number; label: string }[] = [ + { value: 60, label: 'Every minute' }, + { value: 300, label: 'Every 5 minutes' }, + { value: 600, label: 'Every 10 minutes' }, + { value: 900, label: 'Every 15 minutes' }, + { value: 1_800, label: 'Every 30 minutes' }, + { value: 3_600, label: 'Hourly' }, + { value: 10_800, label: 'Every 3 hours' }, + { value: 21_600, label: 'Every 6 hours' }, + { value: 43_200, label: 'Every 12 hours' }, + { value: 86_400, label: 'Daily' }, + { value: 604_800, label: 'Weekly' }, +]; + +/* The choices for one task: the presets, plus its own declared cadence and whatever it is + * currently set to if either falls outside the list. + * + * Adding them rather than snapping to the nearest preset is what stops the control being + * destructive to look at — a task declaring 45 minutes must not silently become hourly + * because somebody opened the page and the select had to show *something*. */ +function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] { + const choices = [...CADENCE_CHOICES]; + for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) { + if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) { + choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') }); + } + } + return choices.sort((a, b) => a.value - b.value); +} + function statusTone(status: TaskRun['status']): Tone { if (status === 'failed') return 'bad'; if (status === 'running') return 'info'; @@ -65,7 +107,32 @@ export function TasksPage() { await reload(); }); + // Named setCadence rather than setInterval so it cannot shadow the global of that + // name inside this component, which is a trap for anything added here later. + const setCadence = (task: ScheduledTask, intervalSeconds: number) => + run(`${task.id}:interval`, async () => { + await wrap( + () => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }), + `${task.name} now runs ${interval(intervalSeconds)}.`, + ); + await reload(); + }); + + // Sending zero is how the API is told to forget an override, so this is a separate call + // from the select rather than an option inside it — see CADENCE_CHOICES. + const resetCadence = (task: ScheduledTask) => + run(`${task.id}:interval`, async () => { + await wrap( + () => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }), + `${task.name} back to its default cadence.`, + ); + await reload(); + }); + const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length; + const retimed = tasks.filter( + (task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds, + ).length; const disabled = tasks.filter((task) => !task.enabled).length; const groups = data?.groups ?? []; @@ -105,6 +172,15 @@ export function TasksPage() { icon: 'power', tone: disabled > 0 ? 'warn' : undefined, }, + // Worth a tile of its own: a retimed task is the most likely explanation for + // "why has this not run", and it is invisible on a page that only prints the + // cadence currently in force. + { + label: 'Retimed', + value: num(retimed), + icon: 'clock', + tone: retimed > 0 ? 'note' : undefined, + }, ]} /> @@ -133,6 +209,10 @@ export function TasksPage() { {task.name}{' '} {task.running ? running : null} {!task.enabled ? off : null} + {task.defaultIntervalSeconds > 0 && + task.intervalSeconds !== task.defaultIntervalSeconds ? ( + retimed + ) : null}

{task.description}

@@ -161,6 +241,33 @@ export function TasksPage() { ) : ( never run )} + {/* Disabled while a run is in flight: changing the cadence + reschedules from now, and doing that underneath a running job + is how one run silently becomes two. */} + + {task.defaultIntervalSeconds > 0 && + task.intervalSeconds !== task.defaultIntervalSeconds ? ( + + ) : null} - preferences[Keys.SPEED_UP_CREDITS] = enabled - updateActiveProfile(preferences) { it.copy(speedUpCredits = enabled) } - } - } - /** * The viewer picking a colour scheme. Not validated here on purpose: the legal ids are * the gateway's catalogue and this build has no copy of it, so the only honest check is @@ -817,7 +808,6 @@ class SettingsStore(private val context: Context) { store[Keys.SEEK_INTERVAL_SECONDS] = normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds) store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode) - store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n") store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n") @@ -842,7 +832,6 @@ class SettingsStore(private val context: Context) { seekIntervalSeconds = normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds), skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode), - speedUpCredits = preferences.speedUpCredits, forYouMinutes = preferences.forYouMinutes, homeRowOrder = preferences.homeRowOrder.joinToString("\n"), homePinnedRows = preferences.homePinnedRows.joinToString("\n"), @@ -1269,7 +1258,6 @@ class SettingsStore(private val context: Context) { seekIntervalSeconds = previous?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS, skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE, - speedUpCredits = previous?.speedUpCredits ?: true, ) profiles.removeAll { it.id == id } profiles.add(profile) @@ -1419,7 +1407,6 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] = normalizeSeekIntervalSeconds(profile.seekIntervalSeconds) preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode) - preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision preferences.remove(Keys.LAST_BACKDROP_URL) } @@ -1463,7 +1450,6 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS, ), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]), - speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID, ) } @@ -1501,7 +1487,6 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS, ), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]), - speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, audioPassthroughMode = AudioPassthroughMode .from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value, audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(), diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 123489f..e40a598 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -58,8 +58,6 @@ data class UserPreferences( val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, /** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */ val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, - /** Shrink the closing credits to one side at double speed with what is on next beside. */ - val speedUpCredits: Boolean = true, val forYouMinutes: Int = 0, val homeRowOrder: List = emptyList(), val homePinnedRows: List = emptyList(), @@ -93,7 +91,6 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences( subtitleLanguage = subtitleLanguage, seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds), skipIntroMode = normalizeSkipIntroMode(skipIntroMode), - speedUpCredits = speedUpCredits, forYouMinutes = forYouMinutes, homeRowOrder = homeRowOrder.decodeLineList(), homePinnedRows = homePinnedRows.decodeLineList(), @@ -145,7 +142,6 @@ fun decodeUserPreferences( skipIntroMode = normalizeSkipIntroMode( json.string("skipIntroMode", fallback.skipIntroMode), ), - speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits), forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes), homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder), homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows), @@ -170,7 +166,6 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject { put("subtitleLanguage", subtitleLanguage) put("seekIntervalSeconds", seekIntervalSeconds) put("skipIntroMode", skipIntroMode) - put("speedUpCredits", speedUpCredits) put("forYouMinutes", forYouMinutes) putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } } putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsCountdown.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsCountdown.kt new file mode 100644 index 0000000..324d32a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsCountdown.kt @@ -0,0 +1,21 @@ +package com.ponzischeme89.memby.ui.player + +/** + * How long until the next episode takes over, as the credits countdown prints it. + * + * Pure and unit-tested, apart from the player, for the same reason the speed arithmetic + * beside it is: this is the one number on the credits pane that changes, it is drawn four + * times a second, and the ways it can be wrong — a stray "0:60", a negative value at the + * moment of handover — are ones nobody would catch by looking at a television. + * + * A clock value rather than a count of seconds. The pane can open with more than a minute + * still to run, and "in 78" is not a time anybody reads; the seconds are padded so the figure + * does not change width as it counts down, which on a 30sp bold number is a visible twitch. + */ +fun creditsCountdownLabel(remainingMs: Long): String { + // Rounded up, so the figure reaches "0:01" and is replaced by the next episode rather + // than resting on "0:00" for the last tick. Clamped at zero because the playhead can + // legitimately pass the end between the tick and the read. + val seconds = if (remainingMs <= 0L) 0L else (remainingMs + 999L) / 1_000L + return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}" +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt index dc754f3..8dbe5d6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt @@ -99,20 +99,6 @@ fun creditsCeilingAfterStall(ceiling: Float): Float { /** Whether a ceiling still has any speeding up left in it. */ fun creditsSpeedIsActive(ceiling: Float): Boolean = ceiling > CREDITS_NORMAL_SPEED + SPEED_EPSILON -/** - * How a speed reads on the chip in the corner of the panel: "2×", "1.5×". - * - * Built out of whole tenths rather than by formatting the float. A quantised 1.5 is not - * exactly 1.5 in binary, so `toString` on it prints "1.5000001", and `String.format` would - * print a comma for the decimal point on a set configured in half of Europe. - */ -fun creditsSpeedLabel(speed: Float): String { - val tenths = Math.round(speed * 10f) - val whole = tenths / 10 - val fraction = tenths % 10 - return if (fraction == 0) "$whole×" else "$whole.$fraction×" -} - /** * Rounds to the nearest step. Written as a multiply-round-divide so that the three speeds * that matter — 1.0, 1.5, 2.0 — come back exactly, which is what lets a test assert the ramp diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 4ff0984..5be4381 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -344,7 +344,12 @@ class PlayerActivity : ComponentActivity() { private var endCreditsAvailable = false private var creditsView: View? = null private var creditsCountdown: TextView? = null - private var creditsSpeedChip: TextView? = null + private var creditsCountdownGroup: View? = null + // What is *ending*, opposite the panel describing what is next. Exactly one of the two is + // ever shown: an Emby logo is commonly black on transparent, so a set that drew the image + // whenever there was one would print an invisible heading on this near-black screen. + private var creditsLogo: ImageView? = null + private var creditsLogoText: TextView? = null private var creditsStartMs: Long? = null private var creditsActive = false /** @@ -363,6 +368,8 @@ class PlayerActivity : ComponentActivity() { */ private var creditsSpeedCeiling = CREDITS_TARGET_SPEED private var creditsEnteredAtMs = 0L + /** When the speed was last actually changed, so the refill it causes is not read as a stall. */ + private var creditsSpeedChangedAtMs = 0L // The ten-minute lower third. Shown once per item — a viewer who has been told is // told; re-announcing it every time they seek would be nagging, not informing. @@ -2779,13 +2786,11 @@ class PlayerActivity : ComponentActivity() { // episode twice, so the countdown moves into the pane instead — and it is only worth // drawing inside the last minute, where it was always the banner's job. if (creditsActive) { - creditsCountdown?.apply { - if (remainingMs in 1L..NEXT_UP_LEAD_MS) { - text = getString(R.string.next_up_starting_in, ceil(remainingMs / 1_000.0).toInt()) - visibility = View.VISIBLE - } else { - visibility = View.GONE - } + if (remainingMs in 1L..NEXT_UP_LEAD_MS) { + creditsCountdown?.text = creditsCountdownLabel(remainingMs) + creditsCountdownGroup?.visibility = View.VISIBLE + } else { + creditsCountdownGroup?.visibility = View.GONE } if (remainingMs == 0L) playNext(next) return @@ -2995,7 +3000,9 @@ class PlayerActivity : ComponentActivity() { val view = findViewById(R.id.player_end_credits) creditsView = view creditsCountdown = view.findViewById(R.id.player_end_credits_countdown) - creditsSpeedChip = view.findViewById(R.id.player_end_credits_speed) + creditsCountdownGroup = view.findViewById(R.id.player_end_credits_countdown_group) + creditsLogo = view.findViewById(R.id.player_end_credits_logo) + creditsLogoText = view.findViewById(R.id.player_end_credits_logo_text) view.findViewById(R.id.player_end_credits_play).setOnClickListener { nextEpisode?.let(::playNext) } @@ -3036,13 +3043,14 @@ class PlayerActivity : ComponentActivity() { * also means the pane inherits auto-play's switch, since a next episode is only resolved * when auto-play is on — deliberate, because this is the auto-advance experience and a * viewer who turned that off has said they want the credits. + * + * There is no per-viewer switch for the pane itself. `creditsDismissed` is the only way + * out and it is deliberately per episode: "Watch credits" is a decision about the thing + * on screen now, which is what somebody wanting the roll actually means, where a setting + * buried two screens away was a decision made once and never revisited. */ private fun updateEndCreditsFromPlayhead(playback: Player, next: NextEpisode) { if (creditsDismissed) return - if (!(ServiceLocator.settings.current?.speedUpCredits ?: true)) { - if (creditsActive) leaveEndCredits(restoreSpeed = true) - return - } val duration = playback.duration if (!creditsWorthShowing(creditsStartMs, duration)) return val start = creditsStartMs ?: return @@ -3074,8 +3082,8 @@ class PlayerActivity : ComponentActivity() { view.findViewById(R.id.player_end_credits_image).load(next.imageUrl) { crossfade(true) } - creditsCountdown?.visibility = View.GONE - updateCreditsSpeedChip(CREDITS_NORMAL_SPEED) + creditsCountdownGroup?.visibility = View.GONE + bindCreditsLogo() view.alpha = 0f view.visibility = View.VISIBLE @@ -3092,6 +3100,48 @@ class PlayerActivity : ComponentActivity() { startCreditsSpeedRamp() } + /** + * Puts what is ending in the top left, opposite the panel describing what is next. + * + * The same treatment [bindTitleArtwork] gives the transport's title, and for the same + * reason: an Emby logo is commonly black on transparent, so it is tinted against a dark + * background before being shown, and a logo that fails to load falls back to words rather + * than leaving the corner empty. Exactly one of the image and the text is ever visible. + * + * The fallback prefers the *series* name over [playbackTitle], which on an episode is the + * episode's own name — this corner is about the programme, and the episode title is + * already printed opposite for the one coming next. + */ + private fun bindCreditsLogo() { + val logo = creditsLogo ?: return + val fallback = creditsLogoText ?: return + val name = nextEpisode?.seriesName?.takeIf(String::isNotBlank) + ?: playbackTitle.takeIf(String::isNotBlank) + fallback.text = name.orEmpty() + + val url = logoUrl?.takeIf(String::isNotBlank) + if (url == null) { + logo.clearColorFilter() + logo.visibility = View.GONE + fallback.visibility = if (name.isNullOrBlank()) View.GONE else View.VISIBLE + return + } + logo.load(url) { + crossfade(false) + listener( + onSuccess = { _, result -> + makeLogoVisibleOnDarkBackground(logo, result.drawable) + logo.visibility = View.VISIBLE + fallback.visibility = View.GONE + }, + onError = { _, _ -> + logo.visibility = View.GONE + fallback.visibility = if (name.isNullOrBlank()) View.GONE else View.VISIBLE + }, + ) + } + } + /** * Puts the picture and the speed back, without recording a decision. * @@ -3148,21 +3198,17 @@ class PlayerActivity : ComponentActivity() { val playback = player ?: return if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return playback.setPlaybackSpeed(speed) - updateCreditsSpeedChip(speed) - } - - private fun updateCreditsSpeedChip(speed: Float) { - val chip = creditsSpeedChip ?: return - if (!creditsSpeedIsActive(speed)) { - chip.visibility = View.GONE - return - } - chip.text = getString(R.string.end_credits_speed, creditsSpeedLabel(speed)) - chip.visibility = View.VISIBLE + // Every speed change makes the pipeline refill, and that refill is a rebuffer. The + // moment it happened is recorded so the stall it causes is not read as evidence that + // the stream cannot sustain the speed that caused it — see [stepDownCreditsSpeed]. + creditsSpeedChangedAtMs = SystemClock.elapsedRealtime() + MembyDiagnostics.debug( + "credits_speed", "playback" to playSessionId, "item" to itemId, + "speed" to speed, "ceiling" to creditsSpeedCeiling, + ) } private fun restoreNormalSpeed() { - creditsSpeedChip?.visibility = View.GONE val playback = player ?: return if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) { return @@ -3181,7 +3227,25 @@ class PlayerActivity : ComponentActivity() { */ private fun stepDownCreditsSpeed() { if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return + // A speed change empties and refills the pipeline, which media3 reports as buffering + // — so the stall that *follows* raising the speed is caused by the raise rather than + // by the stream being unable to hold it. Counting it was reading the cause as the + // symptom: entering the credits produced one stall immediately, which dropped the + // ceiling to 1.5×, whose own change produced another, which dropped it to 1×. The + // credits then ran at normal speed with nothing on screen saying why. + val sinceChange = SystemClock.elapsedRealtime() - creditsSpeedChangedAtMs + if (sinceChange < CREDITS_STALL_GRACE_MS) { + MembyDiagnostics.debug( + "credits_stall_ignored", "playback" to playSessionId, "item" to itemId, + "since_speed_change_ms" to sinceChange, + ) + return + } creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling) + MembyDiagnostics.debug( + "credits_speed_stepped_down", "playback" to playSessionId, "item" to itemId, + "ceiling" to creditsSpeedCeiling, + ) creditsSpeedJob?.cancel() creditsSpeedJob = null if (!creditsSpeedIsActive(creditsSpeedCeiling)) { @@ -3230,7 +3294,8 @@ class PlayerActivity : ComponentActivity() { creditsDismissed = false creditsStartMs = null creditsSpeedCeiling = CREDITS_TARGET_SPEED - creditsSpeedChip?.visibility = View.GONE + creditsSpeedChangedAtMs = 0L + creditsCountdownGroup?.visibility = View.GONE creditsView?.apply { animate().cancel() visibility = View.GONE @@ -4963,6 +5028,16 @@ class PlayerActivity : ComponentActivity() { /** Floats compared by threshold, so an unchanged speed is never re-sent. */ private const val CREDITS_SPEED_EPSILON = 0.001f + + /** + * How long after a speed change a stall is attributed to the change rather than to + * the stream. + * + * Comfortably longer than a pipeline refill and comfortably shorter than the roll, so + * a stream that genuinely cannot hold the speed still gives itself away — it stalls + * again, and the second one counts. + */ + private const val CREDITS_STALL_GRACE_MS = 2_500L /** * How often the playhead is compared against the title-sequence markers, and so how * often the ring on the button advances. The same rate the next-up countdown reads diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index bebadff..0e2ce9b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -237,7 +237,6 @@ internal data class SettingsPanelState( val showTenMinuteReminder: Boolean = true, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, - val speedUpCredits: Boolean = true, val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO, val audioPassthroughCodecs: Set = emptySet(), val detectedAudioPassthroughCodecs: Set = emptySet(), @@ -298,7 +297,6 @@ internal data class SettingsPanelActions( val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {}, val onSeekIntervalChanged: (Int) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {}, - val onSpeedUpCreditsChanged: (Boolean) -> Unit = {}, val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {}, val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> }, val onRingColorChanged: (String) -> Unit = {}, @@ -360,7 +358,6 @@ fun SettingsSheet( var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) } var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } - var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) } var audioPassthroughMode by remember { mutableStateOf(settings.audioPassthroughPreference.mode) } @@ -455,7 +452,6 @@ fun SettingsSheet( settings.showTenMinuteReminder, settings.seekIntervalSeconds, settings.skipIntroMode, - settings.speedUpCredits, settings.audioPassthroughMode, settings.audioPassthroughCodecs, settings.welcomeQuoteStyle, @@ -466,7 +462,6 @@ fun SettingsSheet( showTenMinuteReminder = settings.showTenMinuteReminder seekInterval = settings.seekIntervalSeconds skipIntroMode = settings.skipIntroMode - speedUpCredits = settings.speedUpCredits audioPassthroughMode = settings.audioPassthroughPreference.mode audioPassthroughCodecs = settings.audioPassthroughPreference.codecs ringColor = settings.ringColorHex @@ -495,7 +490,6 @@ fun SettingsSheet( showTenMinuteReminder = showTenMinuteReminder, seekIntervalSeconds = seekInterval, skipIntroMode = skipIntroMode, - speedUpCredits = speedUpCredits, audioPassthroughMode = audioPassthroughMode, audioPassthroughCodecs = audioPassthroughCodecs, detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough, @@ -561,11 +555,6 @@ fun SettingsSheet( skipIntroMode = it persistSetting { store.setSkipIntroMode(it) } }, - onSpeedUpCreditsChanged = { - onAnalyticsEvent("speed_up_credits", "toggle") - speedUpCredits = it - persistSetting { store.setSpeedUpCredits(it) } - }, onAudioPassthroughModeChanged = { mode -> onAnalyticsEvent("audio_passthrough", "change") audioPassthroughMode = mode @@ -982,14 +971,6 @@ internal fun SettingsPanelContent( onSelected = actions.onSkipIntroModeChanged, ) SettingDivider() - SettingsToggleRow( - title = "Closing credits", - description = "Shrink them to one side at double speed and show " + - "what is on next.", - checked = state.speedUpCredits, - onCheckedChange = actions.onSpeedUpCreditsChanged, - ) - SettingDivider() SettingsChoiceRow( title = "Skip with left and right", description = "How far one press moves what you are watching.", diff --git a/app/src/main/res/layout/player_end_credits.xml b/app/src/main/res/layout/player_end_credits.xml index 4726297..fe54502 100644 --- a/app/src/main/res/layout/player_end_credits.xml +++ b/app/src/main/res/layout/player_end_credits.xml @@ -11,6 +11,17 @@ So the left half of this layout is empty on purpose: it is a hole for the picture that is already there to show through. + Three things sit on it, and the arrangement is the design: what is *ending* in the top + left, how long until it hands over in the top right, and what is *next* in the panel + down the right-hand side. The two corners are deliberately opposite each other — they + are the two facts a viewer wants at a glance, and putting either inside the panel buries + it in prose the eye has already finished reading. + + Nothing here says how fast the picture is running. A chip stating "2× speed" was the + only thing on screen that ever mentioned it, and it explained a mechanism rather than + answering a question: the countdown opposite already says the thing a viewer actually + wants to know, and it stays true whatever speed the stream turned out to sustain. + There is no scrim and no card behind the picture. It sits on somebody's film, and the only lit surface is the button under focus. --> - + - The start margin is aligned with the shrunken picture's own left edge, which - CREDITS_VIDEO_SCALE and CREDITS_VIDEO_SHIFT_X put at ~34dp on a 16:9 set, so it reads - as a caption on the credits rather than as something floating in the black beside - them. The bottom margin is the seek chip's, for the same reason: it is the height - this app already puts a chip at. --> + android:visibility="gone" + tools:text="Love Story" + tools:visibility="visible" /> + + + + + + + + - - - Watch credits - %1$s speed + + NEXT EPISODE STARTING IN Starting now… Memby Memby has been updated to version %1$s. diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/CreditsSpeedTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/CreditsSpeedTest.kt index 11c5087..8bec414 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/CreditsSpeedTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/CreditsSpeedTest.kt @@ -108,15 +108,7 @@ class CreditsSpeedTest { * 1.5 is not exactly 1.5 in binary, so `toString` on it prints "1.5000001", and * `String.format` would print a comma for the point on a set configured in half of Europe. */ - @Test - fun `the label reads as a person would write it`() { - assertEquals("2×", creditsSpeedLabel(CREDITS_TARGET_SPEED)) - assertEquals("1.5×", creditsSpeedLabel(1.5f)) - assertEquals("1×", creditsSpeedLabel(CREDITS_NORMAL_SPEED)) - // And off the ramp, where the speed is whatever the curve produced. - assertEquals("1.8×", creditsSpeedLabel(1.7996f)) - } - + private companion object { const val TOLERANCE = 0.03f } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/EndCreditsScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/EndCreditsScreenshotTest.kt index ac48d0a..a8ef3a2 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/EndCreditsScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/EndCreditsScreenshotTest.kt @@ -46,15 +46,16 @@ class EndCreditsScreenshotTest { name = "end-credits", title = "The Crossing", meta = "S02E05 · Northbound", - speed = CREDITS_TARGET_SPEED, + seriesName = "Northbound", countdown = null, ) } /** - * Inside the last minute, where the countdown appears. It lives in the pane rather than - * in the next-up banner, which is suppressed while this is up — two of them would fight - * over the same transform and print the episode twice. + * Inside the last minute, where the countdown appears in the top right — opposite the + * logo, and the only figure on the pane that moves. It lives here rather than in the + * next-up banner, which is suppressed while this is up: two of them would fight over the + * same transform and print the episode twice. */ @Test fun `the last minute, counting down`() { @@ -62,23 +63,38 @@ class EndCreditsScreenshotTest { name = "end-credits-countdown", title = "The Crossing", meta = "S02E05 · Northbound", - speed = CREDITS_TARGET_SPEED, - countdown = "Starting in 28s", + seriesName = "Northbound", + countdown = "0:28", ) } /** - * The stream could not hold 2×, so the ceiling fell. Worth a capture because the chip is - * the only thing on screen that ever says so, and "1.5× speed" has to sit in the same - * plate as "2× speed" without changing the layout around it. + * Both corners at their longest at once: a series name that fills the width the logo is + * allowed, against a countdown still in minutes. This is the capture that would show the + * two colliding, which is the only way that failure appears. */ @Test - fun `a stream that could only manage one and a half`() { + fun `a long series name beside a long countdown`() { capture( - name = "end-credits-reduced-speed", + name = "end-credits-long-series-name", title = "The Crossing", meta = "S02E05 · Northbound", - speed = 1.5f, + seriesName = "The Cartographer of the Lower Reaches", + countdown = "1:04", + ) + } + + /** + * Nothing to put in the top left. A film has no series and an item may carry no logo at + * all, and the corner must then be empty rather than holding a blank plate. + */ + @Test + fun `no logo and no series name`() { + capture( + name = "end-credits-no-logo", + title = "The Crossing", + meta = "S02E05 · Northbound", + seriesName = "", countdown = null, ) } @@ -94,7 +110,7 @@ class EndCreditsScreenshotTest { name = "end-credits-long-title", title = "The Cartographer of the Lower Reaches", meta = "", - speed = CREDITS_TARGET_SPEED, + seriesName = "Northbound", countdown = null, ) } @@ -103,7 +119,7 @@ class EndCreditsScreenshotTest { name: String, title: String, meta: String, - speed: Float, + seriesName: String, countdown: String?, ) { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() @@ -136,12 +152,18 @@ class EndCreditsScreenshotTest { text = meta visibility = if (meta.isBlank()) View.GONE else View.VISIBLE } - pane.findViewById(R.id.player_end_credits_speed).text = - activity.getString(R.string.end_credits_speed, creditsSpeedLabel(speed)) - pane.findViewById(R.id.player_end_credits_countdown).apply { - text = countdown.orEmpty() - visibility = if (countdown == null) View.GONE else View.VISIBLE + // What is ending, top left. The text treatment rather than the image, because a + // capture is about whether the two corners balance and a stand-in logo bitmap would + // only prove that a rectangle fits. + pane.findViewById(R.id.player_end_credits_logo_text).apply { + text = seriesName + visibility = if (seriesName.isBlank()) View.GONE else View.VISIBLE } + // The countdown, top right. Its absence is a case worth capturing on its own: the + // pane spends most of its life without one. + pane.findViewById(R.id.player_end_credits_countdown).text = countdown.orEmpty() + pane.findViewById(R.id.player_end_credits_countdown_group).visibility = + if (countdown == null) View.GONE else View.VISIBLE pane.findViewById(R.id.player_end_credits_image) .setImageBitmap(nextEpisodeArtwork()) // The remote lands on Play, which is what the pane does on opening — so the capture diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt index a9f774a..a740cea 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsSheetScreenshotTest.kt @@ -128,10 +128,12 @@ class SettingsSheetScreenshotTest { val togglesByPage = mapOf( SettingsPage.APPEARANCE to listOf("Show logos"), + // The closing-credits pane was here. It is no longer a viewer preference — the + // operator's `end_credits` flag is the only switch — so there is nothing on this + // page to turn it off with. SettingsPage.PLAYBACK to listOf( "Ten minutes left", "Play the next episode", - "Closing credits", ), SettingsPage.HOME to listOf( "Continue watching", @@ -188,7 +190,6 @@ class SettingsSheetScreenshotTest { var showLogo by remember { mutableStateOf(false) } var autoPlayNext by remember { mutableStateOf(false) } var tenMinutes by remember { mutableStateOf(false) } - var speedUpCredits by remember { mutableStateOf(false) } var hideWatched by remember { mutableStateOf(false) } var cardMetadata by remember { mutableStateOf(false) } var ratings by remember { mutableStateOf(false) } @@ -209,7 +210,6 @@ class SettingsSheetScreenshotTest { showLogo = showLogo, autoPlayNext = autoPlayNext, showTenMinuteReminder = tenMinutes, - speedUpCredits = speedUpCredits, hideWatchedMovies = hideWatched, showCardMetadata = cardMetadata, showRatingsStrip = ratings, @@ -228,10 +228,6 @@ class SettingsSheetScreenshotTest { tenMinutes = it record("Ten minutes left", it) }, - onSpeedUpCreditsChanged = { - speedUpCredits = it - record("Closing credits", it) - }, onHideWatchedMoviesChanged = { hideWatched = it record("Hide films you have seen", it) diff --git a/docker-compose.yml b/docker-compose.yml index 265c0de..2a4f90b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,11 @@ services: # What the TVs are told to stream from. Only set this when it differs from the # address above (video goes device -> Emby directly, never through the gateway). MEMBY_EMBY_PUBLIC_URL: "${MEMBY_EMBY_PUBLIC_URL:-}" + # Where the gateway reads media *bytes* from, which only credits detection does. + # Blank falls back to MEMBY_EMBY_URL; set it to the LAN address when Emby is on a + # different host, or every ranged read takes the public route and a buffering proxy + # turns it into a whole-file read. + MEMBY_EMBY_MEDIA_URL: "${MEMBY_EMBY_MEDIA_URL:-}" MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable" MEMBY_REDIS_URL: "redis://redis:6379/0" MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}" @@ -81,6 +86,14 @@ services: MEMBY_FOR_YOU_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-24h}" MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-24h}" MEMBY_FOR_YOU_REBUILD_HOUR: "${MEMBY_FOR_YOU_REBUILD_HOUR:-4}" + # Credits detection, which reads the demand the Tracearr settings above import: it + # scans a few episodes ahead of each viewer rather than the library. Off unless the + # .env says otherwise, because it is the only thing here that opens a media file. + MEMBY_CREDITS_ENABLED: "${MEMBY_CREDITS_ENABLED:-false}" + MEMBY_CREDITS_FFMPEG: "${MEMBY_CREDITS_FFMPEG:-}" + MEMBY_CREDITS_PREFETCH_EPISODES: "${MEMBY_CREDITS_PREFETCH_EPISODES:-3}" + MEMBY_CREDITS_MAX_PREFETCH: "${MEMBY_CREDITS_MAX_PREFETCH:-5}" + MEMBY_CREDITS_QUEUE_LIMIT: "${MEMBY_CREDITS_QUEUE_LIMIT:-20}" mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}" volumes: - memby-releases:/data/releases diff --git a/gradle.properties b/gradle.properties index 8cb13fc..e64f54b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,3 +35,5 @@ android.builtInKotlin=false # `android { }` report itself deprecated in the build files; that warning cannot be # resolved from this side, and clears when the Kotlin plugin adopts the new DSL. android.newDsl=false + +android.sync.suppressAgpWarnings=UNSUPPORTED_PROJECT_OPTION_USE,DEPRECATED_DSL diff --git a/server/internal/api/credits_test.go b/server/internal/api/credits_test.go index 1c97db0..79dece4 100644 --- a/server/internal/api/credits_test.go +++ b/server/internal/api/credits_test.go @@ -270,23 +270,18 @@ func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) { } } -// The toggle a television is told to obey has to be one this build knows. -func TestSpeedUpCreditsIsCatalogued(t *testing.T) { - definition, ok := preferenceDefinitionFor("speedUpCredits") - if !ok { - t.Fatal("speedUpCredits is missing from the preference catalogue") - } - if definition.Kind != preferenceToggle { - t.Fatalf("kind = %v, want a toggle", definition.Kind) - } - if definition.Default != true { - t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+ - "is visible and reversible in a way an automatic seek is not", definition.Default) +// The credits pane is not a viewer preference any more, and this pins the removal from both +// ends: the key is gone from the catalogue, and a document still carrying it — written by a +// television or an operator before the removal — has it dropped rather than round-tripped. +// Without the second half a stored `false` would survive every sync and quietly keep the +// feature off on the one set that had turned it off. +func TestSpeedUpCreditsIsNoLongerAPreference(t *testing.T) { + if _, ok := preferenceDefinitionFor("speedUpCredits"); ok { + t.Fatal("speedUpCredits is still in the preference catalogue") } - // An illegal value must come back as the default rather than reaching a player. - normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"}) - if normalised["speedUpCredits"] != true { - t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"]) + normalised := normalizePreferences(map[string]any{"speedUpCredits": false}) + if _, present := normalised["speedUpCredits"]; present { + t.Fatal("a stored speedUpCredits must be dropped, not carried forward") } } diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index fc3774d..a989690 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -163,17 +163,15 @@ var preferenceCatalogue = []preferenceDefinition{ option(skipIntroOff, "Do nothing"), }, }, - { - // Default on: the whole feature is that it happens without being asked for, and it - // is visible, reversible and over in a minute — a viewer who dislikes it turns it - // off having seen exactly what it does. That is a different trade from - // skipIntroMode's, which defaults to the button rather than the automatic seek - // because a jump nobody can see coming is not recoverable by watching it. - Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback", - Description: "When an episode reaches its closing credits, shrink them to one side " + - "at double speed and show what is on next beside them.", - Kind: preferenceToggle, Default: true, - }, + // `speedUpCredits` was here. It is deliberately not a viewer preference any more: the + // closing-credits pane is how this client ends an episode, and an opt-out made it a + // feature half the household never saw. The operator's `end_credits` flag remains the + // one switch, which is the right level for it — it governs a subsystem that reads media + // bytes, and turning it off is a decision about the server rather than about taste. + // + // Removing the key from the catalogue is also how the stored values are cleaned up: + // normalizePreferences drops what it does not recognise, so a viewer who had turned it + // off gets the pane back on their next sync with nothing having to migrate anything. { Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback", Description: "Turn a subtitle track on automatically when the title has one.", diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 6ee33ba..9506d14 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.45 +0.1.47 diff --git a/server/internal/credits/detector.go b/server/internal/credits/detector.go index e19f39f..d8a4de8 100644 --- a/server/internal/credits/detector.go +++ b/server/internal/credits/detector.go @@ -38,6 +38,27 @@ const ( // primary guard against answering on noise, and it is set high because the cost of a // wrong marker is somebody losing the end of an episode. minSeparation = 0.18 + + // onsetFraction is how credit-like a frame has to be, relative to the established roll, + // to count as already part of it. + // + // The split itself lands where the credits are *established*, because the sustain window + // after it has to clear creditLikeFloor on average — and credits usually fade in, so the + // first second or two of the fade scores below that floor and pushes the split forward. + // Measured against real media the gap is two to three seconds, which is visible: the roll + // has plainly begun before the picture moves. + // + // So the split is walked backwards through the fade to the first frame that is already + // mostly credit-like. Two fifths is deliberately generous — this is looking for the start + // of a ramp, not for more credits — and it is a fraction of the established level rather + // than an absolute, because a roll over a bright background never reaches the same score + // as one over black and would otherwise never be walked back at all. + onsetFraction = 0.4 + + // onsetBackoffMs bounds that walk. A fade is a second or two; anything walking further is + // no longer following one, and the bound is what stops a gradual dimming at the end of a + // scene dragging the marker back into the programme. + onsetBackoffMs = 4000 ) // VisualDetector implements Detector over the ffmpeg sampler. @@ -195,7 +216,46 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64, } bestIndex, bestSeparation, found = split, separation, true } - return bestIndex, bestSeparation, found + if !found { + return 0, 0, false + } + // The separation is reported for the split that earned it, never for the walked-back + // frame: confidence is a statement about how clearly the transition was found, and + // recomputing it over a fade would report the answer as weaker for having been improved. + return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval), + bestSeparation, true +} + +// backOffToOnset walks a split backwards through the credits' fade-in. +// +// Pure, bounded, and it can only ever move the marker earlier — which is the direction that +// needs the care, so both bounds matter: it stops at the first frame that is not already +// mostly credit-like, and it never travels further than onsetBackoffMs. On a hard cut from +// programme to credits the preceding frame scores near zero and the walk stops immediately, +// which is correct: there is no fade to find and the split was already right. +func backOffToOnset(scores []float64, split int, established float64, interval time.Duration) int { + if split <= 0 || established <= 0 || interval <= 0 { + return split + } + limit := int(float64(onsetBackoffMs) / float64(interval.Milliseconds())) + if limit < 1 { + // A sampling interval coarser than the whole allowance cannot resolve a fade at all. + // The coarse pass is this case, and it is the one whose answer the fine pass replaces. + return split + } + floor := onsetFraction * established + earliest := split - limit + if earliest < 0 { + earliest = 0 + } + onset := split + for index := split - 1; index >= earliest; index-- { + if scores[index] < floor { + break + } + onset = index + } + return onset } // visualConfidence maps separation onto a score. diff --git a/server/internal/credits/detector_test.go b/server/internal/credits/detector_test.go index ab0c033..885fccc 100644 --- a/server/internal/credits/detector_test.go +++ b/server/internal/credits/detector_test.go @@ -160,3 +160,87 @@ func TestVisualConfidenceIsCapped(t *testing.T) { t.Fatalf("a detection at the separation threshold scored %.2f, below the bar", score) } } + +// The onset walk-back, tested on scores directly. +// +// It is pure and it is the only thing in the detector that can move a marker *earlier*, so +// its two bounds matter more than the movement itself: a hard cut must not move at all, and +// a long ramp must not drag the marker back into the programme. + +// establishedCredits is the score a fully-established roll produces, near enough — the +// literal matters only as the thing onsetFraction is measured against. +const establishedCredits = 0.90 + +func TestBackOffWalksIntoTheFade(t *testing.T) { + // Forty frames of programme, three of fade, then the roll. The split arrives at the first + // fully-established frame; the fade before it is already the credits. + scores := make([]float64, 0, 60) + for range 40 { + scores = append(scores, 0.02) + } + scores = append(scores, 0.40, 0.55, 0.75) + for range 17 { + scores = append(scores, establishedCredits) + } + + onset := backOffToOnset(scores, 43, establishedCredits, fineInterval) + if onset != 40 { + t.Fatalf("onset at frame %d, want 40 (the first frame of the fade)", onset) + } +} + +// A cut straight from programme to credits has no fade to find, and the split was already +// right. Moving it would be inventing a ramp that is not there. +func TestBackOffStopsAtAHardCut(t *testing.T) { + scores := make([]float64, 0, 60) + for range 40 { + scores = append(scores, 0.02) + } + for range 20 { + scores = append(scores, establishedCredits) + } + + if onset := backOffToOnset(scores, 40, establishedCredits, fineInterval); onset != 40 { + t.Fatalf("a hard cut moved from 40 to %d", onset) + } +} + +// The bound is what stops a scene dimming gradually towards the credits pulling the marker +// back through the last minute of the programme. +func TestBackOffIsBounded(t *testing.T) { + // Everything qualifies, so only the bound can stop the walk. + scores := make([]float64, 80) + for index := range scores { + scores[index] = establishedCredits + } + + onset := backOffToOnset(scores, 60, establishedCredits, fineInterval) + limit := int(float64(onsetBackoffMs) / float64(fineInterval.Milliseconds())) + if onset != 60-limit { + t.Fatalf("walked back to %d, want %d (%d frames)", onset, 60-limit, limit) + } + if travelled := (60 - onset) * int(fineInterval.Milliseconds()); travelled > onsetBackoffMs { + t.Fatalf("walked back %dms, past the %dms bound", travelled, onsetBackoffMs) + } +} + +// A frame that is dark but not yet mostly credit-like is still the programme. +func TestBackOffRefusesAFrameBelowTheOnsetFraction(t *testing.T) { + scores := []float64{0.02, 0.02, 0.10, establishedCredits, establishedCredits} + // 0.10 is well under onsetFraction of the established level. + if onset := backOffToOnset(scores, 3, establishedCredits, fineInterval); onset != 3 { + t.Fatalf("a below-threshold frame was walked into: onset %d, want 3", onset) + } +} + +// Guarding the degenerate inputs rather than trusting callers, since this runs on whatever +// the sampler produced. +func TestBackOffHandlesDegenerateInput(t *testing.T) { + scores := []float64{establishedCredits, establishedCredits} + if onset := backOffToOnset(scores, 0, establishedCredits, fineInterval); onset != 0 { + t.Fatalf("a split at zero moved to %d", onset) + } + if onset := backOffToOnset(scores, 1, 0, fineInterval); onset != 1 { + t.Fatalf("an established level of zero moved the split to %d", onset) + } +} diff --git a/server/internal/scheduler/scheduler.go b/server/internal/scheduler/scheduler.go index ade2e8c..bcb8a1c 100644 --- a/server/internal/scheduler/scheduler.go +++ b/server/internal/scheduler/scheduler.go @@ -51,15 +51,21 @@ type Task struct { // Status is one task as the console reads it: the declaration, the operator's overrides, // the last run and when the next one is due. type Status struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Group string `json:"group"` - Interval int64 `json:"intervalSeconds"` - Enabled bool `json:"enabled"` - Running bool `json:"running"` - NextRun *time.Time `json:"nextRun,omitempty"` - LastRun *store.TaskRun `json:"lastRun,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Group string `json:"group"` + Interval int64 `json:"intervalSeconds"` + // DefaultInterval is the cadence declared in code, which Interval hides whenever an + // operator has overridden it. Both are sent because the console cannot otherwise tell + // "every ten minutes because that is the default" from "every ten minutes because + // somebody chose it" — and without that distinction its cadence control has no way to + // offer a way back, or to say that a task is no longer running as shipped. + DefaultInterval int64 `json:"defaultIntervalSeconds"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + NextRun *time.Time `json:"nextRun,omitempty"` + LastRun *store.TaskRun `json:"lastRun,omitempty"` } type registered struct { @@ -458,8 +464,9 @@ func (s *Scheduler) Snapshot() []Status { status := Status{ ID: entry.task.ID, Name: entry.task.Name, Description: entry.task.Description, Group: entry.task.Group, - Interval: int64(entry.effectiveInterval() / time.Second), - Enabled: entry.enabled, Running: entry.running, + Interval: int64(entry.effectiveInterval() / time.Second), + DefaultInterval: int64(entry.task.Interval / time.Second), + Enabled: entry.enabled, Running: entry.running, } if !entry.nextRun.IsZero() && entry.enabled { next := entry.nextRun diff --git a/server/internal/scheduler/scheduler_test.go b/server/internal/scheduler/scheduler_test.go index 2d6d623..a7b980c 100644 --- a/server/internal/scheduler/scheduler_test.go +++ b/server/internal/scheduler/scheduler_test.go @@ -219,3 +219,44 @@ func waitForRun(t *testing.T, sched *Scheduler, id string) Status { } var _ sync.Locker = (*sync.Mutex)(nil) + +// The console needs both cadences to draw its control honestly: the one in force and the +// one the code declares. Reporting only the effective interval made "every ten minutes +// because that is the default" and "every ten minutes because somebody chose it" identical +// on the wire, so nothing could offer a way back to the default or mark a task as no longer +// running as shipped. +func TestSnapshotReportsTheDeclaredCadenceBesideTheEffectiveOne(t *testing.T) { + sched := quietScheduler() + sched.Register(Task{ID: "credits", Name: "Credits", Interval: 10 * time.Minute, Run: noop}) + + before := sched.Snapshot()[0] + if before.Interval != 600 || before.DefaultInterval != 600 { + t.Fatalf("unoverridden task: interval %d, default %d, want 600 and 600", + before.Interval, before.DefaultInterval) + } + + if err := sched.SetInterval(context.Background(), "credits", time.Hour); err != nil { + t.Fatalf("SetInterval: %v", err) + } + after := sched.Snapshot()[0] + if after.Interval != 3600 { + t.Fatalf("effective interval %d, want 3600", after.Interval) + } + // The declared cadence must survive the override, or the way back is lost. + if after.DefaultInterval != 600 { + t.Fatalf("declared cadence %d, want 600 — an override must not overwrite it", + after.DefaultInterval) + } +} + +// A task that declares no cadence at all runs only when somebody presses the button, and +// the console has to be able to say so rather than printing "every 0 seconds". +func TestATaskWithNoDeclaredCadenceReportsZeroForBoth(t *testing.T) { + sched := quietScheduler() + sched.Register(Task{ID: "manual", Name: "Manual", Run: noop}) + + status := sched.Snapshot()[0] + if status.Interval != 0 || status.DefaultInterval != 0 { + t.Fatalf("interval %d, default %d, want 0 and 0", status.Interval, status.DefaultInterval) + } +}