diff --git a/.env.example b/.env.example index 19eb2d3..1593474 100644 --- a/.env.example +++ b/.env.example @@ -85,8 +85,9 @@ MEMBY_SONARR_TTL=5m # 0 turns the banners off and leaves the airing-today row alone. MEMBY_SONARR_ALERT_WINDOW=3h -# Optional Radarr calendar integration. Upcoming movies are selected strictly from -# Radarr's digital release date; theatrical and physical dates are ignored. +# Optional Radarr calendar integration. Upcoming movies cover the coming month, ordered by +# Radarr's digital release date. A film with no digital date yet is estimated at its cinema +# date plus 30 days and says so on the card; physical dates are ignored. MEMBY_RADARR_URL=http://10.0.0.2:7878 MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5 MEMBY_RADARR_TTL=5m diff --git a/CLAUDE.md b/CLAUDE.md index 05e4682..a000320 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -743,6 +743,53 @@ each page read as a pile of unrelated controls. Things to preserve: the page that can do something about it. A screen that both summarises and changes state is where an accidental click lives. +**The gateway's own settings are `/admin/settings`**, reached from the account menu in the +top bar rather than from the rail — every other page decides what the *televisions* do, and +this one is about the server process. It is `store.GatewaySettings` (one `app_settings` row) +over `internal/api/gateway_settings.go`, and it holds the household timezone, the log level, +the idle sign-out, the two alert windows and the Emby health probe. Things to preserve: + +- **Every field is an override, and `.env` is still the configuration.** Blank means + "whatever this container was started with", which the page prints beside each field, so + clearing a setting is a real undo rather than a value the operator has to remember. A + setting that can legitimately be *off* therefore needs a third value: **-1 is off, 0 is + deployed** (`store.GatewaySettingsOff`), because a plain zero would make "turn this off" + indistinguishable from "leave it alone". +- **Nothing reads the document directly.** The effective-value helpers in + `gateway_settings.go` — `householdLocation`, `sessionIdleExpiry`, `sonarrAlertWindow`, + `radarrAlertWindow`, `embyHealthInterval` — are the only readers, so adding a setting is + one helper beside its config field rather than teaching every call site that an override + exists. `s.cfg.SonarrLocation` in particular should not be read directly any more: + `householdLocation()` is what makes a timezone change reach the schedule rows, the hero + rotation and the sign-in history. +- **A setting that is read once at start-up is not a setting.** `WatchEmbyReachability` + re-reads its cadence every tick and keeps ticking (slowly) while the probe is off, so + switching it back on does not need a restart; the idle sweep reads the expiry inside + `Run` rather than closing over it; and the log level is a `*slog.LevelVar` threaded from + `main` through `Deps.LogLevel`, applied on save rather than waited for — an operator who + has just turned debug on and gone to look at the log must not spend thirty seconds + believing it did not work. `deployedLogLevel` is remembered because clearing the + override has to restore *something*, and the variable itself has by then been moved. + +**Two things a television does are notifications now**, both in `internal/api/device_activity.go`. +`TypeDeviceFirstUse` announces the first time a set opened Memby on a household-local day +and `TypeDeviceUpdated` announces one that finished updating itself. Things to preserve: + +- **First use is anchored on `/v1/home`**, not on the auth middleware every call passes + through: a set left on overnight polls `/v1/status` every ten seconds, so any-request + would announce it at midnight — an event nobody did, at the hour nobody is reading. It is + marked *before* the cached response is served, because a household whose rows are still + warm from another set has still just been opened by this one. +- **The answer comes from the insert.** `store.MarkDeviceDay` is `ON CONFLICT DO NOTHING` + on `device_activity_days`, so two televisions racing on the same row cannot both be told + they were first. The marks are retired with the device and pruned by their own + housekeeping task. +- **An update needs a previous version to be an update.** `announceDeviceUpdate` fires from + `captureClientIdentity` — the only place a set that updated in place is ever seen, since + it never signs in again — and an empty previous version is a build this gateway had never + been told the version of, not a version that moved. A *downgrade* is still announced: a + sideloaded step backwards is news too. + **Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a pure function turning the learned weights into the phrases a detail page shows. It is kept apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides diff --git a/admin-ui/dist/assets/index-CASotpHk.css b/admin-ui/dist/assets/index-CASotpHk.css deleted file mode 100644 index 2faa8b9..0000000 --- a/admin-ui/dist/assets/index-CASotpHk.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:grid;place-items:center;width:38px;height:38px;flex:0 0 38px;padding:0;border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:default;opacity:1}.topbar-status .dot{width:9px;height:9px;border-radius:50%;background:var(--quiet);box-shadow:0 0 0 3px #7d85901f}.topbar-status[data-tone=ok] .dot{background:var(--accent-ink);box-shadow:0 0 0 3px var(--accent-wash),0 0 9px #56d36447}.topbar-status[data-tone=bad] .dot{background:var(--danger);box-shadow:0 0 0 3px var(--danger-wash)}.account-menu{position:relative;display:flex;align-items:center;height:38px;flex:0 0 auto}.account-trigger{max-width:190px;height:38px;padding:0 9px 0 5px;border-color:var(--line);background:var(--surface);color:var(--muted)}.account-trigger:hover:not(:disabled),.account-menu[data-open=true] .account-trigger{border-color:#56d36480;background:var(--surface-hi);color:var(--text)}.account-avatar{display:grid;place-items:center;width:27px;height:27px;flex:0 0 27px;border:1px solid rgba(86,211,100,.36);border-radius:50%;background:var(--accent-wash);color:var(--accent-ink);font-size:11px;font-weight:750}.account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}.account-caret{width:12px;height:12px;transition:transform .16s ease}.account-menu[data-open=true] .account-caret{transform:rotate(180deg)}.account-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(280px,calc(100vw - 24px));padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.account-identity{display:flex;align-items:center;gap:11px;min-width:0;padding:9px 10px 12px;border-bottom:1px solid var(--line-soft)}.account-avatar-large{width:34px;height:34px;flex-basis:34px;font-size:13px}.account-identity span:last-child{min-width:0}.account-identity small,.account-identity b{display:block}.account-identity small{color:var(--quiet);font-size:11px}.account-identity b{overflow:hidden;text-overflow:ellipsis;font-size:13.5px}.account-panel form{margin-top:6px}.account-panel form button{justify-content:flex-start;width:100%;height:38px;border-color:transparent;background:transparent;color:var(--muted)}.account-panel form button:hover:not(:disabled){background:var(--surface-hi);color:var(--text)}@media (max-width: 900px){.topbar-version{display:none}}.omni{position:relative;width:100%}.omni-input{display:flex;align-items:center;gap:8px;width:100%;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.omni[data-open=true] .omni-input{border-color:var(--accent)}.omni-input input[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right:auto;left:0;width:100%;max-height:min(50vh,420px);overflow-y:auto;padding:6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.omni-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--radius-sm);color:var(--text);text-decoration:none}.omni-item .ico{color:var(--quiet);flex:0 0 16px}.omni-item.on,.omni-item:hover{background:var(--surface-hi)}.omni-item b{font-size:13.5px;font-weight:600}.omni-item small{display:block;color:var(--quiet);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-item span{flex:1 1 auto;min-width:0}.omni-group{flex:0 0 auto;color:var(--quiet);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.bell{position:relative;display:flex;align-items:center;height:38px}.bell-button{position:relative;display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}.bell-button:hover{background:var(--surface-hi);color:var(--text)}.bell[data-open=true] .bell-button{border-color:var(--accent);color:var(--text)}.bell-badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.bell-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(420px,92vw);border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40;overflow:hidden}.bell-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line)}.bell-head b{font-size:13px}.bell-list{max-height:min(58vh,460px);overflow-y:auto}.bell-item{display:flex;gap:11px;padding:11px 14px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.bell-item:last-child{border-bottom:0}.bell-item:hover{background:var(--surface-lift)}.bell-item[data-unread=true]{box-shadow:inset 2px 0 0 var(--accent)}.bell-item .glyph{flex:0 0 auto}.bell-body{flex:1 1 auto;min-width:0}.bell-body b{display:block;font-size:13px;font-weight:600}.bell-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px;overflow-wrap:anywhere}.bell-body time{display:block;margin-top:3px;color:var(--quiet);font-size:11px}.bell-foot{padding:9px 14px;border-top:1px solid var(--line);text-align:center}.bell-foot a{font-size:12.5px;text-decoration:none}.rail{position:fixed;top:var(--top);bottom:0;left:0;width:var(--rail);padding:12px 12px max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);min-width:0;margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-key,.topbar-version{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{width:40px;flex-basis:40px;min-width:40px;height:40px;border:1px solid var(--line);background:var(--surface)}.omni-input,.bell,.bell-button,.account-menu,.account-trigger{height:40px}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 1100px){.account-trigger{width:40px;padding:0}.account-name,.account-caret{display:none}}@media (max-width: 820px){.brand-word{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}@media (max-width: 680px){:root{--top: calc(60px + var(--safe-top))}.topbar{align-items:center;flex-wrap:nowrap;gap:clamp(4px,1.5vw,8px);padding:calc(var(--safe-top) + 8px) max(8px,var(--safe-right)) 8px max(8px,var(--safe-left))}.rail-toggle{order:1}.topbar-brand{order:2;height:40px;flex:0 0 auto}.topbar .omni{order:3;flex:1 1 96px;width:0;min-width:0;max-width:none}.topbar-status{order:4;margin-left:0}.bell{order:5}.account-menu{order:6}.brand-mark{width:30px;height:30px;flex-basis:30px}.account-panel,.bell-panel{position:fixed;top:calc(var(--top) + 8px)}.account-panel{right:max(12px,var(--safe-right))}}@media (max-width: 370px){.tiles{grid-template-columns:minmax(0,1fr)}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono);contain:layout paint style}.loghead{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);gap:12px;padding:7px 12px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:760px}.logbody{position:relative;min-width:760px}.logline{display:flex;gap:12px;padding:3px 12px;border-bottom:1px solid rgba(255,255,255,.03);white-space:pre-wrap;overflow-wrap:anywhere}.logline[data-virtual=true]{position:absolute;top:0;right:0;left:0;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);align-items:center;height:48px;padding-top:4px;padding-bottom:4px;white-space:nowrap;contain:strict}.logline:hover{background:#ffffff08}.logline time{flex:0 0 auto;color:var(--quiet)}.logline time small{display:block;color:var(--muted);font-size:10px}.logline .lvl{flex:0 0 46px;font-weight:700}.logline[data-level=ERROR] .lvl{color:var(--danger-ink)}.logline[data-level=WARN] .lvl{color:var(--warn-ink)}.logline[data-level=INFO] .lvl{color:var(--info-ink)}.logline[data-level=DEBUG] .lvl{color:var(--quiet)}.logline .msg{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}.logline .attrs{color:var(--quiet)}@media (min-width: 900px){.logline{display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr)}}.logline .attrs b{color:var(--muted);font-weight:400}.logattrs-button{display:block;width:100%;min-width:0;height:28px;padding:0;overflow:hidden;border:0;background:transparent;color:var(--quiet);font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logattrs-button:hover:not(:disabled){border:0;background:transparent;color:var(--text)}.logattrs-button:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.log-inspector{margin-top:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.log-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.log-inspector-head>div{min-width:0}.log-inspector-head b,.log-inspector-head span{display:block;overflow-wrap:anywhere}.log-inspector-head span{margin-top:3px;color:var(--quiet);font:11px/1.5 var(--mono)}.log-inspector dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:5px 14px;margin:12px 0 0;padding-top:10px;border-top:1px solid var(--line);font:12px/1.5 var(--mono)}.log-inspector dt{color:var(--muted)}.log-inspector dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.loghead,.logbody{min-width:680px}.loghead,.logline[data-virtual=true]{grid-template-columns:128px 44px minmax(160px,1fr) minmax(220px,1.4fr);gap:10px}.logview{height:min(66dvh,720px)}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:0}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar .account-trigger{width:44px;height:44px;padding:0}.topbar .bell,.topbar .account-menu,.topbar .omni-input{height:44px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (pointer: fine) and (min-width: 821px) and (max-height: 800px){.page{padding-top:20px;padding-bottom:52px}.page-head{margin-bottom:16px}.card{padding-top:14px;padding-bottom:14px}.card-head{margin-bottom:11px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}} diff --git a/admin-ui/dist/assets/index-CEU6X4jy.js b/admin-ui/dist/assets/index-CEU6X4jy.js deleted file mode 100644 index a937439..0000000 --- a/admin-ui/dist/assets/index-CEU6X4jy.js +++ /dev/null @@ -1,12 +0,0 @@ -import{r as p,a as Ms,u as De,L as te,b as Fe,m as Te,O as Es,N as Rs,c as Re,B as As,R as Is,d as W,e as $s}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))i(a);new MutationObserver(a=>{for(const c of a)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 l(a){const c={};return a.integrity&&(c.integrity=a.integrity),a.referrerPolicy&&(c.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?c.credentials="include":a.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function i(a){if(a.ep)return;a.ep=!0;const c=l(a);fetch(a.href,c)}})();var ds={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 Ts=p,qs=Symbol.for("react.element"),Ls=Symbol.for("react.fragment"),Ds=Object.prototype.hasOwnProperty,Fs=Ts.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Ps={key:!0,ref:!0,__self:!0,__source:!0};function hs(s,n,l){var i,a={},c=null,t=null;l!==void 0&&(c=""+l),n.key!==void 0&&(c=""+n.key),n.ref!==void 0&&(t=n.ref);for(i in n)Ds.call(n,i)&&!Ps.hasOwnProperty(i)&&(a[i]=n[i]);if(s&&s.defaultProps)for(i in n=s.defaultProps,n)a[i]===void 0&&(a[i]=n[i]);return{$$typeof:qs,type:s,key:c,ref:t,props:a,_owner:Fs.current}}Ae.Fragment=Ls;Ae.jsx=hs;Ae.jsxs=hs;ds.exports=Ae;var e=ds.exports,us,Be=Ms;us=Be.createRoot,Be.hydrateRoot;const ms={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 ie({name:s,className:n}){const l=ms[s];return l?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:l})}):null}function Ie({name:s,tone:n}){return ms[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(ie,{name:s})}):null}const fe=[{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:"credits",path:"/admin/credits",label:"Credits detection",title:"Credits detection",intro:"Control predictive scanning and review every completed credits scan.",icon:"clock"}]},{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 now or schedule daily quiet time.",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"}]}],Os=fe.flatMap(s=>s.items),Bs=fe.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Ue extends Error{constructor(n,l){super(n),this.status=l,this.name="ApiError"}}const Us=5*60*1e3;let ps=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{ps=Date.now()},{passive:!0});const Vs=()=>Date.now()-ps({}));throw new Ue(i.error??`Request failed (${l.status})`,l.status)}if(l.status!==204)return await l.json()}function Se(s){const n=new URLSearchParams;for(const[i,a]of Object.entries(s))a==null||a===""||a===!1||n.set(i,String(a));const l=n.toString();return l?`?${l}`:""}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"})},_s=3e4,xs=p.createContext(null);function zs({children:s}){var u;const[n,l]=p.useState(),[i,a]=p.useState(!1),[c,t]=p.useState(""),[o,d]=p.useState(""),[r,v]=p.useState(!0),w=p.useRef(0),x=p.useCallback(async()=>{const b=++w.current;try{const j=await F.get("/admin/api/status");if(b!==w.current)return;l(j),a(!0),d("")}catch(j){if(b!==w.current)return;a(!1),d(j instanceof Error?j.message:String(j))}finally{b===w.current&&(t(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 x()},[x,(u=n==null?void 0:n.maintenance)==null?void 0:u.message]);p.useEffect(()=>{x();let b;const j=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void x(),_s)},S=()=>{j(),document.hidden||x()};return j(),document.addEventListener("visibilitychange",S),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",S)}},[x]);const f=p.useMemo(()=>{var b;return{status:n,version:(n==null?void 0:n.serverVersion)??"",currentUser:((b=n==null?void 0:n.currentUser)==null?void 0:b.trim())||"Administrator",online:i,checkedAt:c,error:o,loading:r,reload:x,setMaintenance:m}},[n,i,c,o,r,x,m]);return e.jsx(xs.Provider,{value:f,children:s})}function ae(){const s=p.useContext(xs);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Hs(s,n){const l=s.label.toLowerCase(),i=s.group.toLowerCase();return l.startsWith(n)?4:l.includes(n)?3:i.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function Ks(){const s=De(),{status:n}=ae(),[l,i]=p.useState(!1),[a,c]=p.useState(""),[t,o]=p.useState(0),d=p.useRef(null),r=p.useRef(null),v=p.useMemo(()=>{const x=a.trim().toLowerCase();return[...Bs,...((n==null?void 0:n.requestUsers)??[]).map(f=>({id:`user-${f.id}`,path:`/admin/accounts/${encodeURIComponent(f.id)}`,label:f.username||"Unnamed user",title:`User: ${f.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(f=>({id:`device-${f.deviceId}`,path:`/admin/devices/${encodeURIComponent(f.deviceId)}`,label:f.deviceName||"Unnamed device",title:`Device: ${f.deviceName||"Unnamed device"}`,intro:`${f.username||"Unknown user"} · ${f.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((f,u)=>({item:f,rank:x?Hs(f,x):1,index:u})).filter(f=>f.rank>0).sort((f,u)=>u.rank-f.rank||f.index-u.index).map(({item:f,rank:u})=>({item:f,rank:u}))},[a,n]);p.useEffect(()=>o(0),[a]),p.useEffect(()=>{const x=m=>{var f;(f=d.current)!=null&&f.contains(m.target)||i(!1)};return document.addEventListener("pointerdown",x),()=>document.removeEventListener("pointerdown",x)},[]),p.useEffect(()=>{const x=m=>{var u,b;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(m.preventDefault(),(u=r.current)==null||u.focus(),(b=r.current)==null||b.select())};return window.addEventListener("keydown",x),()=>window.removeEventListener("keydown",x)},[]);const w=x=>{var m;i(!1),c(""),(m=r.current)==null||m.blur(),s(x)};return e.jsxs("div",{className:"omni",ref:d,"data-open":l||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(ie,{name:"search"}),e.jsx("input",{ref:r,type:"search",value:a,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":l,onFocus:()=>i(!0),onChange:x=>{c(x.target.value),i(!0)},onKeyDown:x=>{var m;if(x.key==="Escape")c(""),i(!1),(m=r.current)==null||m.blur();else if(x.key==="ArrowDown")x.preventDefault(),o(f=>Math.min(f+1,v.length-1));else if(x.key==="ArrowUp")x.preventDefault(),o(f=>Math.max(f-1,0));else if(x.key==="Enter"){const f=v[t];if(!f)return;x.preventDefault(),w(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),l?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((x,m)=>e.jsxs("a",{className:m===t?"omni-item on":"omni-item",href:x.item.path,role:"option","aria-selected":m===t,onPointerEnter:()=>o(m),onClick:f=>{f.preventDefault(),w(x.item.path)},children:[x.item.icon?e.jsx(ie,{name:x.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:x.item.label}),e.jsx("small",{children:x.item.intro})]}),e.jsx("span",{className:"omni-group",children:x.item.group})]},x.item.id))}):null]})}const N=s=>(s??0).toLocaleString(),O=s=>s?new Date(s).toLocaleString():"—";function be(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 l=Math.floor(n/60);return l<60?`${l}m ${n%60}s`:`${Math.floor(l/60)}h ${l%60}m`}function qe(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 l=Math.round(n/60);return l<48?l===1?"hourly":`every ${l} hours`:`every ${Math.round(l/24)} days`}function ye(s){const n=["B","KB","MB","GB"];let l=Number(s??0),i=0;for(;l>=1024&&iString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Me=s=>`${Math.round((s??0)*100)}%`;function pe(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const l=Math.floor(n/1e3);if(l<45)return"just now";const i=Math.floor(l/60);if(i<60)return`${i} min ago`;const a=Math.floor(i/60);if(a<24)return`${a}h ago`;const c=Math.floor(a/24);return c<30?`${c}d ago`:new Date(s).toLocaleDateString()}const vs=15*60*1e3,Gs=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}`);l(S.events),a(S.unread),t(S.types),w.current=Math.max(w.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=>{w.current=Math.max(w.current,j.id),l(S=>S.some(h=>h.id===j.id)?S:[j,...S].sort((h,g)=>g.id-h.id).slice(0,We)),j.readAt||a(S=>S+1),t(S=>S.some(h=>h.type===j.type)?S.map(h=>h.type===j.type?{...h,count:h.count+1}:h):[...S,{type:j.type,count:1}])},[]);p.useEffect(()=>{x()},[x]),p.useEffect(()=>{let j=null,S,h=!1;return(()=>{h||(j=new EventSource(`/admin/api/notifications/stream?after=${w.current}`),j.addEventListener("open",()=>{d(!0),window.clearInterval(S),S=void 0}),j.addEventListener("admin",E=>{try{m(JSON.parse(E.data))}catch{}}),j.addEventListener("error",()=>{d(!1),S===void 0&&(S=window.setInterval(()=>void x(),Zs))}))})(),()=>{h=!0,j==null||j.close(),window.clearInterval(S)}},[m,x]);const f=p.useCallback(async j=>{const S=j.filter(h=>h>0);if(S.length!==0){l(h=>h.map(g=>S.includes(g.id)&&!g.readAt?{...g,readAt:new Date().toISOString()}:g));try{const h=await F.post("/admin/api/notifications/read",{ids:S});a(h.unread)}catch{x()}}},[x]),u=p.useCallback(async()=>{a(0),l(j=>j.map(S=>S.readAt?S:{...S,readAt:new Date().toISOString()}));try{const j=await F.post("/admin/api/notifications/read",{all:!0});a(j.unread)}catch{x()}},[x]),b=p.useMemo(()=>({events:n,unread:i,types:c,connected:o,error:r,markRead:f,markAllRead:u,reload:x}),[n,i,c,o,r,f,u,x]);return e.jsx(bs.Provider,{value:b,children:s})}function Oe(){const s=p.useContext(bs);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function fs(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function gs(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 Qs(){const{events:s,unread:n,connected:l,markRead:i,markAllRead:a}=Oe(),[c,t]=p.useState(!1),o=p.useRef(null);return p.useEffect(()=>{const d=v=>{var w;(w=o.current)!=null&&w.contains(v.target)||t(!1)},r=v=>{v.key==="Escape"&&t(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",r),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",r)}},[]),p.useEffect(()=>{if(!c)return;const d=s.filter(r=>!r.readAt).map(r=>r.id);d.length>0&&i(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:()=>t(d=>!d),children:[e.jsx(ie,{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:[l?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 a(),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 r=e.jsxs(e.Fragment,{children:[e.jsx(Ie,{name:gs(d.type),tone:fs(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:pe(d.occurredAt)})]})]});return d.link?e.jsx(te,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>t(!1),children:r},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:r},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(te,{to:"/admin/activity",onClick:()=>t(!1),children:"All activity"})})]}):null]})}function V({title:s,intro:n,actions:l,crumbs:i,icon:a}){var o;const c=Fe(),t=a??((o=Os.find(d=>Te({path:d.path,end:!0},c.pathname)))==null?void 0:o.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(ie,{name:t})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),l?e.jsx("div",{className:"page-head-actions",children:l}):null]})]})}function $({title:s,intro:n,icon:l,tone:i,actions:a,footer:c,children:t}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[l?e.jsx(Ie,{name:l,tone:i}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),a?e.jsx("div",{className:"card-head-actions",children:a}):null]}):null,t,c?e.jsx("div",{className:"card-foot",children:c}):null]})}function oe({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function re({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Ie,{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 M({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function ne({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function J({children:s}){return e.jsx("p",{className:"empty",children:s})}function ee({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 ue({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function I({children:s,onClick:n,variant:l,size:i,disabled:a,busy:c,icon:t,type:o="button",title:d}){return e.jsxs("button",{type:o,className:"","data-variant":l,"data-size":i,disabled:a||c,onClick:n,title:d,children:[c?e.jsx("span",{className:"spinner"}):t?e.jsx(ie,{name:t}):null,s]})}function L({label:s,hint:n,children:l,grow:i}){return e.jsxs("label",{className:i?"field grow":"field",children:[e.jsx("span",{children:s}),l,n?e.jsx("small",{children:n}):null]})}function _({label:s,hint:n,checked:l,onChange:i,disabled:a}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:l,disabled:a,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 Ee({value:s,options:n,onChange:l}){return e.jsx("div",{className:"segments",role:"group",children:n.map(i=>e.jsx("button",{type:"button","aria-pressed":i.value===s,onClick:()=>l(i.value),children:i.label},String(i.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function ys({data:s,labelOf:n,valueOf:l,toneOf:i,title:a}){if(s.length===0)return e.jsx(J,{children:"Nothing in this window."});const c=s.map(o=>l(o)),t=Math.max(1,...c);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((o,d)=>{const r=l(o);return e.jsx("div",{className:"bar","data-tone":i==null?void 0:i(o),"data-empty":r===0||void 0,style:{height:`${Math.max(2,r/t*100)}%`},title:a?a(o):`${n(o,d)}: ${r}`},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:l}){const i=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":l,children:e.jsx("div",{style:{width:`${i*100}%`}})})}function U({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(ie,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(ie,{name:"close"})}):null]}):null}function z({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,l)=>e.jsx("span",{className:"skeleton"},l))}),Array.from({length:s},(n,l)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},l))]})}function he({title:s,body:n,confirmLabel:l="Confirm",destructive:i,busy:a,onConfirm:c,onCancel:t}){const o=p.useId(),d=p.useRef(null);return p.useEffect(()=>{var v;(v=d.current)==null||v.focus();const r=w=>{w.key==="Escape"&&t()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[t]),e.jsx("div",{className:"scrim",onPointerDown:r=>r.target===r.currentTarget&&t(),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(I,{onClick:t,variant:"quiet",children:"Cancel"}),e.jsx(I,{onClick:c,variant:i?"danger":"primary",busy:a,children:l})]})]})})}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 ws({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:l}=Oe(),i=Fe(),[a,c]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),t=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};p.useEffect(()=>{const d=fe.find(r=>r.items.some(v=>Te({path:v.path,end:!0},i.pathname)));d&&c(r=>{const v={...r};return fe.forEach(w=>{w.collapsible!==!1&&(v[w.id]=w.id!==d.id)}),t(v),v})},[i.pathname]);const o=(d,r=!1)=>{c(v=>{const w={...v},x=!(v[d]??r);return fe.forEach(m=>{m.collapsible!==!1&&(w[m.id]=m.id===d?x:!0)}),t(w),w})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:fe.map(d=>{const r=d.items.filter(m=>!m.hidden);if(r.length===0)return null;const v=r.some(m=>Te({path:m.path,end:!0},i.pathname)),w=d.collapsible!==!1,x=v||!w||!(a[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&w?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":x,onClick:()=>o(d.id,d.defaultCollapsed),children:[d.label,e.jsx(ie,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,x?r.map(m=>e.jsx(Rs,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:f})=>f?"on":"","aria-current":void 0,children:({isActive:f})=>e.jsxs("span",{style:{display:"contents"},ref:u=>{const b=u==null?void 0:u.parentElement;b&&(f?b.setAttribute("aria-current","page"):b.removeAttribute("aria-current"))},children:[m.icon?e.jsx(ie,{name:m.icon}):null,m.label,m.badge==="notifications"&&l>0?e.jsx("span",{className:"rail-badge",children:l>99?"99+":l}):null]})},m.id)):null]},d.id)})})}function en(){var E,C,B;const{version:s,currentUser:n,online:l,loading:i,status:a,setMaintenance:c}=ae(),[t,o]=p.useState(!1),[d,r]=p.useState(!1),[v,w]=p.useState(!1),[x,m]=p.useState(!1),f=Fe(),u=!!((E=a==null?void 0:a.maintenance)!=null&&E.enabled),b=!!((C=a==null?void 0:a.quietTime)!=null&&C.active),j=p.useRef(null),S=((B=Array.from(n.trim())[0])==null?void 0:B.toLocaleUpperCase("en-NZ"))||"A",h=a&&l&&!u&&!b?"ok":a||!i?"bad":"checking",g=async()=>{if(!(v||!a)){w(!0);try{await c(!u)}finally{w(!1)}}};return p.useEffect(()=>o(!1),[f.pathname]),p.useEffect(()=>{if(!d)return;const y=K=>{var Y;(Y=j.current)!=null&&Y.contains(K.target)||r(!1)},q=K=>{K.key==="Escape"&&r(!1)};return document.addEventListener("mousedown",y),window.addEventListener("keydown",q),()=>{document.removeEventListener("mousedown",y),window.removeEventListener("keydown",q)}},[d]),p.useEffect(()=>{if(!t)return;const y=document.body.style.overflow;document.body.style.overflow="hidden";const q=K=>{K.key==="Escape"&&o(!1)};return window.addEventListener("keydown",q),()=>{document.body.style.overflow=y,window.removeEventListener("keydown",q)}},[t]),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":t,"aria-controls":"rail",onClick:()=>o(y=>!y),children:e.jsx(ie,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Ks,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsx("button",{type:"button",className:"topbar-status","data-tone":h,"aria-pressed":u,disabled:!a||!l||v||b,title:a?b?"Memby quiet time is active":u?"Bring Memby back online":l?"Take Memby offline":"Memby is not responding":"Checking Memby status","aria-label":b?"Memby quiet time is active":u?"Memby is offline. Bring it online":a&&l?"Memby is online. Take it offline":i?"Checking Memby status":"Memby is not responding",onClick:()=>u?void g():m(!0),children:e.jsx("span",{className:"dot","aria-hidden":"true"})}),e.jsx(Qs,{}),e.jsxs("div",{className:"account-menu","data-open":d||void 0,ref:j,children:[e.jsxs("button",{type:"button",className:"account-trigger","aria-haspopup":"menu","aria-expanded":d,"aria-label":`Signed in as ${n}`,onClick:()=>r(y=>!y),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:S}),e.jsx("span",{className:"account-name",children:n}),e.jsx(ie,{name:"caret",className:"ico account-caret"})]}),d?e.jsxs("div",{className:"account-panel",role:"menu",children:[e.jsxs("div",{className:"account-identity",children:[e.jsx("span",{className:"account-avatar account-avatar-large","aria-hidden":"true",children:S}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsxs("button",{type:"submit",role:"menuitem",children:[e.jsx(ie,{name:"logout"}),"Log out"]})})]}):null]})]})]}),t?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>o(!1)}):null,e.jsx(Xs,{open:t,onNavigate:()=>o(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Es,{})}),x?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:v,onConfirm:()=>{m(!1),g()},onCancel:()=>m(!1)}):null]})}const ks=p.createContext(null),sn=5e3;function nn({children:s}){const[n,l]=p.useState([]),i=p.useRef(1),a=p.useCallback(d=>{l(r=>r.filter(v=>v.id!==d))},[]),c=p.useCallback((d,r="ok")=>{const v=i.current++;l(w=>[...w,{id:v,message:d,tone:r}]),window.setTimeout(()=>a(v),sn)},[a]),t=p.useCallback(async(d,r)=>{try{const v=await d();return r&&c(r,"ok"),v}catch(v){c(v instanceof Error?v.message:String(v),"bad");return}},[c]),o=p.useMemo(()=>({show:c,wrap:t}),[c,t]);return e.jsxs(ks.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(ie,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>a(d.id),"aria-label":"Dismiss",children:e.jsx(ie,{name:"close"})})]},d.id))})]})}function se(){const s=p.useContext(ks);if(!s)throw new Error("useToast used outside ToastProvider");return s}function Q(s,n={}){const{pollMs:l,enabled:i=!0}=n,[a,c]=p.useState(),[t,o]=p.useState(""),[d,r]=p.useState(i),[v,w]=p.useState(!1),x=p.useRef(0),m=p.useRef(!1),f=p.useCallback(async()=>{if(!i)return;const u=++x.current;m.current&&w(!0);try{const b=await F.get(s);if(u!==x.current)return;c(b),o(""),m.current=!0}catch(b){if(u!==x.current)return;o(b instanceof Error?b.message:String(b))}finally{u===x.current&&(r(!1),w(!1))}},[s,i]);return p.useEffect(()=>(m.current=!1,r(!0),f(),()=>{x.current+=1}),[f]),p.useEffect(()=>{if(!l||!i)return;let u;const b=()=>{window.clearInterval(u),u=document.hidden?void 0:window.setInterval(()=>void f(),l)},j=()=>{b(),document.hidden||f()};return b(),document.addEventListener("visibilitychange",j),()=>{window.clearInterval(u),document.removeEventListener("visibilitychange",j)}},[l,i,f]),{data:a,error:t,loading:d,refreshing:v,reload:f,set:c}}function X(){const[s,n]=p.useState(null),l=p.useRef(!0);p.useEffect(()=>()=>{l.current=!1},[]);const i=p.useCallback(async(a,c)=>{n(a);try{return await c(),!0}finally{l.current&&n(null)}},[]);return{busy:s,run:i}}function tn(){var b,j,S,h,g,E;const{status:s,error:n,loading:l}=ae(),i=Q("/admin/api/runtime",{pollMs:3e4}),a=Q("/admin/api/views",{pollMs:6e4});if(l||!s)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(z,{})]});const c=s.features??{features:[],revision:0,safeMode:!1},t=c.features??[],o=s.clients??[],d=o.filter(C=>Ne(C.lastSeen)).length,r=s.updatePolicy??{},v=!!r.minimumVersion&&r.minimumVersion===r.latestVersion,w=s.playbackPolicy,x=s.mdblist,m=s.forYou,f=(s.runs??[]).slice(0,5),u=i.data;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(re,{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=a.data)==null?void 0:b.lastWeek.visits)??0} this time last week`,value:N((j=a.data)==null?void 0:j.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((S=a.data)==null?void 0:S.lastWeek.viewers)??0} this time last week`,value:N((h=a.data)==null?void 0:h.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${t.filter(C=>C.enabled).length} / ${t.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx($,{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:(g=s.maintenance)!=null&&g.enabled?e.jsx(M,{tone:"bad",children:"offline for maintenance"}):(E=s.quietTime)!=null&&E.active?e.jsx(M,{tone:"warn",children:"quiet time active"}):e.jsx(M,{tone:"ok",children:"online"})},{label:"Feature control plane",value:c.safeMode?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["revision r",N(c.revision)]})},{label:"App update prompt",value:r.enabled?e.jsxs(M,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",r.latestVersion]}):e.jsx(M,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(M,{tone:"warn",children:"running"}):e.jsxs(M,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(w==null?void 0:w.prerollEnabled)===!1?e.jsx(M,{children:"off"}):e.jsxs(M,{tone:"ok",children:[((w==null?void 0:w.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx($,{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(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"MDBList ratings",value:x!=null&&x.enabled?e.jsxs(M,{tone:"ok",children:[N(x.cachedTitles)," titles stored"]}):e.jsx(M,{children:x!=null&&x.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(M,{tone:"warn",children:"rebuilding"}):e.jsxs(M,{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(oe,{cols:"2",children:[e.jsx($,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(te,{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:f.length===0?e.jsx(ee,{columns:4,children:"No imports have run yet."}):f.map(C=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(C.startedAt)}),e.jsx("td",{children:C.kind}),e.jsx("td",{children:e.jsx(M,{tone:C.status==="success"?"ok":C.status==="running"?"warn":"bad",children:C.status})}),e.jsx("td",{className:"num",children:N(C.itemsUpserted)})]},C.id||C.startedAt))})]})})}),e.jsx($,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:u?e.jsxs(e.Fragment,{children:[e.jsx(ws,{tiles:[{label:"goroutines",value:N(u.goroutines)},{label:"heap in use",value:ye(u.heapInuse)},{label:"reserved",value:ye(u.sys)},{label:"collections",value:N(u.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",ye(u.nextGc)," · memory limit"," ",u.memoryLimit>0&&u.memoryLimit`/admin/api/notifications${Se({days:a,type:t,severity:d,unread:v,limit:f,offset:x*f})}`,[a,t,d,v,x]),{data:b,error:j,loading:S,reload:h}=Q(u),g=async()=>{await l(),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(I,{onClick:()=>void g(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(U,{message:j}),e.jsx(re,{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(L,{label:"Window",children:e.jsx(Ee,{value:a,options:an.map(E=>({value:E.value,label:E.label})),onChange:E=>{c(E),m(0)}})}),e.jsx(L,{label:"Kind",children:e.jsxs("select",{value:t,onChange:E=>{o(E.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((b==null?void 0:b.types)??[]).map(E=>e.jsxs("option",{value:E.type,children:[_e(E.type)," (",E.count,")"]},E.type))]})}),e.jsx(L,{label:"Severity",children:e.jsxs("select",{value:d,onChange:E=>{r(E.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(L,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:E=>{w(E.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(I,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{h(),i()},children:"Refresh"})})]}),S?e.jsx(z,{}):e.jsx($,{title:"Events",icon:"bell",tone:"info",footer:((b==null?void 0:b.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx(I,{size:"sm",disabled:x===0,onClick:()=>m(x-1),children:"Newer"}),e.jsx(I,{size:"sm",disabled:(x+1)*f>=((b==null?void 0:b.total)??0),onClick:()=>m(x+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(ee,{columns:6,children:"Nothing has happened in this window."}):b==null?void 0:b.events.map(E=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(E.occurredAt),children:pe(E.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Ie,{name:gs(E.type),tone:fs(E)}),_e(E.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:E.title}),E.summary?e.jsx("div",{className:"muted",children:E.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:E.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:E.target||"—"}),e.jsxs("td",{className:"nowrap",children:[E.readAt?null:e.jsx(M,{tone:"ok",children:"new"}),E.link?e.jsx(te,{className:"table-row-link",to:E.link,children:"Open"}):null]})]},E.id))})]})})})]})}function ln(){const{data:s,error:n,loading:l}=Q("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],a=i.flatMap(o=>o.devices??[]),c=i.filter(o=>{var d;return(d=o.recommendations)==null?void 0:d.completed}).length,t=i.filter(o=>{var d,r;return((d=o.recommendations)==null?void 0:d.prompted)&&!((r=o.recommendations)!=null&&r.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(U,{message:n}),e.jsx(ue,{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."}),l?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Memby users",value:N(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:N(a.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(a.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(t),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:i.length===0?e.jsx(J,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):i.map(o=>{var x,m;const d=o.devices??[],r=d.filter(f=>Ne(f.lastSeen)).length,v=(x=o.recommendations)!=null&&x.completed?{label:"personalised",tone:"ok"}:(m=o.recommendations)!=null&&m.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},w=Pe(o.lastSeen);return e.jsxs(te,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(o.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:js(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":w.tone,title:w.label})]}),e.jsxs("span",{className:"list-meta",children:[N(d.length)," device",d.length===1?"":"s",r?` · ${r} active now`:""," · last seen ",O(o.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(M,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},o.id)})})]})]})}function $e(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function on(){const{userId:s=""}=Re(),n=De(),{wrap:l}=se(),{busy:i,run:a}=X(),c=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:t,error:o,loading:d,reload:r}=Q("/admin/api/accounts",{pollMs:3e4}),[v,w]=p.useState(null),[x,m]=p.useState(null),[f,u]=p.useState(null),[b,j]=p.useState(null),[S,h]=p.useState(null),g=((t==null?void 0:t.accounts)??[]).find(A=>A.id===s),E=(t==null?void 0:t.catalogue)??[],C=(t==null?void 0:t.themes)??[];p.useEffect(()=>{var A;v===null&&g&&w({...((A=g.settings)==null?void 0:A.preferences)??{}})},[g,v]),p.useEffect(()=>{f===null&&g&&u({...g.notifications})},[g,f]),p.useEffect(()=>{if(x!==null||!g)return;const A=g.themes??[];m(A.length===0?C.map(R=>R.id):A)},[g,x,C]);const B=p.useMemo(()=>{const A=[];for(const R of E){let H=A.find(k=>k.name===R.area);H||A.push(H={name:R.area,definitions:[]}),H.definitions.push(R)}return A},[E]),y=(A,R,H,k)=>a(A,async()=>{const D=await l(R,H);j(null),D!==void 0&&(k==null||k()),await r()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(te,{to:"/admin/accounts",children:"← All users"})}),e.jsx(z,{})]});if(!g)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(te,{to:"/admin/accounts",children:"← All users"})}),e.jsx(U,{message:o}),e.jsx($,{children:e.jsx(J,{children:"This user is no longer signed in to Memby."})})]});const q=g.devices??[],K=q.filter(A=>Ne(A.lastSeen)).length,Y=g.settings??{},G=g.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:g.username||"Unnamed user",intro:`Memby user · ${N(q.length)} device${q.length===1?"":"s"} · last seen ${O(g.lastSeen)}`,crumbs:e.jsx(te,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:js(g.username)}),K?e.jsxs(M,{tone:"ok",children:[K," active now"]}):e.jsx(M,{children:"idle"}),e.jsx(ne,{children:g.id})]})}),e.jsx(U,{message:o}),e.jsxs(oe,{cols:"wide",children:[e.jsx($,{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:q.length===0?e.jsx(J,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:q.map(A=>{const R=Pe(A.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":R.tone,title:R.label})," ",e.jsx(te,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(A.id)}`,children:A.name||"Memby TV"})]}),e.jsxs("p",{children:[A.version?`Memby ${A.version}`:"Legacy Memby client"," · ",R.label," · last seen ",O(A.lastSeen)," · signed in ",O(A.signedInAt)]}),(A.versions??[]).length>0?e.jsx("div",{className:"chips",children:(A.versions??[]).map(H=>e.jsxs(ne,{tone:H.version===A.version?"ok":void 0,children:[H.version,H.version===A.version?" · now":""]},H.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(I,{size:"sm",disabled:!A.id,onClick:()=>h({id:A.id,name:A.name}),children:"Rename"}),e.jsx(I,{size:"sm",variant:"danger",disabled:!A.id,onClick:()=>j({kind:"remove-device",deviceId:A.id,name:A.name}),children:"Sign out"})]})]},A.id||A.name)})})}),e.jsxs($,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:G.completed?e.jsx(I,{busy:i==="reset-rec",onClick:()=>j({kind:"reset-recommendations"}),children:"Clear stored choices"}):G.prompted?e.jsx(I,{onClick:()=>j({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(I,{variant:"primary",busy:i==="prompt",onClick:()=>void y("prompt",()=>F.put(`${c}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:G.completed?e.jsx(M,{tone:"ok",children:"completed"}):G.prompted?e.jsx(M,{tone:"warn",children:"prompt queued"}):e.jsx(M,{children:"not invited"})}),e.jsx(cn,{prompt:G})]})]}),e.jsx($,{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:f!=null&&f.enabled?e.jsx(M,{tone:"ok",children:"enabled"}):e.jsx(M,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:i==="notifications",onClick:()=>void y("notifications",()=>F.put(`${c}/notifications`,f),"Notification settings saved.",()=>u(null)),children:"Save notifications"}),e.jsx(I,{onClick:()=>{u(null),r()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(_,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:A=>u(R=>R&&{...R,enabled:A})}),e.jsx(_,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:f.showReturnAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,showReturnAlerts:A})}),e.jsx(_,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,sonarrAlerts:A})}),e.jsx(_,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,radarrAlerts:A})}),e.jsx(_,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:f.updateAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,updateAlerts:A})}),e.jsx(_,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,libraryAlerts:A})}),e.jsx(_,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:f.systemAlerts,disabled:!f.enabled,onChange:A=>u(R=>R&&{...R,systemAlerts:A})})]}):null}),e.jsx($,{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:Y.saved?e.jsxs(M,{tone:Y.source==="admin"?"warn":"ok",children:["r",N(Y.revision)," · ",Y.source||"device"," · ",O(Y.updatedAt)]}):e.jsx(M,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:i==="push",onClick:()=>void y("push",()=>F.put(`${c}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>w(null)),children:"Push to their televisions"}),e.jsx(I,{onClick:()=>{w(null),r()},children:"Discard changes"}),e.jsx(I,{onClick:()=>j({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(te,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:B.map(A=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:A.name}),A.definitions.map(R=>e.jsx(dn,{definition:R,value:v==null?void 0:v[R.key],onChange:H=>w(k=>({...k??{},[R.key]:H}))},R.key))]},A.name))}),e.jsx($,{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:(g.themes??[]).length===0?e.jsx(M,{children:"all schemes"}):e.jsxs(M,{tone:"note",children:[N((g.themes??[]).length)," of ",N(C.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:i==="themes",onClick:()=>(x??[]).length===0?j({kind:"no-themes"}):void y("themes",()=>F.put(`${c}/themes`,{themes:x??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx(I,{onClick:()=>m(C.map(A=>A.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:C.map(A=>{var R,H,k;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(x??[]).includes(A.id),onChange:D=>m(T=>D.target.checked?[...T??[],A.id]:(T??[]).filter(P=>P!==A.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":$e((R=A.palette)==null?void 0:R.surface),"--swatch-accent":$e((H=A.palette)==null?void 0:H.accent),"--swatch-hairline":$e((k=A.palette)==null?void 0:k.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:A.name}),e.jsx("p",{children:A.description})]})]},A.id)})})}),e.jsx($,{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(I,{variant:"danger",onClick:()=>j({kind:"remove-account"}),children:"Remove Memby access"})}),S?e.jsx(hn,{initial:S.name,busy:i==="rename",onCancel:()=>h(null),onConfirm:A=>void y("rename",()=>F.put(`${c}/devices/${encodeURIComponent(S.id)}`,{deviceName:A}),"Device renamed.",()=>h(null))}):null,b?e.jsx(un,{pending:b,busy:i,username:g.username,onCancel:()=>j(null),onConfirm:()=>{switch(b.kind){case"remove-device":return void y("remove-device",()=>F.del(`${c}/devices/${encodeURIComponent(b.deviceId)}`),"Device signed out.");case"remove-account":return void y("remove-account",()=>F.del(`${c}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void y("reset-rec",()=>F.del(`${c}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void y("reset-prefs",()=>F.del(`${c}/preferences`),"Defaults restored.",()=>w(null));case"no-themes":return void y("themes",()=>F.put(`${c}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function cn({prompt:s}){const n=s.ratings??[],l=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],i=[...n.map(a=>e.jsxs(ne,{tone:"warn",children:[a.title," · ",N(a.rating)," ★"]},`r:${a.title}`)),...l.flatMap(([a,c])=>(c??[]).map(t=>e.jsxs(ne,{children:[a,": ",t]},`${a}:${t}`)))];return i.length===0?e.jsx(J,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function dn({definition:s,value:n,onChange:l}){if(s.kind==="toggle")return e.jsx(_,{label:s.name,hint:s.description,checked:!!n,onChange:l});if(s.kind==="choice"||s.kind==="number"){const a=s.unit??"",c=s.kind==="number"?(s.numbers??[]).map(t=>({value:String(t),label:t===0?"No limit":a?`${t} ${a}`:String(t)})):s.options??[];return e.jsx(L,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:t=>l(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 a=Array.isArray(n)?n:[],c=[...a,...(s.options??[]).map(t=>t.value).filter(t=>!a.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 o=(s.options??[]).find(d=>d.value===t);return o?e.jsx(_,{label:o.label,checked:a.includes(t),onChange:d=>l(d?[...a,t]:a.filter(r=>r!==t))},t):null})})]})}const i=Array.isArray(n)?n:[];return e.jsx(L,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` -`),onChange:a=>l(a.target.value.split(` -`).map(c=>c.trim()).filter(Boolean))})})}function hn({initial:s,busy:n,onConfirm:l,onCancel:i}){const[a,c]=p.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(L,{label:"Device name",children:e.jsx("input",{type:"text",value:a,autoFocus:!0,maxLength:80,onChange:t=>c(t.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(I,{variant:"quiet",onClick:i,children:"Cancel"}),e.jsx(I,{variant:"primary",busy:n,disabled:!a.trim(),onClick:()=>l(a.trim()),children:"Rename"})]})]})})}function un({pending:s,busy:n,username:l,onConfirm:i,onCancel:a}){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 ${l||"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:a})}function mn(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const i=(s.options??[]).find(a=>a.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 l=Array.isArray(n)?n:[];return l.length===0?"None":l.map(i=>{var a;return((a=(s.options??[]).find(c=>c.value===i))==null?void 0:a.label)??i}).join(", ")}function pn(){const{userId:s=""}=Re(),{wrap:n}=se(),{busy:l,run:i}=X(),a=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:c,error:t,loading:o,reload:d}=Q(`${a}/preferences/history`,{pollMs:3e4}),[r,v]=p.useState(new Set),[w,x]=p.useState(null),m=(c==null?void 0:c.username)||"this account",f=(c==null?void 0:c.devices)??[],u=(c==null?void 0:c.revisions)??[],b=(c==null?void 0:c.catalogue)??[],j=h=>v(g=>{const E=new Set(g);return E.has(h)?E.delete(h):E.add(h),E}),S=h=>i("restore",async()=>{await n(()=>F.post(`${a}/preferences/revisions/${h}/restore`),`Restored r${h}.`),x(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(te,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(U,{message:t}),o?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx($,{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(M,{tone:c.currentSource==="admin"?"warn":"ok",children:["now on r",N(c.currentRevision)," · ",c.currentSource||"device"]}):e.jsx(M,{children:"defaults · never synced"}),children:f.length===0?e.jsx(J,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(h=>{const g=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":g})," ",h.name||"Memby TV"," ",h.signedOut?e.jsx(M,{children:"signed out"}):null]}),e.jsxs("p",{children:[h.never?"Has not fetched these settings yet":`Holding r${N(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(M,{children:"never taken one"}):h.behind?e.jsxs(M,{tone:"bad",children:[N(h.behind)," behind"]}):e.jsx(M,{tone:"ok",children:"up to date"})})]},h.deviceId||h.name)})})}),e.jsx($,{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(ee,{columns:6,children:"Nothing has been changed on this account yet."}):u.flatMap(h=>{const g=r.has(h.revision),E=h.acks??[],C=h.changes??[],B=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(h.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",N(h.revision)," ",h.current?e.jsx(M,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(M,{tone:h.source==="admin"?"warn":"ok",children:h.author}),h.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",N(h.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:h.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):C.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:C.map((y,q)=>e.jsxs(ne,{children:[y.name,": ",y.before," → ",y.after]},`${y.name}:${q}`))})}),e.jsx("td",{className:"num",children:E.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:E.map(y=>y.deviceName||y.deviceId).join(", "),children:N(E.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(I,{size:"sm",onClick:()=>j(h.revision),children:g?"Hide":"Show"}),h.current?null:e.jsx(I,{size:"sm",onClick:()=>x(h.revision),children:"Restore"})]})})]},h.revision)];return g&&B.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:b.map(y=>{var q;return e.jsxs(ne,{children:[y.name,":"," ",mn(y,(q=h.preferences)==null?void 0:q[y.key])]},y.key)})})})},`${h.revision}:detail`)),B})})]})})})]}),w!==null?e.jsx(he,{title:`Restore revision ${w}?`,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:l==="restore",onConfirm:()=>void S(w),onCancel:()=>x(null)}):null]})}function xn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(l=>e.jsx(ne,{tone:l.version===s.version?"ok":void 0,children:l.version},l.version))})}function jn(){const{status:s,error:n,loading:l}=ae(),i=(s==null?void 0:s.clients)??[],a=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(U,{message:n}),l?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"devices known",value:N(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(i.filter(t=>Ne(t.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:N(a.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:N(c.size),icon:"download",tone:"note"}]}),e.jsx($,{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(ee,{columns:6,children:"No devices have signed in yet."}):i.map(t=>{const o=(t.capabilities??[]).includes("server_features_v1"),d=Pe(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(te,{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(xn,{client:t})}),e.jsx("td",{children:e.jsx(M,{tone:o?"ok":"warn",children:o?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:O(t.lastSeen)})]},`${t.deviceId}:${t.username}`)})})]})})})]})]})}const Ke={user:"",q:"",ip:"",outcome:"",from:"",to:""},vn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function bn(){var h,g,E,C,B,y;const[s,n]=p.useState("log"),[l,i]=p.useState(7),[a,c]=p.useState(Ke),[t,o]=p.useState(0),d=100,r=p.useMemo(()=>Se({...a,days:a.from?void 0:l||void 0,limit:d,offset:t*d}),[a,l,t]),v=Q(`/admin/api/logins${r}`,{enabled:s==="log"}),w=Q(`/admin/api/logins/devices${r}`,{enabled:s==="devices"}),x=((h=v.data)==null?void 0:h.users)??((g=w.data)==null?void 0:g.users)??[],m=((E=v.data)==null?void 0:E.totals)??((C=w.data)==null?void 0:C.totals),f=((B=v.data)==null?void 0:B.retentionDays)??((y=w.data)==null?void 0:y.retentionDays)??90,u=s==="log"?v.loading:w.loading,b=s==="log"?v.error:w.error,j=q=>{c(K=>({...K,...q})),o(0)},S=Object.entries(a).some(([,q])=>q!=="")||!!a.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(Ee,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(U,{message:b}),m?e.jsx(re,{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:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(L,{label:"Window",children:e.jsx(Ee,{value:a.from?-1:l,options:vn.map(q=>({value:q.value,label:q.label})),onChange:q=>{i(q),j({from:"",to:""})}})}),e.jsx(L,{label:"Person",children:e.jsxs("select",{value:a.user,onChange:q=>j({user:q.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),x.map(q=>e.jsx("option",{value:q.id,children:q.username||q.id},q.id))]})}),e.jsx(L,{label:"Outcome",children:e.jsxs("select",{value:a.outcome,onChange:q=>j({outcome:q.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(L,{label:"Address",children:e.jsx("input",{type:"text",value:a.ip,placeholder:"10.0.0.4",onChange:q=>j({ip:q.target.value})})}),e.jsx(L,{label:"From",children:e.jsx("input",{type:"date",value:a.from,onChange:q=>j({from:q.target.value})})}),e.jsx(L,{label:"To",children:e.jsx("input",{type:"date",value:a.to,onChange:q=>j({to:q.target.value})})}),e.jsx(L,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:a.q,placeholder:"Name, device or address",onChange:q=>j({q:q.target.value})})}),e.jsx("div",{className:"filter-actions",children:S?e.jsx(I,{variant:"quiet",size:"sm",onClick:()=>{c(Ke),o(0)},children:"Clear"}):null})]}),u?e.jsx(z,{}):s==="log"?e.jsx(fn,{data:v.data,page:t,limit:d,onPage:o}):e.jsx(gn,{data:w.data})]})}function fn({data:s,page:n,limit:l,onPage:i}){if(!s)return null;const a=s.events.length,c=s.total===0?0:n*l+1;return e.jsxs(e.Fragment,{children:[e.jsxs(oe,{cols:"wide",children:[e.jsx($,{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(ys,{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($,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(J,{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:[N(t.logins)," in",t.failures>0?` · ${N(t.failures)} refused`:""]})]}),t.failures>0&&t.logins===0?e.jsx(M,{tone:"bad",children:"only refused"}):null]},t.ipAddress))})})]}),e.jsx($,{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+a-1)} of ${N(s.total)}`}),footer:s.total>l?e.jsxs(e.Fragment,{children:[e.jsx(I,{size:"sm",disabled:n===0,onClick:()=>i(n-1),children:"Newer"}),e.jsx(I,{size:"sm",disabled:(n+1)*l>=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(ee,{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(te,{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(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:t.failureReason||"refused"})})]},t.id))})]})})})]})}function gn({data:s}){return s?e.jsx($,{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(ee,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(te,{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 yn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function wn(){const{deviceId:s=""}=Re(),[n,l]=p.useState(7),i=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:a,error:c,loading:t}=Q(i,{enabled:!!s}),o=a==null?void 0:a.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(te,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(te,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Ee,{value:n,options:yn.map(r=>({value:r.value,label:r.label})),onChange:l})}),e.jsx(U,{message:c}),t?e.jsx(z,{}):a?e.jsxs(e.Fragment,{children:[e.jsx(re,{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(oe,{cols:"wide",children:[e.jsx($,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(ys,{data:a.days,labelOf:r=>r.day,valueOf:r=>r.logins+r.failures,toneOf:r=>r.failures>r.logins?"bad":void 0,title:r=>`${r.day}: ${r.logins} in${r.failures?`, ${r.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx($,{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:a.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($,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:a.versions.length===0?e.jsx(J,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:a.versions.map(r=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:r.version}),e.jsxs("p",{children:[O(r.firstSeen)," → ",O(r.lastSeen)]})]})},r.version))})})]})]}),e.jsx($,{title:"Addresses",icon:"globe",tone:"data",children:a.addresses.length===0?e.jsx(J,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:a.addresses.map(r=>e.jsxs(ne,{tone:r.failures>0?"warn":"data",children:[r.ipAddress," · ",N(r.logins),r.failures>0?` (+${N(r.failures)} refused)`:""]},r.ipAddress))})}),e.jsx($,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[N(a.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:a.events.length===0?e.jsx(ee,{columns:6,children:"This television has not connected in the selected window."}):a.events.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(r.occurredAt)}),e.jsx("td",{children:r.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:r.ipAddress||"—"}),e.jsx("td",{className:"mono",children:r.clientVersion||"—"}),e.jsx("td",{className:"muted",children:r.method}),e.jsx("td",{className:"nowrap",children:r.success?r.newDevice?e.jsx(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:r.failureReason||"refused"})})]},r.id))})]})})})]}):null]})}function kn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(!1),r=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),w=x=>t(x,async()=>{await a(()=>F.post("/admin/api/sync",{kind:x}),x==="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(U,{message:n}),l||!s?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"items",value:N(s.library.total),icon:"library",tone:"data"},...Object.keys(r).sort().map(x=>({label:x,value:N(r[x]),icon:"list"})),{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx($,{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(I,{variant:"primary",icon:"sync",disabled:v,busy:c==="incremental",onClick:()=>void w("incremental"),children:"Sync new items"}),e.jsx(I,{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 w("full"),onCancel:()=>d(!1)}):null]})}const Nn={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 Sn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(!1),[r,v]=p.useState(""),[w,x]=p.useState(!1),[m,f]=p.useState([]),[u,b]=p.useState(!1),j=s==null?void 0:s.mdblist;p.useEffect(()=>{u||!j||(d(j.enabled),f(j.sources??[]))},[j,u]);const S=()=>t("save",async()=>{await a(()=>F.post("/admin/api/mdblist-settings",{enabled:o,apiKey:r.trim(),clearApiKey:w,sources:m}),"Ratings settings saved."),v(""),x(!1),b(!1),await i()}),h=(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(U,{message:n}),l||!j?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"titles stored",value:N(h),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(oe,{cols:"2",children:[e.jsxs($,{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(M,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(M,{children:j.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(_,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:o,onChange:g=>{d(g),b(!0)}}),e.jsx(L,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:r,placeholder:j.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:g=>v(g.target.value)})}),e.jsx(_,{label:"Remove the saved key",checked:w,onChange:x})]}),e.jsx($,{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(J,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(j.availableSources??[]).map(g=>e.jsx(_,{label:Nn[g]??g,checked:m.includes(g),onChange:E=>{b(!0),f(C=>E?[...C,g]:C.filter(B=>B!==g))}},g))})})]}),e.jsx($,{children:e.jsxs("div",{className:"row",children:[e.jsx(I,{variant:"primary",busy:c==="save",onClick:()=>void S(),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 Cn(){var w;const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),o=(s==null?void 0:s.requestUsers)??[],d=((w=s==null?void 0:s.requestPolicy)==null?void 0:w.allowedUserIds)??[],r=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(x=>[x.userId,x])),[s==null?void 0:s.requestUsage]),v=x=>t(`access-${x}`,async()=>{const m=d.includes(x)?d.filter(f=>f!==x):[...d,x];await a(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(x)?"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(U,{message:n}),l?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx($,{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(M,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(M,{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(J,{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($,{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(J,{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(x=>{const m=r.get(x.id),f=d.includes(x.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:x.username})}),e.jsx("td",{className:"muted nowrap",children:O(x.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(I,{size:"sm",variant:f?"quiet":"primary",busy:c===`access-${x.id}`,onClick:()=>void v(x.id),children:f?"Remove access":"Give access"})})]},x.id)})})]})})})]})]})}function Mn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(!1),r=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),w=(x,m,f)=>t(m,async()=>{await a(()=>F.post("/admin/api/for-you",{action:x}),f),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(U,{message:n}),l?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tracearr sessions",value:N((r==null?void 0:r.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:N((r==null?void 0:r.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:N((r==null?void 0:r.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:O(r==null?void 0:r.lastFullImport),small:!0,icon:"clock"}]}),e.jsx($,{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(I,{variant:"primary",icon:"download",disabled:v,busy:c==="import",onClick:()=>void w("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(I,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(I,{icon:"sync",disabled:v,busy:c==="rebuild",onClick:()=>void w("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx($,{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(te,{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 w("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const En=[["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?En.flatMap(([n,l])=>Object.entries(s[l]??{}).map(([i,a])=>({dimension:n,name:i,weight:a.weight??0,evidence:a.evidence??0}))).sort((n,l)=>Math.abs(l.weight)-Math.abs(n.weight)):[]}const Ge=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function An(){const{status:s}=ae(),{wrap:n}=se(),{busy:l,run:i}=X(),[a,c]=p.useState(""),[t,o]=p.useState("default"),[d,r]=p.useState("0"),[v,w]=p.useState(""),[x,m]=p.useState(null),[f,u]=p.useState("Choose a person to inspect their recommendations."),[b,j]=p.useState(""),S=(s==null?void 0:s.requestUsers)??[],h=()=>i("run",async()=>{if(!a){j("Choose a person to pressure-test.");return}j(""),u("Running the permission check and the scorer…");const y=new URLSearchParams({userId:a,context:t,minutes:d||"0",limit:"100"});v&&y.set("at",new Date(v).toISOString());const q=await n(()=>F.get(`/admin/api/recommendations?${y.toString()}`));q?(m(q),u(`Scored at ${new Date().toLocaleTimeString()}.`)):u("Pressure test failed.")}),g=Rn((x==null?void 0:x.profile)??null).slice(0,24),E=(x==null?void 0:x.actions)??[],C=(x==null?void 0:x.items)??[],B=(x==null?void 0:x.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(U,{message:b}),e.jsx($,{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(I,{variant:"primary",busy:l==="run",onClick:()=>void h(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Person",children:e.jsxs("select",{value:a,onChange:y=>c(y.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),S.map(y=>e.jsx("option",{value:y.id,children:y.username},y.id))]})}),e.jsx(L,{label:"Context",children:e.jsxs("select",{value:t,onChange:y=>o(y.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(L,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:y=>r(y.target.value)})}),e.jsx(L,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:y=>w(y.target.value)})})]})}),x?e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"prepared pool",value:N(x.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:N(x.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:N(C.length),icon:"sparkle",tone:"note"},{label:"source events",value:N(B.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:B.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:O(B.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs($,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[g.length===0?e.jsx(J,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:g.map(y=>e.jsxs(ne,{tone:y.weight<0?"bad":void 0,children:[y.dimension,": ",y.name," ",Ge(y.weight)," · n=",N(y.evidence)]},`${y.dimension}:${y.name}`))}),E.length===0?e.jsx(J,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:E.map((y,q)=>e.jsxs(ne,{tone:"ok",children:[y.action,": ",y.title||y.itemId]},`${y.action}:${q}`))})]}),C.length===0?e.jsx($,{children:e.jsx(J,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(oe,{children:C.map((y,q)=>{const K=y.explanation??{},Y=Object.entries(K.components??{}).sort((R,H)=>Math.abs(H[1])-Math.abs(R[1])),G=y.exposure??{},A=[y.type,y.year,y.runtimeMinutes?`${y.runtimeMinutes} min`:null,...y.genres??[]].filter(Boolean).join(" · ");return e.jsxs($,{title:`#${q+1} · ${y.title}`,intro:A,actions:e.jsx(ne,{tone:"ok",children:Number(K.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[y.preparedReason||"No legacy prepared explanation",y.compatibilityLabel?` · ${y.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(K.reasonCodes??[]).map(R=>e.jsx(ne,{tone:"ok",children:R},R)),Y.map(([R,H])=>e.jsxs(ne,{tone:H<0?"bad":void 0,children:[R,"=",Ge(H)]},R))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",N(y.baseRank??0)," · base ",Number(y.baseScore??0).toFixed(3)," · affinity ",Number(y.affinityScore??0).toFixed(3)," · compatibility"," ",Number(y.compatibilityScore??0).toFixed(3)," · impressions"," ",N(G.impressions??0)," · focuses ",N(G.focuses??0)," · selects"," ",N(G.selects??0)]}),e.jsx("div",{className:"chips",children:(y.eligibleRows??[]).map(R=>e.jsx(ne,{tone:"ok",children:R},R))}),y.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",y.preparedEvidenceTitle]}):null]})]},`${q}:${y.title}`)})})]}):null]})}const In=4,je=[{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 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 $n(s){return s.frequency??"once"}function Tn(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 Qe(s,n){const l=new Date;l.setMinutes(Math.ceil(l.getMinutes()/30)*30,0,0);const i=new Date(l.getTime()+2*60*60*1e3),a=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:l.toISOString(),endAt:i.toISOString(),priority:0,enabled:!0,placements:[a?n:"home"]}}function qn(){var A,R,H;const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a,show:c}=se(),{busy:t,run:o}=X(),[d,r]=p.useState("home"),[v,w]=p.useState({home:ge(),movies:ge(),tv_shows:ge()}),[x,m]=p.useState(!1),[f,u]=p.useState(""),[b,j]=p.useState(null),[S,h]=p.useState([]),[g,E]=p.useState(null),C=s==null?void 0:s.heroPolicy;p.useEffect(()=>{var k,D,T;x||!C||(w({home:((k=C.placements)==null?void 0:k.home)??{pinnedItems:C.pinnedItems??[],primeSubtitle:C.primeSubtitle??""},movies:((D=C.placements)==null?void 0:D.movies)??ge(),tv_shows:((T=C.placements)==null?void 0:T.tv_shows)??ge()}),h(C.schedules??[]))},[C,x]);const B=()=>o("search",async()=>{const k=f.trim();if(!k)return;const D=await a(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(k)}`));D&&j(D.items??[])}),y=v[d],q=y.pinnedItems??[],K=k=>{w(D=>({...D,[d]:{...D[d],...k}})),m(!0)},Y=k=>{if(!q.some(D=>D.id===k.id)){if(q.length>=In){c("Remove a pinned title before adding another.","bad");return}K({pinnedItems:[...q,k]})}},G=()=>o("save",async()=>{await a(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([k,D])=>[k,{pinnedItemIds:(D.pinnedItems??[]).map(T=>T.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:S}),"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(U,{message:n}),l?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(oe,{children:je.map(k=>{var le;const D=v[k.id],T=S.filter(me=>me.enabled&&(me.placements??["home"]).includes(k.id)).sort((me,Cs)=>Cs.priority-me.priority)[0],P=(D.pinnedItems??[]).length?"Manual":T?"Schedule ready":"Automatic",ce=(le=C==null?void 0:C.items)==null?void 0:le.find(me=>me.id===(T==null?void 0:T.itemId)),de=(D.pinnedItems??[]).map(me=>me.name).join(", ")||(ce==null?void 0:ce.name)||(T==null?void 0:T.itemId)||"Resolved for each viewer";return e.jsx($,{title:k.label,intro:`${P} · ${de}`,tone:k.id===d?"info":void 0,children:e.jsxs(I,{size:"sm",variant:"quiet",onClick:()=>r(k.id),children:["Manage ",k.label]})},k.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:je.map(k=>e.jsx(I,{variant:d===k.id?"primary":"quiet",onClick:()=>r(k.id),children:k.label},k.id))}),e.jsxs($,{title:`${(A=je.find(k=>k.id===d))==null?void 0:A.label} hero`,intro:`Pinned ${(R=je.find(k=>k.id===d))==null?void 0:R.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(I,{variant:"primary",busy:t==="save",onClick:()=>void G(),children:"Save hero"}),e.jsx(I,{onClick:()=>{K({pinnedItems:[]})},children:"Clear pins"}),x?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[q.length===0?e.jsx(J,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:q.map((k,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:k.name}),e.jsxs("small",{children:[k.type,k.year?` · ${k.year}`:""]})]}),e.jsx(I,{size:"sm",icon:"clock",onClick:()=>E(Qe(k,d)),children:"Schedule"}),e.jsx(I,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${k.name}`,onClick:()=>K({pinnedItems:q.filter(T=>T.id!==k.id)})})]},k.id))}),e.jsx(L,{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:y.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:k=>{K({primeSubtitle:k.target.value})}})})]}),e.jsx($,{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(M,{tone:"info",children:(C==null?void 0:C.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:t==="save",onClick:()=>void G(),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(J,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...S].sort((k,D)=>Number(D.enabled)-Number(k.enabled)||D.priority-k.priority).map(k=>{const D=[...(C==null?void 0:C.items)??[],...q,...b??[]].find(T=>T.id===k.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":k.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:k.frequency==="weekly"?"Weekly":k.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:k.frequency?k.startTime:k.startAt?new Date(k.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)??k.itemId}),e.jsx(M,{tone:k.enabled?"ok":void 0,children:k.enabled?"enabled":"paused"})]}),e.jsx("p",{children:Tn(k)}),e.jsxs("div",{className:"chips",children:[(k.placements??["home"]).map(T=>{var P;return e.jsx("span",{className:"chip",children:(P=je.find(ce=>ce.id===T))==null?void 0:P.label},T)}),k.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",k.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(I,{size:"sm",onClick:()=>E({...k}),children:"Edit"}),e.jsx(I,{size:"sm",variant:"quiet",onClick:()=>{h(T=>T.map(P=>P.id===k.id?{...P,enabled:!P.enabled}:P)),m(!0)},children:k.enabled?"Pause":"Enable"}),e.jsx(I,{size:"sm",variant:"quiet",onClick:()=>{h(T=>T.filter(P=>P.id!==k.id)),m(!0)},children:"Remove"})]})]},k.id)})})}),e.jsxs($,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(H=je.find(k=>k.id===d))==null?void 0:H.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(L,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:k=>u(k.target.value),onKeyDown:k=>{k.key==="Enter"&&B()}})}),e.jsx(I,{busy:t==="search",icon:"search",onClick:()=>void B(),children:"Search"})]}),b===null?null:b.length===0?e.jsx(J,{children:"No playable films or series matched that search."}):e.jsx(oe,{children:b.map(k=>e.jsx($,{title:k.name,intro:`${k.type||"Title"} · ${k.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(I,{size:"sm",icon:"plus",disabled:q.some(D=>D.id===k.id)||d==="movies"&&k.type!=="Movie"||d==="tv_shows"&&k.type!=="Series",onClick:()=>Y(k),children:q.some(D=>D.id===k.id)?"Pinned":"Add to hero"}),e.jsx(I,{size:"sm",icon:"clock",onClick:()=>E(Qe(k,d)),children:"Schedule"})]})},k.id))})]})]}),g?e.jsx(Ln,{schedule:g,item:[...(C==null?void 0:C.items)??[],...q,...b??[]].find(k=>k.id===g.itemId),timeZone:(C==null?void 0:C.timeZone)||"server local time",isNew:!S.some(k=>k.id===g.id),onCancel:()=>E(null),onSave:k=>{h(D=>D.some(T=>T.id===k.id)?D.map(T=>T.id===k.id?k:T):[...D,k]),E(null),m(!0)}}):null]})}function Ln({schedule:s,item:n,timeZone:l,isNew:i,onSave:a,onCancel:c}){const[t,o]=p.useState({...s,weekdays:[...s.weekdays??[]]}),d=$n(t),r=u=>{const b=new Date,j=new Date(b.getTime()+2*60*60*1e3);o(S=>{var h;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"?(h=S.weekdays)!=null&&h.length?S.weekdays:[1,2,3,4,5]:[]}})},v=t.placements??["home"],w=u=>u==="home"||u==="movies"&&(n==null?void 0:n.type)==="Movie"||u==="tv_shows"&&(n==null?void 0:n.type)==="Series",x=!!(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)),f=d==="once"?x: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)??t.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",l,"."]})]}),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:()=>r(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(L,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:t.startAt?Ze(new Date(t.startAt)):"",onChange:u=>o(b=>({...b,startAt:u.target.value?new Date(u.target.value).toISOString():void 0}))})}),e.jsx(L,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:t.endAt?Ze(new Date(t.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(L,{label:"Starts each time",children:e.jsx("input",{type:"time",value:t.startTime??"",onChange:u=>o(b=>({...b,startTime:u.target.value}))})}),e.jsx(L,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:t.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:Le.map(b=>b.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:Le.map(u=>{const b=(t.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:je.map(u=>e.jsx(_,{label:u.label,checked:v.includes(u.id),disabled:!w(u.id),onChange:b=>o(j=>{const S=j.placements??["home"],h=b?[...S,u.id]:S.filter(g=>g!==u.id);return{...j,placements:h.length?[...new Set(h)]:S}})},u.id))})]}),e.jsx(L,{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:u=>o(b=>({...b,priority:Number(u.target.value)}))})})]}),e.jsx(_,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:t.enabled,onChange:u=>o(b=>({...b,enabled:u}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(I,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx(I,{variant:"primary",disabled:!f,onClick:()=>a(t),children:i?"Add rule":"Save rule"})]})]})})}function Dn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(null),r=s==null?void 0:s.features,v=(r==null?void 0:r.features)??[],w=(s==null?void 0:s.clients)??[],x=(r==null?void 0:r.revision)??0,m=(j,S,h,g)=>t(S,async()=>{await a(()=>F.post("/admin/api/features",{action:j,expectedRevision:x,overrides:g??{}}),h),d(null),await i()}),f=w.filter(j=>(j.capabilities??[]).includes("server_features_v1")).length,u=!!(r!=null&&r.safeMode),b=(j,S)=>({...Object.fromEntries(v.filter(h=>h.source==="override").map(h=>[h.key,h.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(U,{message:n}),l||!r?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx($,{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(I,{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(ws,{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:`${f} / ${w.length}`},{label:"published revision",value:`r${N(x)}`}]})}),e.jsx(oe,{cols:"2",children:v.length===0?e.jsx($,{title:"Nothing registered",icon:"sliders",children:e.jsx(J,{children:"No server features are registered."})}):v.map(j=>e.jsxs($,{title:j.name,intro:j.description,actions:e.jsx(M,{tone:j.enabled?"ok":void 0,children:j.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",j.recovery]}),children:[e.jsx(_,{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(ne,{children:j.key}),e.jsxs(ne,{children:["protocol ",N(j.minimumProtocol),"+"]}),e.jsx(ne,{tone:j.compatible?"ok":"warn",children:j.compatible?"server compatible":"compatibility blocked"}),e.jsx(ne,{tone:"note",children:j.area})]})]},j.key))}),e.jsx($,{children:e.jsxs("div",{className:"row",children:[e.jsx(I,{disabled:!r.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(I,{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(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["live · revision r",N(x)]})]})})]}),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 Fn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a,show:c}=se(),{busy:t,run:o}=X(),[d,r]=p.useState(!0),[v,w]=p.useState("6.5"),[x,m]=p.useState(!1);p.useEffect(()=>{var u,b;x||!s||(r(((u=s.playbackPolicy)==null?void 0:u.prerollEnabled)!==!1),w(String((((b=s.playbackPolicy)==null?void 0:b.prerollDurationMs)??6500)/1e3)))},[s,x]);const f=()=>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 a(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(u*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(U,{message:n}),l?e.jsx(z,{rows:1}):e.jsxs($,{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(M,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(M,{children:"off"}),footer:e.jsx(I,{variant:"primary",busy:t==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(_,{label:"Show the preroll before a title starts",checked:d,onChange:u=>{r(u),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(L,{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=>{w(u.target.value),m(!0)}})})})]})]})}function Pn(){const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(null),[r,v]=p.useState(null),[w,x]=p.useState(!1),m=s==null?void 0:s.subtitles,f=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=h=>d(g=>g&&{...g,...h}),b=()=>t("save",async()=>{o&&(await a(()=>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 i())}),j=()=>t("test",async()=>{v(null);const h=await a(()=>F.post("/admin/api/subtitle-test"));v((h==null?void 0:h.results)??[])}),S=()=>t("clear",async()=>{await a(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),x(!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(U,{message:n}),l||!m||!o?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{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((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:O(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx($,{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(M,{tone:"ok",children:"on"}):e.jsx(M,{children:"off"}):e.jsx(M,{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(_,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:o.bazarr,disabled:!m.bazarrConfigured,onChange:h=>u({bazarr:h})})}),e.jsxs($,{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(M,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(M,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(_,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:o.openSubtitles,onChange:h=>u({openSubtitles:h})}),e.jsx(L,{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:h=>u({key:h.target.value})})}),e.jsx(_,{label:"Remove the saved key",checked:o.clearKey,onChange:h=>u({clearKey:h})}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{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:h=>u({username:h.target.value})})}),e.jsx(L,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.password,onChange:h=>u({password:h.target.value})})})]}),e.jsx(_,{label:"Sign out and forget the account",checked:o.clearLogin,onChange:h=>u({clearLogin:h})})]})]}),e.jsxs($,{children:[e.jsxs("div",{className:"row",children:[e.jsx(I,{variant:"primary",busy:c==="save",onClick:()=>void b(),children:"Save subtitle settings"}),e.jsx(I,{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."})]}),r===null?null:r.length===0?e.jsx(J,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:r.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(M,{tone:h.ok?"ok":"bad",children:h.ok?"reachable":"not reachable"})})]},h.provider))})]}),e.jsx($,{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:f!=null&&f.count?e.jsxs(M,{tone:"data",children:[N(f.count)," files · ",ye(f.bytes)]}):e.jsx(M,{children:"nothing held"}),footer:e.jsx(I,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>x(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),w?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:()=>x(!1)}):null]})}function On(){var H;const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(null),[r,v]=p.useState(!1),[w,x]=p.useState(!1),[m,f]=p.useState(""),[u,b]=p.useState(""),[j,S]=p.useState(!1),[h,g]=p.useState(null),[E,C]=p.useState(""),B=p.useRef(""),y=s==null?void 0:s.updatePolicy;p.useEffect(()=>{o||!y||d({version:y.latestVersion??"",url:y.downloadUrl??"",notes:y.notes??"",retireBelow:y.retireBelowVersion??"",required:!!y.minimumVersion&&y.minimumVersion===y.latestVersion,destructive:!!y.retireBelowVersion&&y.retireBelowVersion===y.latestVersion})},[y,o]),p.useEffect(()=>{let k=!0;const D=async()=>{try{const P=await F.get("/admin/api/release-builder");if(!k)return;g(P),C(""),P.state==="succeeded"&&P.finishedAt&&B.current!==P.finishedAt&&(B.current=P.finishedAt,d(null),await i())}catch(P){if(!k)return;C(P instanceof Error?P.message:"The release builder is unavailable.")}};D();const T=window.setInterval(()=>void D(),3e3);return()=>{k=!1,window.clearInterval(T)}},[i]);const q=k=>t(k?"save":"off",async()=>{o&&(await a(()=>F.post("/admin/api/update-policy",{enabled:k,latestVersion:o.version.trim(),downloadUrl:o.url.trim(),notes:o.notes.trim(),required:o.required,destructive:o.destructive,retireBelowVersion:o.retireBelow.trim()}),k?"Update policy saved.":"Update prompts turned off."),v(!1),d(null),await i())}),K=!!(y!=null&&y.minimumVersion)&&(y==null?void 0:y.minimumVersion)===(y==null?void 0:y.latestVersion),Y=!!(y!=null&&y.retireBelowVersion)&&(y==null?void 0:y.retireBelowVersion)===(y==null?void 0:y.latestVersion),G=k=>d(D=>D&&{...D,...k}),A=()=>t("release",async()=>{const k=await a(()=>F.post("/admin/api/release-builder",{tag:m.trim(),notes:u.trim(),mandatory:j}),"Memby release started.");k&&(g(k),C(""),x(!1))}),R=(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(U,{message:n}),e.jsx(U,{message:E}),e.jsxs($,{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(M,{tone:R,children:(h==null?void 0:h.state)??"checking"}),footer:e.jsx(I,{variant:"primary",busy:c==="release"||(h==null?void 0:h.state)==="running",disabled:!!E,onClick:()=>j?x(!0):void A(),children:m.trim()?`Build ${m.trim()}`:"Build latest release"}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(L,{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:k=>f(k.target.value)})}),e.jsx(L,{label:"Release notes",hint:"Leave blank to use this version's CHANGELOG entry.",children:e.jsx("input",{type:"text",value:u,disabled:(h==null?void 0:h.state)==="running",placeholder:"What's new on the television",onChange:k=>b(k.target.value)})})]}),e.jsx(_,{label:"Make this update required",hint:"Older televisions cannot dismiss the update prompt. You will confirm before the build starts.",checked:j,disabled:(h==null?void 0:h.state)==="running",onChange:S}),h!=null&&h.message?e.jsx(ue,{tone:R,children:h.message}):null,(H=h==null?void 0:h.logs)!=null&&H.length?e.jsx("pre",{className:"code",children:h.logs.join(` -`)}):null,e.jsxs(ue,{children:["Command-line fallback: ",e.jsx("code",{children:(h==null?void 0:h.fallback)??"docker compose run --rm --build memby-builder release"})]})]}),l||!o?e.jsx(z,{rows:1}):e.jsxs($,{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:y!=null&&y.enabled?e.jsxs(M,{tone:K?"warn":"ok",children:[Y?"sign-out · ":K?"required · ":"optional · ",y.latestVersion]}):e.jsx(M,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:c==="save",onClick:()=>o.required?v(!0):void q(!0),children:"Save policy"}),e.jsx(I,{busy:c==="off",onClick:()=>void q(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Latest version",children:e.jsx("input",{type:"text",value:o.version,placeholder:"0.2.63",onChange:k=>G({version:k.target.value})})}),e.jsx(L,{label:"APK URL",children:e.jsx("input",{type:"text",value:o.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:k=>G({url:k.target.value})})})]}),e.jsx(L,{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:k=>G({notes:k.target.value})})}),e.jsx(L,{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:k=>G({retireBelow:k.target.value})})}),e.jsx(_,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:o.required,onChange:k=>G({required:k})}),e.jsx(_,{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:k=>G(k?{destructive:!0,required:!0,retireBelow:o.version.trim()}:{destructive:!1,retireBelow:o.retireBelow.trim()===o.version.trim()?"":o.retireBelow})})]}),r&&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 q(!0),onCancel:()=>v(!1)}):null,w?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 A(),onCancel:()=>x(!1)}):null]})}const Bn=2e4,Un=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 Wn(s){const n=[...Vn];for(const l of[s.defaultIntervalSeconds,s.intervalSeconds])l>0&&!n.some(i=>i.value===l)&&n.push({value:l,label:qe(l).replace(/^every /,"Every ")});return n.sort((l,i)=>l.value-i.value)}function Ye(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function _n(){const{wrap:s}=se(),{busy:n,run:l}=X(),[i,a]=p.useState(!1),{data:c,error:t,loading:o,reload:d}=Q("/admin/api/tasks?limit=60",{pollMs:i?Un:Bn}),r=(c==null?void 0:c.tasks)??[],v=r.some(g=>g.running);v!==i&&a(v);const w=g=>l(g.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(g.id)}/run`),`${g.name} started.`),await d()}),x=(g,E)=>l(`${g.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(g.id)}`,{enabled:E}),E?`${g.name} switched on.`:`${g.name} switched off.`),await d()}),m=(g,E)=>l(`${g.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(g.id)}`,{intervalSeconds:E}),`${g.name} now runs ${qe(E)}.`),await d()}),f=g=>l(`${g.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(g.id)}`,{intervalSeconds:0}),`${g.name} back to its default cadence.`),await d()}),u=r.filter(g=>{var E;return((E=g.lastRun)==null?void 0:E.status)==="failed"}).length,b=r.filter(g=>g.defaultIntervalSeconds>0&&g.intervalSeconds!==g.defaultIntervalSeconds).length,j=r.filter(g=>!g.enabled).length,S=(c==null?void 0:c.groups)??[],h=r.filter(g=>!g.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(U,{message:t}),o?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tasks",value:N(r.length),icon:"clock",tone:"info"},{label:"Running now",value:N(r.filter(g=>g.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(ue,{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,...h.length>0?[""]:[]].map(g=>{const E=r.filter(C=>C.group===g);return E.length===0?null:e.jsx($,{title:g||"Other",icon:g==="System"?"chip":g==="Analytics"?"chart":"wrench",tone:g==="System"?"info":g==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:E.map(C=>{var B;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[C.name," ",C.running?e.jsx(M,{tone:"info",children:"running"}):null,C.enabled?null:e.jsx(M,{tone:"warn",children:"off"}),C.defaultIntervalSeconds>0&&C.intervalSeconds!==C.defaultIntervalSeconds?e.jsx(M,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:C.description}),e.jsxs("p",{className:"quiet",children:[qe(C.intervalSeconds),C.enabled&&C.nextRun?` · next ${pe(C.nextRun).replace(" ago","")}`:"",C.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:O(C.lastRun.startedAt),children:pe(C.lastRun.startedAt)}),` in ${be(C.lastRun.durationMs)}`,C.lastRun.detail?` — ${C.lastRun.detail}`:""]}):" · never run"]}),(B=C.lastRun)!=null&&B.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(M,{tone:"bad",children:C.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[C.lastRun?e.jsx(M,{tone:Ye(C.lastRun.status),children:C.lastRun.status}):e.jsx(M,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${C.name} runs`,value:C.intervalSeconds,disabled:n===`${C.id}:interval`||C.running,onChange:y=>void m(C,Number(y.target.value)),children:Wn(C).map(y=>e.jsxs("option",{value:y.value,children:[y.label,y.value===C.defaultIntervalSeconds?" (default)":""]},y.value))}),C.defaultIntervalSeconds>0&&C.intervalSeconds!==C.defaultIntervalSeconds?e.jsx(I,{size:"sm",icon:"refresh",busy:n===`${C.id}:interval`,onClick:()=>void f(C),children:"Default"}):null,e.jsx(_,{label:"",checked:C.enabled,disabled:n===`${C.id}:enabled`,onChange:y=>void x(C,y)}),e.jsx(I,{size:"sm",icon:"play",busy:n===C.id,disabled:C.running,onClick:()=>void w(C),children:"Run now"})]})]},C.id)})})},g||"other")}),e.jsx($,{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(ee,{columns:6,children:"No task has run yet."}):c==null?void 0:c.runs.map(g=>{var E;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(g.startedAt),children:pe(g.startedAt)}),e.jsx("td",{children:((E=r.find(C=>C.id===g.taskId))==null?void 0:E.name)??g.taskId}),e.jsx("td",{className:"muted",children:g.trigger}),e.jsx("td",{children:e.jsx(M,{tone:Ye(g.status),children:g.status})}),e.jsx("td",{className:"num muted",children:be(g.durationMs)}),e.jsx("td",{className:"muted",children:g.error||g.detail||"—"})]},g.id)})})]})})})]})]})}const zn={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Hn(){const{wrap:s,show:n}=se(),{busy:l,run:i}=X(),{data:a,error:c,loading:t,reload:o}=Q("/admin/api/integrations",{pollMs:6e4}),[d,r]=p.useState(null),[v,w]=p.useState(null),x=(a==null?void 0:a.catalogue)??[],m=(a==null?void 0:a.integrations)??[],f=S=>r({id:S.id,name:S.name,url:"",enabled:S.enabled,events:S.events??[]}),u=()=>i("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(r(null),await o())}),b=S=>i("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(S.id)}`),`${S.name} removed.`),w(null),await o()}),j=S=>i(`test:${S.id}`,async()=>{const h=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(S.id)}/test`));h&&n(h.message,h.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(I,{variant:"primary",icon:"plus",onClick:()=>r(zn),children:"Add a webhook"})}),e.jsx(U,{message:c}),e.jsx(Kn,{}),e.jsx(Gn,{}),e.jsx(Zn,{}),((a==null?void 0:a.dropped)??0)>0?e.jsxs(ue,{tone:"warn",children:[N((a==null?void 0:a.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(z,{}):m.length===0&&!d?e.jsx($,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(J,{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(Jn,{integration:S,catalogue:x,busy:l,onEdit:()=>f(S),onTest:()=>void j(S),onRemove:()=>w(S)},S.id)),d?e.jsx(Qn,{draft:d,catalogue:x,busy:l==="save",onChange:r,onSave:()=>void u(),onCancel:()=>r(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:l==="remove",onConfirm:()=>void b(v),onCancel:()=>w(null)}):null]})}function Kn(){const{wrap:s}=se(),{busy:n,run:l}=X(),{data:i,error:a,loading:c,reload:t}=Q("/admin/api/arr-integrations"),o=d=>l("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($,{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(U,{message:a??""}),c?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(_,{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 o({sonarrEnabled:d})}),e.jsx(_,{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 o({radarrEnabled:d})})]})]})}function Gn(){const{wrap:s}=se(),{busy:n,run:l}=X(),{data:i,error:a,loading:c,reload:t}=Q("/admin/api/sonarr-request-policy"),[o,d]=p.useState(0),[r,v]=p.useState(!1);p.useEffect(()=>{i&&(d(i.qualityProfileId),v(i.searchImmediately))},[i]);const w=()=>l("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:o,searchImmediately:r}),"Sonarr TV request policy saved."),await t()}),x=i==null?void 0:i.profiles.find(m=>m.id===o);return e.jsxs($,{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(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx(I,{variant:"primary",busy:n==="sonarr-request-policy",disabled:c||o<=0,onClick:()=>void w(),children:"Save Sonarr policy"}),children:[e.jsx(U,{message:a??(i==null?void 0:i.error)??""}),c?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(L,{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:!(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(_,{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:r,onChange:v}),x?e.jsxs(ue,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:x.name})," (profile ID ",x.id,"), be monitored using Memby’s existing all-episodes strategy, and ",r?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Zn(){const{wrap:s}=se(),{busy:n,run:l}=X(),{data:i,error:a,loading:c,reload:t}=Q("/admin/api/radarr-request-policy"),[o,d]=p.useState(0),[r,v]=p.useState(!1);p.useEffect(()=>{i&&(d(i.qualityProfileId),v(i.searchImmediately))},[i]);const w=()=>l("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:o,searchImmediately:r}),"Radarr movie request policy saved."),await t()}),x=i==null?void 0:i.profiles.find(m=>m.id===o);return e.jsxs($,{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(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx(I,{variant:"primary",busy:n==="radarr-request-policy",disabled:c||o<=0,onClick:()=>void w(),children:"Save Radarr policy"}),children:[e.jsx(U,{message:a??(i==null?void 0:i.error)??""}),c?e.jsx(z,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(L,{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:!(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(_,{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:r,onChange:v}),x?e.jsxs(ue,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:x.name})," (profile ID ",x.id,"), remain monitored, and ",r?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Jn({integration:s,catalogue:n,busy:l,onEdit:i,onTest:a,onRemove:c}){const t=s.health,o=!t.lastFailure||t.lastSuccess&&t.lastSuccess>t.lastFailure,d=s.events??[];return e.jsxs($,{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(M,{tone:"ok",children:"on"}):e.jsx(M,{tone:"warn",children:"off"}),t.deliveries>0?e.jsx(M,{tone:o?"ok":"bad",children:o?"delivering":"failing"}):e.jsx(M,{children:"never used"}),e.jsx(I,{size:"sm",icon:"pulse",busy:l===`test:${s.id}`,onClick:a,children:"Test"}),e.jsx(I,{size:"sm",onClick:i,children:"Edit"}),e.jsx(I,{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(r=>{var v;return((v=n.find(w=>w.type===r))==null?void 0:v.label)??r}).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:[N(t.deliveries)," attempts, ",N(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(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(r.attemptedAt),children:pe(r.attemptedAt)}),e.jsx("td",{className:"muted",children:r.eventType}),e.jsx("td",{children:r.success?e.jsx(M,{tone:"ok",children:r.statusCode||"ok"}):e.jsx(M,{tone:"bad",children:r.error||r.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:be(r.durationMs)})]},r.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(ee,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Qn({draft:s,catalogue:n,busy:l,onChange:i,onSave:a,onCancel:c}){const t=[...new Set(n.map(d=>d.group))],o=(d,r)=>i({...s,events:r?[...s.events,d]:s.events.filter(v=>v!==d)});return e.jsxs($,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"primary",busy:l,onClick:a,children:s.id?"Save":"Add"}),e.jsx(I,{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(L,{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(L,{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(_,{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(r=>r.group===d).map(r=>e.jsx(_,{label:r.label,hint:r.description,checked:s.events.includes(r.type),onChange:v=>o(r.type,v)},r.type))]},d))]})}function Yn(){var K,Y,G,A;const{status:s,error:n,loading:l,reload:i}=ae(),{wrap:a}=se(),{busy:c,run:t}=X(),[o,d]=p.useState(""),[r,v]=p.useState(!1),[w,x]=p.useState(!1),[m,f]=p.useState(!1),[u,b]=p.useState("23:00"),[j,S]=p.useState("07:00"),[h,g]=p.useState(""),[E,C]=p.useState(!1),B=!!((K=s==null?void 0:s.maintenance)!=null&&K.enabled);p.useEffect(()=>{var R;!w&&s&&d(((R=s.maintenance)==null?void 0:R.message)??"")},[s,w]),p.useEffect(()=>{E||!(s!=null&&s.quietTime)||(f(s.quietTime.enabled),b(s.quietTime.startTime),S(s.quietTime.endTime),g(s.quietTime.message))},[s,E]);const y=R=>t(R?"on":"off",async()=>{await a(()=>F.post("/admin/api/maintenance",{enabled:R,message:o}),R?"Memby is offline for every television.":"Memby is back online."),v(!1),x(!1),await i()}),q=()=>t("quiet",async()=>{await a(()=>F.post("/admin/api/quiet-time",{enabled:m,startTime:u,endTime:j,message:h}),m?"Quiet time saved.":"Quiet time turned off."),C(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time."}),e.jsx(U,{message:n}),l?e.jsx(z,{rows:1}):e.jsx($,{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:B?"bad":"warn",actions:B?e.jsx(M,{tone:"bad",children:"offline"}):e.jsx(M,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(I,{variant:"danger",disabled:B,onClick:()=>v(!0),children:"Go offline"}),e.jsx(I,{disabled:!B,busy:c==="off",onClick:()=>void y(!1),children:"Bring back online"})]}),children:e.jsx(L,{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:R=>{d(R.target.value),x(!0)}})})}),l?null:e.jsxs($,{title:"Quiet time",intro:`Pause new television requests and server background work every day in ${((Y=s==null?void 0:s.quietTime)==null?void 0:Y.timeZone)??"the household timezone"}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`,icon:"clock",tone:(G=s==null?void 0:s.quietTime)!=null&&G.active?"warn":"info",actions:(A=s==null?void 0:s.quietTime)!=null&&A.active?e.jsx(M,{tone:"warn",children:"active now"}):m?e.jsx(M,{tone:"ok",children:"scheduled"}):e.jsx(M,{children:"off"}),footer:e.jsx(I,{variant:"primary",busy:c==="quiet",onClick:()=>void q(),children:"Save quiet time"}),children:[e.jsx(_,{label:"Pause server activity during quiet time",checked:m,onChange:R=>{f(R),C(!0)}}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Starts",hint:"Uses the household's 24-hour clock.",children:e.jsx("input",{type:"time",value:u,onChange:R=>{b(R.target.value),C(!0)}})}),e.jsx(L,{label:"Ends",hint:"May be on the following day, for example 23:00 to 07:00.",children:e.jsx("input",{type:"time",value:j,onChange:R=>{S(R.target.value),C(!0)}})})]}),e.jsx(L,{label:"Message shown on the television",hint:"Shown when a television contacts Memby during quiet time.",children:e.jsx("input",{type:"text",value:h,placeholder:"Quiet time — try again after 7 am",onChange:R=>{g(R.target.value),C(!0)}})})]}),r?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 y(!0),onCancel:()=>v(!1)}):null]})}function Xn(){const{status:s,error:n,loading:l}=ae(),i=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(U,{message:n}),l?e.jsx(z,{rows:1}):e.jsx($,{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(ee,{columns:8,children:"Nothing has been imported yet."}):i.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(a.startedAt)}),e.jsx("td",{children:a.kind}),e.jsx("td",{className:"muted",children:a.trigger}),e.jsx("td",{children:e.jsx(M,{tone:a.status==="success"?"ok":a.status==="running"?"warn":"bad",children:a.status})}),e.jsx("td",{className:"num",children:N(a.itemsSeen)}),e.jsx("td",{className:"num",children:N(a.itemsUpserted)}),e.jsx("td",{className:"num",children:N(a.itemsRemoved)}),e.jsx("td",{className:"muted",children:a.error||""})]},a.id||a.startedAt))})]})})})]})}const Xe={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},es=2e4,et=5e3,we=48,st=31,ss=8,nt=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],ns=new Map(nt.map((s,n)=>[s,n])),tt=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ts=new WeakMap;function ke(s){const n=ts.get(s);if(n)return n;const l=Object.entries(s.attributes??{}).sort((a,c)=>{const t=ns.get(a[0])??(a[0]==="error"?1e3:100),o=ns.get(c[0])??(c[0]==="error"?1e3:100);return t-o||a[0].localeCompare(c[0])}),i={fields:l,summary:l.map(([a,c])=>`${a}=${String(c)}`).join(" "),haystack:[s.message,...l.flat()].join(" ").toLowerCase(),occurred:tt.format(new Date(s.occurredAt))};return ts.set(s,i),i}const it=s=>s.replace(/_/g," "),at=p.memo(function({event:n,index:l,onInspect:i}){const a=ke(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${l*we}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[a.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:a.summary||"No structured details",onClick:()=>i(n.sequence),children:a.summary||"View record"})]})});function rt(){var D;const[s,n]=p.useState([]),[l,i]=p.useState(0),[a,c]=p.useState(!1),[t,o]=p.useState("INFO"),[d,r]=p.useState(""),[v,w]=p.useState(""),[x,m]=p.useState({top:0,height:600}),[f,u]=p.useState(null),b=p.useDeferredValue(d.trim().toLowerCase()),j=p.useRef(0),S=p.useRef(!1),h=p.useRef(0),g=p.useRef(null),E=p.useRef(!0),C=p.useRef(void 0),B=p.useCallback(async()=>{if(a||S.current||document.hidden)return;S.current=!0;const T=h.current,P=[];let ce=0;try{let de=0,le;do le=await F.get(`/admin/api/events?after=${j.current}&limit=1000`),j.current=le.next||j.current,ce+=le.dropped||0,P.push(...le.events??[]),de+=1;while(le.hasMore&&de<20);w("")}catch(de){w(de instanceof Error?de.message:String(de))}finally{P.length>0&&T===h.current&&n(de=>{const le=de.concat(P);return le.length>es?le.slice(le.length-es):le}),ce>0&&T===h.current&&i(de=>de+ce),S.current=!1}},[a]);p.useEffect(()=>{if(a)return;let T;const P=()=>{window.clearInterval(T),T=document.hidden?void 0:window.setInterval(()=>void B(),et)},ce=()=>{P(),document.hidden||B()};return B(),P(),document.addEventListener("visibilitychange",ce),()=>{window.clearInterval(T),document.removeEventListener("visibilitychange",ce)}},[B,a]);const y=p.useMemo(()=>{const T=Xe[t]??20;return s.filter(P=>(Xe[P.level]??0)>=T&&(!b||ke(P).haystack.includes(b)))},[s,t,b]),q=((D=y.at(-1))==null?void 0:D.sequence)??0,K=Math.max(0,x.top-st),Y=Math.ceil(x.height/we)+ss*2,G=Math.min(Math.max(0,Math.floor(K/we)-ss),Math.max(0,y.length-Y)),A=y.slice(G,G+Y),R=p.useMemo(()=>s.find(T=>T.sequence===f),[s,f]);p.useLayoutEffect(()=>{const T=g.current;!T||!E.current||(T.scrollTop=T.scrollHeight,m({top:T.scrollTop,height:T.clientHeight}))},[q,b,t]),p.useEffect(()=>()=>window.cancelAnimationFrame(C.current??0),[]);const H=()=>{const T=g.current;T&&(E.current=T.scrollHeight-T.scrollTop-T.clientHeight{m({top:T.scrollTop,height:T.clientHeight})}))},k=()=>{const T=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),P=document.createElement("a");P.href=URL.createObjectURL(T),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(U,{message:v}),e.jsxs($,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(L,{label:"Level",children:e.jsxs("select",{value:t,onChange:T=>o(T.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(L,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:d,placeholder:"Person, television, title, component, path…",onChange:T=>r(T.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(I,{onClick:()=>c(T=>!T),icon:a?"play":"clock",children:a?"Resume":"Pause"}),e.jsx(I,{onClick:()=>{h.current+=1,n([]),i(0),u(null)},children:"Clear view"}),e.jsx(I,{onClick:k,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:g,onScroll:H,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"})]}),y.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:`${y.length*we}px`},children:A.map((T,P)=>e.jsx(at,{event:T,index:G+P,onInspect:u},T.sequence))})]}),e.jsxs("p",{className:"hint",children:[N(s.length)," retained · ",N(y.length)," matching",y.length?` · ${N(A.length)} rows mounted`:"",l?` · ${N(l)} overwritten before delivery`:"",a?" · paused":""]}),R?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${R.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:R.message}),e.jsxs("span",{children:[ke(R).occurred," · ",R.level," · record #",R.sequence]})]}),e.jsx(I,{size:"sm",variant:"quiet",onClick:()=>u(null),children:"Close"})]}),ke(R).fields.length?e.jsx("dl",{children:ke(R).fields.map(([T,P])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:it(T)}),e.jsx("dd",{children:String(P)})]},T))}):e.jsx(ue,{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 xe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function lt(){const[s,n]=p.useState(30),[l,i]=p.useState(""),a=De(),c=p.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:l})}`,[s,l]),{data:t,error:o,loading:d}=Q(c),r=t==null?void 0:t.stats,v=(t==null?void 0:t.users)??[],w=(t==null?void 0:t.actions)??[],x=(t==null?void 0:t.paths)??[],m=x[0],f=p.useMemo(()=>{const u=new Map(((t==null?void 0:t.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 g,E;const h=(((g=S.stat)==null?void 0:g.uses)??0)-(((E=j.stat)==null?void 0:E.uses)??0);return h||(b.get(j.name)??Number.MAX_SAFE_INTEGER)-(b.get(S.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(U,{message:o}),e.jsx($,{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(L,{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(L,{label:"User",children:e.jsxs("select",{value:l,onChange:u=>{const b=u.target.value;i(b),b&&a(`/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(z,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"journeys",value:N(r==null?void 0:r.journeys),icon:"list",tone:"data"},{label:"viewers",value:N(r==null?void 0:r.viewers),icon:"people",tone:"info"},{label:"completion",value:Me(r==null?void 0:r.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:N(r==null?void 0:r.abandoned),icon:"alert",tone:"note"},{label:"active now",value:N(r==null?void 0:r.active),icon:"pulse",tone:"info"},{label:"average steps",value:((r==null?void 0:r.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:be(r==null?void 0:r.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:Me(r==null?void 0:r.completionRate)})]}),e.jsx(Ys,{value:(r==null?void 0:r.completed)??0,total:(r==null?void 0:r.journeys)??0}),e.jsxs("p",{children:[N(r==null?void 0:r.completed)," completed · ",N(r==null?void 0:r.abandoned)," abandoned ·"," ",N(r==null?void 0:r.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?`${xe(m.from)} → ${xe(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(oe,{cols:"2",children:[e.jsx($,{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:w.length===0?e.jsx(ee,{columns:3,children:"No significant actions in this window."}):w.map(u=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:xe(u.action)}),e.jsx("span",{className:"table-sub",children:xe(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($,{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:x.length===0?e.jsx(ee,{columns:2,children:"No repeated paths in this window."}):x.map((u,b)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[xe(u.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",xe(u.to)]}),e.jsx("td",{className:"num",children:N(u.count)})]},`${u.from}:${u.to}:${b}`))})]})})})]}),e.jsx($,{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:f.map(({name:u,stat:b})=>{const j=(b==null?void 0:b.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:xe(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(M,{tone:"warn",children:"not used"}):j<3?e.jsx(M,{tone:"note",children:"rare"}):e.jsx(M,{tone:"ok",children:"used"})})]},u)})})]})})}),l?null:e.jsx($,{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 ve=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},Ns=s=>ve((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?`${ve(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${ve(s.source)} → ${ve(s.target)}`:Ns(s),ot=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]??ve(s.action);function ct(s){var l;const n=(l=[...s].reverse().find(i=>i.outcome))==null?void 0:l.outcome;return n==="success"||n==="completed"?{label:ve(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:ve(n),tone:"note"}:s.some(i=>i.action==="stop"&&i.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function dt(s){const n=s.reduce((l,i,a)=>(i.category==="playback"&&i.action==="request"&&l.push(a),l),[]);return n.length===0?[s]:n.map((l,i)=>s.slice(i===0?0:l,n[i+1]??s.length))}function ht(){var d,r;const{userId:s=""}=Re(),n=p.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:l,error:i,loading:a}=Q(n),c=((r=(d=l==null?void 0:l.users)==null?void 0:d.find(v=>v.userId===s))==null?void 0:r.username)||s,t=p.useMemo(()=>{const v=new Map;for(const w of(l==null?void 0:l.events)??[])v.set(w.journeyId,[...v.get(w.journeyId)??[],w]);return[...v.values()].map(w=>w.sort((x,m)=>x.sequence-m.sequence)).sort((w,x)=>{var m,f;return(((m=x[0])==null?void 0:m.occurredAt)??"").localeCompare(((f=w[0])==null?void 0:f.occurredAt)??"")})},[l==null?void 0:l.events]),o=t.flatMap(v=>dt(v).map((w,x)=>{var m;return{events:w,key:`${(m=v[0])==null?void 0:m.journeyId}:${x}`}}));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(te,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(U,{message:i}),a?e.jsx(z,{}):e.jsx($,{title:"Viewing journeys",intro:`${t.length} app session${t.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,w)=>{const x=v.events,m=x[0],f=[...x].reverse().find(b=>b.itemName||b.action==="select"||b.category==="playback"&&b.action==="request"),u=ct(x);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 ",w+1," · ",x.length," recorded steps"]})]}),e.jsx(M,{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(ie,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:Ns(m)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(ie,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?as(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(ie,{name:u.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:u.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:x.map(b=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":b.action}),e.jsxs("div",{children:[e.jsx("b",{children:ot(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 ut(){const[s,n]=p.useState(30),{data:l,error:i,loading:a}=Q(`/admin/api/analytics?days=${s}`),c=(l==null?void 0:l.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(U,{message:i}),e.jsx($,{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(L,{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:a?e.jsx(z,{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(ee,{columns:8,children:"No events in this window."}):c.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:rs(t.rowId)}),e.jsx("td",{className:"muted",children:rs(t.rowKind)}),e.jsx("td",{className:"num",children:be(t.dwellMs)}),e.jsx("td",{className:"num",children:N(t.impressions)}),e.jsx("td",{className:"num",children:N(t.focuses)}),e.jsx("td",{className:"num",children:N(t.selects)}),e.jsx("td",{className:"num",children:Me(t.selectRate)}),e.jsx("td",{className:"num",children:N(t.viewers)})]},`${t.rowId}:${t.rowKind}`))})]})})})]})}function mt(){const[s,n]=p.useState(7),{data:l,error:i,loading:a}=Q(`/admin/api/searches?days=${s}`),c=(l==null?void 0:l.terms)??[],t=(l==null?void 0:l.recent)??[],o=l==null?void 0:l.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(U,{message:i}),e.jsx(re,{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:`${(l==null?void 0:l.retentionDays)??30} days`,small:!0,icon:"clock"}]}),a?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx($,{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(L,{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(ee,{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($,{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(ee,{columns:3,children:"No searches in this window."}):t.map((d,r)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:O(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(M,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${r}`))})]})})})]})]})}function ls(s,n){if(n===0)return s>0?"new this week":"no change";const l=Math.round((s-n)/n*100);return`${l>0?"+":""}${l}% vs last week`}function pt(){const{data:s,error:n,loading:l}=Q("/admin/api/views",{pollMs:6e4}),i=(s==null?void 0:s.daily)??[],a=(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(U,{message:n}),l?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{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($,{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(ee,{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:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})}),e.jsx($,{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:a.length===0?e.jsx(ee,{columns:3,children:"No home-screen visits yet today."}):a.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 xt=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function jt(){var a;const s=Q("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:l}=X(),i=(c,t)=>l(`${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(z,{rows:4}):e.jsx($,{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:(a=s.data)!=null&&a.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:xt(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(M,{tone:c.replacementRequested?"note":void 0,children:c.replacementRequested?c.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(M,{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(I,{size:"sm",variant:"quiet",busy:n===`${c.id}-acknowledged`,onClick:()=>void i(c,"acknowledged"),children:"Acknowledge"})," ",e.jsx(I,{size:"sm",variant:"quiet",busy:n===`${c.id}-resolved`,onClick:()=>void i(c,"resolved"),children:"Resolve"})," ",e.jsx(I,{size:"sm",variant:"quiet",busy:n===`${c.id}-dismissed`,onClick:()=>void i(c,"dismissed"),children:"Dismiss"})]})]},c.id))})]})}):e.jsx(J,{children:"No media problems have been reported."})})]})}const vt=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",bt=s=>s==="no_match"?"no match":s,os=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",cs=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function ft(){var u,b,j,S;const s=Q("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=se(),{busy:l,run:i}=X(),[a,c]=p.useState(),[t,o]=p.useState(!1);p.useEffect(()=>{!t&&s.data&&c(s.data.settings)},[s.data,t]);const d=h=>{c(g=>g&&{...g,...h}),o(!0)},r=()=>{a&&i("save",async()=>{const h=await n(()=>F.put("/admin/api/credits",a),"Credits scanning settings saved.");h&&(s.set(h),c(h.settings),o(!1))})},v=((u=s.data)==null?void 0:u.history)??[],w=((b=s.data)==null?void 0:b.pending)??[],x=v.filter(h=>h.outcome==="detected").length,m=v.filter(h=>h.outcome==="no_match").length,f=v.filter(h=>h.outcome==="failed").length;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Credits detection",intro:"Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."}),e.jsx(U,{message:s.error}),s.loading||!a?e.jsx(z,{}):e.jsxs(e.Fragment,{children:[(j=s.data)!=null&&j.enabled?null:e.jsx(ue,{tone:"warn",children:"Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled."}),e.jsx(re,{tiles:[{label:"Waiting candidates",value:N((S=s.data)==null?void 0:S.queueDepth),icon:"clock",tone:w.length?"info":void 0},{label:"Detected in this history",value:N(x),icon:"check",tone:"ok"},{label:"No match",value:N(m),icon:"search",tone:m?"warn":void 0},{label:"Failed",value:N(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(oe,{cols:"wide",children:[e.jsx($,{title:"Candidate controls",intro:"The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead.",icon:"sliders",tone:"note",footer:e.jsx(I,{variant:"primary",icon:"check",busy:l==="save",disabled:!t,onClick:r,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Candidate limit",hint:"Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.",children:e.jsx("input",{type:"number",min:1,max:100,value:a.candidateLimit,onChange:h=>d({candidateLimit:Number(h.target.value)})})}),e.jsx(L,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:a.prefetchEpisodes,onChange:h=>d({prefetchEpisodes:Number(h.target.value)})})}),e.jsx(L,{label:"Maximum look-ahead",hint:"Upper bound for fast binge viewing; must not be below the ordinary look-ahead.",children:e.jsx("input",{type:"number",min:a.prefetchEpisodes,max:20,value:a.maxPrefetch,onChange:h=>d({maxPrefetch:Number(h.target.value)})})}),e.jsx(L,{label:"Retry delay (hours)",hint:"After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.",children:e.jsx("input",{type:"number",min:0,max:720,value:a.retryHours,onChange:h=>d({retryHours:Number(h.target.value)})})})]})}),e.jsxs($,{title:"How selection works",icon:"sparkle",tone:"data",children:[e.jsx("p",{className:"muted",children:"Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching."}),e.jsx("p",{className:"muted",children:"A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it."})]})]}),e.jsx($,{title:"Waiting candidates",intro:"The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes.",icon:"list",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Episode"}),e.jsx("th",{children:"Reason"}),e.jsx("th",{className:"num",children:"Priority"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Demand seen"}),e.jsx("th",{children:"Item ID"})]})}),e.jsx("tbody",{children:w.length===0?e.jsx(ee,{columns:6,children:"No episodes are waiting to be scanned."}):w.map(h=>e.jsxs("tr",{children:[e.jsx("td",{children:cs(h.season,h.episode)}),e.jsx("td",{children:e.jsx(M,{tone:"info",children:os(h.reason)})}),e.jsx("td",{className:"num",children:N(h.priority)}),e.jsx("td",{className:"num",children:N(h.userCount)}),e.jsx("td",{className:"nowrap muted",title:O(h.lastViewed),children:pe(h.lastViewed)}),e.jsx("td",{className:"mono muted",children:h.itemId})]},h.itemId))})]})})}),e.jsx($,{title:"Scan history",intro:"Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately.",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:"Finished"}),e.jsx("th",{children:"Programme"}),e.jsx("th",{children:"Selected because"}),e.jsx("th",{children:"Result"}),e.jsx("th",{children:"Marker"}),e.jsx("th",{children:"Evidence"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:v.length===0?e.jsx(ee,{columns:7,children:"No credits scans have completed yet."}):v.map(h=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(h.finishedAt),children:pe(h.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:h.seriesName||h.itemName||h.itemId}),e.jsxs("span",{className:"table-sub",children:[cs(h.season,h.episode),h.itemName&&h.seriesName?` · ${h.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:"info",children:os(h.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",h.priority]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:vt(h.outcome),children:bt(h.outcome)}),h.error?e.jsx("span",{className:"table-sub",children:h.error}):null]}),e.jsx("td",{className:"nowrap",children:h.markerMs>0?be(h.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[h.method||"visual",h.confidence>0?` · ${Me(h.confidence)}`:"",h.frames>0?` · ${h.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:be(h.durationMs)})]},h.id))})]})})})]})]})}function gt(){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 yt(){return e.jsx(As,{children:e.jsx(zs,{children:e.jsx(Js,{children:e.jsx(nn,{children:e.jsx(Is,{children:e.jsxs(W,{path:"/admin",element:e.jsx(en,{}),children:[e.jsx(W,{index:!0,element:e.jsx(tn,{})}),e.jsx(W,{path:"activity",element:e.jsx(rn,{})}),e.jsx(W,{path:"accounts",element:e.jsx(ln,{})}),e.jsx(W,{path:"accounts/:userId",element:e.jsx(on,{})}),e.jsx(W,{path:"accounts/:userId/settings",element:e.jsx(pn,{})}),e.jsx(W,{path:"clients",element:e.jsx(jn,{})}),e.jsx(W,{path:"logins",element:e.jsx(bn,{})}),e.jsx(W,{path:"devices/:deviceId",element:e.jsx(wn,{})}),e.jsx(W,{path:"library",element:e.jsx(kn,{})}),e.jsx(W,{path:"ratings",element:e.jsx(Sn,{})}),e.jsx(W,{path:"requests",element:e.jsx(Cn,{})}),e.jsx(W,{path:"recommendations",element:e.jsx(Mn,{})}),e.jsx(W,{path:"inspector",element:e.jsx(An,{})}),e.jsx(W,{path:"hero",element:e.jsx(qn,{})}),e.jsx(W,{path:"features",element:e.jsx(Dn,{})}),e.jsx(W,{path:"playback",element:e.jsx(Fn,{})}),e.jsx(W,{path:"subtitles",element:e.jsx(Pn,{})}),e.jsx(W,{path:"credits",element:e.jsx(ft,{})}),e.jsx(W,{path:"updates",element:e.jsx(On,{})}),e.jsx(W,{path:"tasks",element:e.jsx(_n,{})}),e.jsx(W,{path:"integrations",element:e.jsx(Hn,{})}),e.jsx(W,{path:"maintenance",element:e.jsx(Yn,{})}),e.jsx(W,{path:"imports",element:e.jsx(Xn,{})}),e.jsx(W,{path:"logs",element:e.jsx(rt,{})}),e.jsx(W,{path:"journeys",element:e.jsx(lt,{})}),e.jsx(W,{path:"journeys/:userId",element:e.jsx(ht,{})}),e.jsx(W,{path:"views",element:e.jsx(pt,{})}),e.jsx(W,{path:"engagement",element:e.jsx(ut,{})}),e.jsx(W,{path:"searches",element:e.jsx(mt,{})}),e.jsx(W,{path:"media-reports",element:e.jsx(jt,{})}),e.jsx(W,{path:"overview",element:e.jsx($s,{to:"/admin",replace:!0})}),e.jsx(W,{path:"*",element:e.jsx(gt,{})})]})})})})})})}const Ss=document.getElementById("root");if(!Ss)throw new Error("the console has no root element to render into");us(Ss).render(e.jsx(p.StrictMode,{children:e.jsx(yt,{})})); diff --git a/admin-ui/dist/assets/index-CahtXjpP.js b/admin-ui/dist/assets/index-CahtXjpP.js new file mode 100644 index 0000000..ad1d363 --- /dev/null +++ b/admin-ui/dist/assets/index-CahtXjpP.js @@ -0,0 +1,11 @@ +import{r as x,a as $s,u as Ve,L as ie,b as Be,m as Fe,N as ps,O as Ts,c as $e,B as Ls,R as Ds,d as V,e as qs}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))i(a);new MutationObserver(a=>{for(const d of a)if(d.type==="childList")for(const t of d.addedNodes)t.tagName==="LINK"&&t.rel==="modulepreload"&&i(t)}).observe(document,{childList:!0,subtree:!0});function o(a){const d={};return a.integrity&&(d.integrity=a.integrity),a.referrerPolicy&&(d.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?d.credentials="include":a.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function i(a){if(a.ep)return;a.ep=!0;const d=o(a);fetch(a.href,d)}})();var xs={exports:{}},Te={};/** + * @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 Fs=x,Ps=Symbol.for("react.element"),Os=Symbol.for("react.fragment"),Us=Object.prototype.hasOwnProperty,Vs=Fs.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Bs={key:!0,ref:!0,__self:!0,__source:!0};function js(s,n,o){var i,a={},d=null,t=null;o!==void 0&&(d=""+o),n.key!==void 0&&(d=""+n.key),n.ref!==void 0&&(t=n.ref);for(i in n)Us.call(n,i)&&!Bs.hasOwnProperty(i)&&(a[i]=n[i]);if(s&&s.defaultProps)for(i in n=s.defaultProps,n)a[i]===void 0&&(a[i]=n[i]);return{$$typeof:Ps,type:s,key:d,ref:t,props:a,_owner:Vs.current}}Te.Fragment=Os;Te.jsx=js;Te.jsxs=js;xs.exports=Te;var e=xs.exports,vs,ze=$s;vs=ze.createRoot,ze.hydrateRoot;const bs={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 ee({name:s,className:n}){const o=bs[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 Le({name:s,tone:n}){return bs[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(ee,{name:s})}):null}const ge=[{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:"credits",path:"/admin/credits",label:"Credits detection",title:"Credits detection",intro:"Control predictive scanning and review every completed credits scan.",icon:"clock"}]},{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 now or schedule daily quiet time.",icon:"wrench"},{id:"gateway-settings",path:"/admin/settings",label:"Gateway settings",title:"Gateway settings",intro:"Timezone, logging and the other server-level settings for this gateway.",icon:"sliders",hidden:!0},{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"}]}],_s=ge.flatMap(s=>s.items),Ws=ge.flatMap(s=>s.items.filter(n=>!n.path.includes(":")).map(n=>({...n,group:s.label??""})));class He extends Error{constructor(n,o){super(n),this.status=o,this.name="ApiError"}}const zs=5*60*1e3;let gs=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{gs=Date.now()},{passive:!0});const Hs=()=>Date.now()-gs({}));throw new He(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,a]of Object.entries(s))a==null||a===""||a===!1||n.set(i,String(a));const o=n.toString();return o?`?${o}`:""}const q={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"})},Gs=3e4,fs=x.createContext(null);function Zs({children:s}){var h;const[n,o]=x.useState(),[i,a]=x.useState(!1),[d,t]=x.useState(""),[r,c]=x.useState(""),[l,g]=x.useState(!0),v=x.useRef(0),u=x.useCallback(async()=>{const j=++v.current;try{const b=await q.get("/admin/api/status");if(j!==v.current)return;o(b),a(!0),c("")}catch(b){if(j!==v.current)return;a(!1),c(b instanceof Error?b.message:String(b))}finally{j===v.current&&(t(new Date().toISOString()),g(!1))}},[]),m=x.useCallback(async j=>{var b;await q.post("/admin/api/maintenance",{enabled:j,message:((b=n==null?void 0:n.maintenance)==null?void 0:b.message)??""}),await u()},[u,(h=n==null?void 0:n.maintenance)==null?void 0:h.message]);x.useEffect(()=>{u();let j;const b=()=>{window.clearInterval(j),j=document.hidden?void 0:window.setInterval(()=>void u(),Gs)},k=()=>{b(),document.hidden||u()};return b(),document.addEventListener("visibilitychange",k),()=>{window.clearInterval(j),document.removeEventListener("visibilitychange",k)}},[u]);const f=x.useMemo(()=>{var j;return{status:n,version:(n==null?void 0:n.serverVersion)??"",currentUser:((j=n==null?void 0:n.currentUser)==null?void 0:j.trim())||"Administrator",online:i,checkedAt:d,error:r,loading:l,reload:u,setMaintenance:m}},[n,i,d,r,l,u,m]);return e.jsx(fs.Provider,{value:f,children:s})}function ae(){const s=x.useContext(fs);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Js(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 Qs(){const s=Ve(),{status:n}=ae(),[o,i]=x.useState(!1),[a,d]=x.useState(""),[t,r]=x.useState(0),c=x.useRef(null),l=x.useRef(null),g=x.useMemo(()=>{const u=a.trim().toLowerCase();return[...Ws,...((n==null?void 0:n.requestUsers)??[]).map(f=>({id:`user-${f.id}`,path:`/admin/accounts/${encodeURIComponent(f.id)}`,label:f.username||"Unnamed user",title:`User: ${f.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(f=>({id:`device-${f.deviceId}`,path:`/admin/devices/${encodeURIComponent(f.deviceId)}`,label:f.deviceName||"Unnamed device",title:`Device: ${f.deviceName||"Unnamed device"}`,intro:`${f.username||"Unknown user"} · ${f.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((f,h)=>({item:f,rank:u?Js(f,u):1,index:h})).filter(f=>f.rank>0).sort((f,h)=>h.rank-f.rank||f.index-h.index).map(({item:f,rank:h})=>({item:f,rank:h}))},[a,n]);x.useEffect(()=>r(0),[a]),x.useEffect(()=>{const u=m=>{var f;(f=c.current)!=null&&f.contains(m.target)||i(!1)};return document.addEventListener("pointerdown",u),()=>document.removeEventListener("pointerdown",u)},[]),x.useEffect(()=>{const u=m=>{var h,j;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(m.preventDefault(),(h=l.current)==null||h.focus(),(j=l.current)==null||j.select())};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[]);const v=u=>{var m;i(!1),d(""),(m=l.current)==null||m.blur(),s(u)};return e.jsxs("div",{className:"omni",ref:c,"data-open":o||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(ee,{name:"search"}),e.jsx("input",{ref:l,type:"search",value:a,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":o,onFocus:()=>i(!0),onChange:u=>{d(u.target.value),i(!0)},onKeyDown:u=>{var m;if(u.key==="Escape")d(""),i(!1),(m=l.current)==null||m.blur();else if(u.key==="ArrowDown")u.preventDefault(),r(f=>Math.min(f+1,g.length-1));else if(u.key==="ArrowUp")u.preventDefault(),r(f=>Math.max(f-1,0));else if(u.key==="Enter"){const f=g[t];if(!f)return;u.preventDefault(),v(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),o?e.jsx("div",{className:"omni-panel",role:"listbox",children:g.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):g.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:()=>r(m),onClick:f=>{f.preventDefault(),v(u.item.path)},children:[u.item.icon?e.jsx(ee,{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 w=s=>(s??0).toLocaleString(),P=s=>s?new Date(s).toLocaleString():"—";function be(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 Pe(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 ye(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(),Re=s=>`${Math.round((s??0)*100)}%`;function pe(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 a=Math.floor(i/60);if(a<24)return`${a}h ago`;const d=Math.floor(a/24);return d<30?`${d}d ago`:new Date(s).toLocaleDateString()}const ws=15*60*1e3,Ys=3*60*60*1e3,Ne=s=>!!s&&Date.now()-new Date(s).getTime(){var b;try{const k=await q.get(`/admin/api/notifications?limit=${Ge}`);o(k.events),a(k.unread),t(k.types),v.current=Math.max(v.current,((b=k.events[0])==null?void 0:b.id)??0),g("")}catch(k){g(k instanceof Error?k.message:String(k))}},[]),m=x.useCallback(b=>{v.current=Math.max(v.current,b.id),o(k=>k.some(p=>p.id===b.id)?k:[b,...k].sort((p,y)=>y.id-p.id).slice(0,Ge)),b.readAt||a(k=>k+1),t(k=>k.some(p=>p.type===b.type)?k.map(p=>p.type===b.type?{...p,count:p.count+1}:p):[...k,{type:b.type,count:1}])},[]);x.useEffect(()=>{u()},[u]),x.useEffect(()=>{let b=null,k,p=!1;return(()=>{p||(b=new EventSource(`/admin/api/notifications/stream?after=${v.current}`),b.addEventListener("open",()=>{c(!0),window.clearInterval(k),k=void 0}),b.addEventListener("admin",E=>{try{m(JSON.parse(E.data))}catch{}}),b.addEventListener("error",()=>{c(!1),k===void 0&&(k=window.setInterval(()=>void u(),Xs))}))})(),()=>{p=!0,b==null||b.close(),window.clearInterval(k)}},[m,u]);const f=x.useCallback(async b=>{const k=b.filter(p=>p>0);if(k.length!==0){o(p=>p.map(y=>k.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const p=await q.post("/admin/api/notifications/read",{ids:k});a(p.unread)}catch{u()}}},[u]),h=x.useCallback(async()=>{a(0),o(b=>b.map(k=>k.readAt?k:{...k,readAt:new Date().toISOString()}));try{const b=await q.post("/admin/api/notifications/read",{all:!0});a(b.unread)}catch{u()}},[u]),j=x.useMemo(()=>({events:n,unread:i,types:d,connected:r,error:l,markRead:f,markAllRead:h,reload:u}),[n,i,d,r,l,f,h,u]);return e.jsx(ks.Provider,{value:j,children:s})}function We(){const s=x.useContext(ks);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function Ns(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function Ss(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 Ze(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 Je=99;function sn(){const{events:s,unread:n,connected:o,markRead:i,markAllRead:a}=We(),[d,t]=x.useState(!1),r=x.useRef(null);return x.useEffect(()=>{const c=g=>{var v;(v=r.current)!=null&&v.contains(g.target)||t(!1)},l=g=>{g.key==="Escape"&&t(!1)};return document.addEventListener("pointerdown",c),document.addEventListener("keydown",l),()=>{document.removeEventListener("pointerdown",c),document.removeEventListener("keydown",l)}},[]),x.useEffect(()=>{if(!d)return;const c=s.filter(l=>!l.readAt).map(l=>l.id);c.length>0&&i(c)},[d]),e.jsxs("div",{className:"bell",ref:r,"data-open":d||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":d,onClick:()=>t(c=>!c),children:[e.jsx(ee,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>Je?`${Je}+`:n}):null]}),d?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 a(),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(c=>{const l=e.jsxs(e.Fragment,{children:[e.jsx(Le,{name:Ss(c.type),tone:Ns(c)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:c.title||c.type}),c.summary?e.jsx("p",{children:c.summary}):null,e.jsx("time",{dateTime:c.occurredAt,children:pe(c.occurredAt)})]})]});return c.link?e.jsx(ie,{className:"bell-item","data-unread":!c.readAt||void 0,to:c.link,onClick:()=>t(!1),children:l},c.id):e.jsx("div",{className:"bell-item","data-unread":!c.readAt||void 0,children:l},c.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(ie,{to:"/admin/activity",onClick:()=>t(!1),children:"All activity"})})]}):null]})}function O({title:s,intro:n,actions:o,crumbs:i,icon:a}){var r;const d=Be(),t=a??((r=_s.find(c=>Fe({path:c.path,end:!0},d.pathname)))==null?void 0:r.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(ee,{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 R({title:s,intro:n,icon:o,tone:i,actions:a,footer:d,children:t}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[o?e.jsx(Le,{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]}),a?e.jsx("div",{className:"card-head-actions",children:a}):null]}):null,t,d?e.jsx("div",{className:"card-foot",children:d}):null]})}function oe({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function re({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Le,{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 te({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function Z({children:s}){return e.jsx("p",{className:"empty",children:s})}function se({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 ue({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:a,busy:d,icon:t,type:r="button",title:c}){return e.jsxs("button",{type:r,className:"","data-variant":o,"data-size":i,disabled:a||d,onClick:n,title:c,children:[d?e.jsx("span",{className:"spinner"}):t?e.jsx(ee,{name:t}):null,s]})}function D({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 z({label:s,hint:n,checked:o,onChange:i,disabled:a}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:o,disabled:a,onChange:d=>i(d.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 Ie({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 G({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function Cs({data:s,labelOf:n,valueOf:o,toneOf:i,title:a}){if(s.length===0)return e.jsx(Z,{children:"Nothing in this window."});const d=s.map(r=>o(r)),t=Math.max(1,...d);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((r,c)=>{const l=o(r);return e.jsx("div",{className:"bar","data-tone":i==null?void 0:i(r),"data-empty":l===0||void 0,style:{height:`${Math.max(2,l/t*100)}%`},title:a?a(r):`${n(r,c)}: ${l}`},c)})}),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 nn({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 U({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(ee,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(ee,{name:"close"})}):null]}):null}function W({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:a,onConfirm:d,onCancel:t}){const r=x.useId(),c=x.useRef(null);return x.useEffect(()=>{var g;(g=c.current)==null||g.focus();const l=v=>{v.key==="Escape"&&t()};return document.addEventListener("keydown",l),()=>document.removeEventListener("keydown",l)},[t]),e.jsx("div",{className:"scrim",onPointerDown:l=>l.target===l.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":r,ref:c,tabIndex:-1,children:[e.jsx("h2",{id:r,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:d,variant:i?"danger":"primary",busy:a,children:o})]})]})})}function Oe({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 Ms({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 tn({open:s,onNavigate:n}){const{unread:o}=We(),i=Be(),[a,d]=x.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),t=c=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(c))}catch{}};x.useEffect(()=>{const c=ge.find(l=>l.items.some(g=>Fe({path:g.path,end:!0},i.pathname)));c&&d(l=>{const g={...l};return ge.forEach(v=>{v.collapsible!==!1&&(g[v.id]=v.id!==c.id)}),t(g),g})},[i.pathname]);const r=(c,l=!1)=>{d(g=>{const v={...g},u=!(g[c]??l);return ge.forEach(m=>{m.collapsible!==!1&&(v[m.id]=m.id===c?u:!0)}),t(v),v})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:ge.map(c=>{const l=c.items.filter(m=>!m.hidden);if(l.length===0)return null;const g=l.some(m=>Fe({path:m.path,end:!0},i.pathname)),v=c.collapsible!==!1,u=g||!v||!(a[c.id]??c.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[c.label&&v?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":u,onClick:()=>r(c.id,c.defaultCollapsed),children:[c.label,e.jsx(ee,{name:"caret",className:"ico caret"})]}):c.label?e.jsx("div",{className:"rail-head rail-head-static",children:c.label}):null,u?l.map(m=>e.jsx(ps,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:f})=>f?"on":"","aria-current":void 0,children:({isActive:f})=>e.jsxs("span",{style:{display:"contents"},ref:h=>{const j=h==null?void 0:h.parentElement;j&&(f?j.setAttribute("aria-current","page"):j.removeAttribute("aria-current"))},children:[m.icon?e.jsx(ee,{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]},c.id)})})}function an(){var E,N,B;const{version:s,currentUser:n,online:o,loading:i,status:a,setMaintenance:d}=ae(),[t,r]=x.useState(!1),[c,l]=x.useState(!1),[g,v]=x.useState(!1),[u,m]=x.useState(!1),f=Be(),h=!!((E=a==null?void 0:a.maintenance)!=null&&E.enabled),j=!!((N=a==null?void 0:a.quietTime)!=null&&N.active),b=x.useRef(null),k=((B=Array.from(n.trim())[0])==null?void 0:B.toLocaleUpperCase("en-NZ"))||"A",p=a&&o&&!h&&!j?"ok":a||!i?"bad":"checking",y=async()=>{if(!(g||!a)){v(!0);try{await d(!h)}finally{v(!1)}}};return x.useEffect(()=>r(!1),[f.pathname]),x.useEffect(()=>{if(!c)return;const S=K=>{var ne;(ne=b.current)!=null&&ne.contains(K.target)||l(!1)},L=K=>{K.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",S),window.addEventListener("keydown",L),()=>{document.removeEventListener("mousedown",S),window.removeEventListener("keydown",L)}},[c]),x.useEffect(()=>{if(!t)return;const S=document.body.style.overflow;document.body.style.overflow="hidden";const L=K=>{K.key==="Escape"&&r(!1)};return window.addEventListener("keydown",L),()=>{document.body.style.overflow=S,window.removeEventListener("keydown",L)}},[t]),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":t,"aria-controls":"rail",onClick:()=>r(S=>!S),children:e.jsx(ee,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Qs,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsx("button",{type:"button",className:"topbar-status","data-tone":p,"aria-pressed":h,disabled:!a||!o||g||j,title:a?j?"Memby quiet time is active":h?"Bring Memby back online":o?"Take Memby offline":"Memby is not responding":"Checking Memby status","aria-label":j?"Memby quiet time is active":h?"Memby is offline. Bring it online":a&&o?"Memby is online. Take it offline":i?"Checking Memby status":"Memby is not responding",onClick:()=>h?void y():m(!0),children:e.jsx("span",{className:"dot","aria-hidden":"true"})}),e.jsx(sn,{}),e.jsxs("div",{className:"account-menu","data-open":c||void 0,ref:b,children:[e.jsxs("button",{type:"button",className:"account-trigger","aria-haspopup":"menu","aria-expanded":c,"aria-label":`Signed in as ${n}`,onClick:()=>l(S=>!S),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:k}),e.jsx("span",{className:"account-name",children:n}),e.jsx(ee,{name:"caret",className:"ico account-caret"})]}),c?e.jsxs("div",{className:"account-panel",role:"menu",children:[e.jsxs("div",{className:"account-identity",children:[e.jsx("span",{className:"account-avatar account-avatar-large","aria-hidden":"true",children:k}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsxs(ps,{to:"/admin/settings",role:"menuitem",onClick:()=>l(!1),children:[e.jsx(ee,{name:"sliders"}),"Gateway settings"]}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsxs("button",{type:"submit",role:"menuitem",children:[e.jsx(ee,{name:"logout"}),"Log out"]})})]}):null]})]})]}),t?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>r(!1)}):null,e.jsx(tn,{open:t,onNavigate:()=>r(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Ts,{})}),u?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:g,onConfirm:()=>{m(!1),y()},onCancel:()=>m(!1)}):null]})}const Es=x.createContext(null),rn=5e3;function ln({children:s}){const[n,o]=x.useState([]),i=x.useRef(1),a=x.useCallback(c=>{o(l=>l.filter(g=>g.id!==c))},[]),d=x.useCallback((c,l="ok")=>{const g=i.current++;o(v=>[...v,{id:g,message:c,tone:l}]),window.setTimeout(()=>a(g),rn)},[a]),t=x.useCallback(async(c,l)=>{try{const g=await c();return l&&d(l,"ok"),g}catch(g){d(g instanceof Error?g.message:String(g),"bad");return}},[d]),r=x.useMemo(()=>({show:d,wrap:t}),[d,t]);return e.jsxs(Es.Provider,{value:r,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(c=>e.jsxs("div",{className:"toast","data-tone":c.tone,children:[e.jsx(ee,{name:c.tone==="bad"?"alert":"check"}),e.jsx("span",{children:c.message}),e.jsx("button",{type:"button",onClick:()=>a(c.id),"aria-label":"Dismiss",children:e.jsx(ee,{name:"close"})})]},c.id))})]})}function Y(){const s=x.useContext(Es);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:o,enabled:i=!0}=n,[a,d]=x.useState(),[t,r]=x.useState(""),[c,l]=x.useState(i),[g,v]=x.useState(!1),u=x.useRef(0),m=x.useRef(!1),f=x.useCallback(async()=>{if(!i)return;const h=++u.current;m.current&&v(!0);try{const j=await q.get(s);if(h!==u.current)return;d(j),r(""),m.current=!0}catch(j){if(h!==u.current)return;r(j instanceof Error?j.message:String(j))}finally{h===u.current&&(l(!1),v(!1))}},[s,i]);return x.useEffect(()=>(m.current=!1,l(!0),f(),()=>{u.current+=1}),[f]),x.useEffect(()=>{if(!o||!i)return;let h;const j=()=>{window.clearInterval(h),h=document.hidden?void 0:window.setInterval(()=>void f(),o)},b=()=>{j(),document.hidden||f()};return j(),document.addEventListener("visibilitychange",b),()=>{window.clearInterval(h),document.removeEventListener("visibilitychange",b)}},[o,i,f]),{data:a,error:t,loading:c,refreshing:g,reload:f,set:d}}function Q(){const[s,n]=x.useState(null),o=x.useRef(!0);x.useEffect(()=>()=>{o.current=!1},[]);const i=x.useCallback(async(a,d)=>{n(a);try{return await d(),!0}finally{o.current&&n(null)}},[]);return{busy:s,run:i}}function on(){var j,b,k,p,y,E;const{status:s,error:n,loading:o}=ae(),i=J("/admin/api/runtime",{pollMs:3e4}),a=J("/admin/api/views",{pollMs:6e4});if(o||!s)return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(W,{})]});const d=s.features??{features:[],revision:0,safeMode:!1},t=d.features??[],r=s.clients??[],c=r.filter(N=>Ne(N.lastSeen)).length,l=s.updatePolicy??{},g=!!l.minimumVersion&&l.minimumVersion===l.latestVersion,v=s.playbackPolicy,u=s.mdblist,m=s.forYou,f=(s.runs??[]).slice(0,5),h=i.data;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(re,{tiles:[{label:"items in the library",value:w(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:w((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${c} active now`,value:w(r.length),icon:"tv",tone:"info"},{label:`visits today · ${((j=a.data)==null?void 0:j.lastWeek.visits)??0} this time last week`,value:w((b=a.data)==null?void 0:b.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((k=a.data)==null?void 0:k.lastWeek.viewers)??0} this time last week`,value:w((p=a.data)==null?void 0:p.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${t.filter(N=>N.enabled).length} / ${t.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx(R,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(Oe,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(C,{tone:"bad",children:"offline for maintenance"}):(E=s.quietTime)!=null&&E.active?e.jsx(C,{tone:"warn",children:"quiet time active"}):e.jsx(C,{tone:"ok",children:"online"})},{label:"Feature control plane",value:d.safeMode?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["revision r",w(d.revision)]})},{label:"App update prompt",value:l.enabled?e.jsxs(C,{tone:g?"warn":"ok",children:[g?"required · ":"optional · ",l.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:(v==null?void 0:v.prerollEnabled)===!1?e.jsx(C,{children:"off"}):e.jsxs(C,{tone:"ok",children:[((v==null?void 0:v.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(R,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(Oe,{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:u!=null&&u.enabled?e.jsxs(C,{tone:"ok",children:[w(u.cachedTitles)," titles stored"]}):e.jsx(C,{children:u!=null&&u.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:[w((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:w((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(oe,{cols:"2",children:[e.jsx(R,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(ie,{to:"/admin/imports",children:"All imports"}),children:e.jsx(G,{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:f.length===0?e.jsx(se,{columns:4,children:"No imports have run yet."}):f.map(N=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(N.startedAt)}),e.jsx("td",{children:N.kind}),e.jsx("td",{children:e.jsx(C,{tone:N.status==="success"?"ok":N.status==="running"?"warn":"bad",children:N.status})}),e.jsx("td",{className:"num",children:w(N.itemsUpserted)})]},N.id||N.startedAt))})]})})}),e.jsx(R,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:h?e.jsxs(e.Fragment,{children:[e.jsx(Ms,{tiles:[{label:"goroutines",value:w(h.goroutines)},{label:"heap in use",value:ye(h.heapInuse)},{label:"reserved",value:ye(h.sys)},{label:"collections",value:w(h.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",ye(h.nextGc)," · memory limit"," ",h.memoryLimit>0&&h.memoryLimit`/admin/api/notifications${Se({days:a,type:t,severity:c,unread:g,limit:f,offset:u*f})}`,[a,t,c,g,u]),{data:j,error:b,loading:k,reload:p}=J(h),y=async()=>{await o(),await p()};return e.jsxs(e.Fragment,{children:[e.jsx(O,{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(U,{message:b}),e.jsx(re,{tiles:[{label:"Events in window",value:w((j==null?void 0:j.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:w(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:w((j==null?void 0:j.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(D,{label:"Window",children:e.jsx(Ie,{value:a,options:cn.map(E=>({value:E.value,label:E.label})),onChange:E=>{d(E),m(0)}})}),e.jsx(D,{label:"Kind",children:e.jsxs("select",{value:t,onChange:E=>{r(E.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((j==null?void 0:j.types)??[]).map(E=>e.jsxs("option",{value:E.type,children:[Ze(E.type)," (",E.count,")"]},E.type))]})}),e.jsx(D,{label:"Severity",children:e.jsxs("select",{value:c,onChange:E=>{l(E.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(D,{label:"Read state",children:e.jsxs("select",{value:g?"unread":"",onChange:E=>{v(E.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:()=>{p(),i()},children:"Refresh"})})]}),k?e.jsx(W,{}):e.jsx(R,{title:"Events",icon:"bell",tone:"info",footer:((j==null?void 0:j.total)??0)>f?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)*f>=((j==null?void 0:j.total)??0),onClick:()=>m(u+1),children:"Older"})]}):void 0,children:e.jsx(G,{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:((j==null?void 0:j.events.length)??0)===0?e.jsx(se,{columns:6,children:"Nothing has happened in this window."}):j==null?void 0:j.events.map(E=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(E.occurredAt),children:pe(E.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Le,{name:Ss(E.type),tone:Ns(E)}),Ze(E.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:E.title}),E.summary?e.jsx("div",{className:"muted",children:E.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:E.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:E.target||"—"}),e.jsxs("td",{className:"nowrap",children:[E.readAt?null:e.jsx(C,{tone:"ok",children:"new"}),E.link?e.jsx(ie,{className:"table-row-link",to:E.link,children:"Open"}):null]})]},E.id))})]})})})]})}function hn(){const{data:s,error:n,loading:o}=J("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],a=i.flatMap(r=>r.devices??[]),d=i.filter(r=>{var c;return(c=r.recommendations)==null?void 0:c.completed}).length,t=i.filter(r=>{var c,l;return((c=r.recommendations)==null?void 0:c.prompted)&&!((l=r.recommendations)!=null&&l.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(U,{message:n}),e.jsx(ue,{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(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Memby users",value:w(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:w(a.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(a.filter(r=>Ne(r.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:w(d),icon:"check",tone:"ok"},{label:"setup prompts queued",value:w(t),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:i.length===0?e.jsx(Z,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):i.map(r=>{var u,m;const c=r.devices??[],l=c.filter(f=>Ne(f.lastSeen)).length,g=(u=r.recommendations)!=null&&u.completed?{label:"personalised",tone:"ok"}:(m=r.recommendations)!=null&&m.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},v=_e(r.lastSeen);return e.jsxs(ie,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(r.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:r.initials||ys(r.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[r.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":v.tone,title:v.label})]}),e.jsxs("span",{className:"list-meta",children:[w(c.length)," device",c.length===1?"":"s",l?` · ${l} active now`:""," · last seen ",P(r.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(C,{tone:g.tone,children:g.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},r.id)})})]})]})}function De(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function un(){const{userId:s=""}=$e(),n=Ve(),{wrap:o}=Y(),{busy:i,run:a}=Q(),d=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:t,error:r,loading:c,reload:l}=J("/admin/api/accounts",{pollMs:3e4}),[g,v]=x.useState(null),[u,m]=x.useState(null),[f,h]=x.useState(null),[j,b]=x.useState(null),[k,p]=x.useState(null),y=((t==null?void 0:t.accounts)??[]).find($=>$.id===s),E=(t==null?void 0:t.catalogue)??[],N=(t==null?void 0:t.themes)??[];x.useEffect(()=>{var $;g===null&&y&&v({...(($=y.settings)==null?void 0:$.preferences)??{}})},[y,g]),x.useEffect(()=>{f===null&&y&&h({...y.notifications})},[y,f]),x.useEffect(()=>{if(u!==null||!y)return;const $=y.themes??[];m($.length===0?N.map(I=>I.id):$)},[y,u,N]);const B=x.useMemo(()=>{const $=[];for(const I of E){let H=$.find(M=>M.name===I.area);H||$.push(H={name:I.area,definitions:[]}),H.definitions.push(I)}return $},[E]),S=($,I,H,M)=>a($,async()=>{const F=await o(I,H);b(null),F!==void 0&&(M==null||M()),await l()});if(c)return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"User",crumbs:e.jsx(ie,{to:"/admin/accounts",children:"← All users"})}),e.jsx(W,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"User",crumbs:e.jsx(ie,{to:"/admin/accounts",children:"← All users"})}),e.jsx(U,{message:r}),e.jsx(R,{children:e.jsx(Z,{children:"This user is no longer signed in to Memby."})})]});const L=y.devices??[],K=L.filter($=>Ne($.lastSeen)).length,ne=y.settings??{},X=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:y.username||"Unnamed user",intro:`Memby user · ${w(L.length)} device${L.length===1?"":"s"} · last seen ${P(y.lastSeen)}`,crumbs:e.jsx(ie,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:y.initials||ys(y.username)}),K?e.jsxs(C,{tone:"ok",children:[K," active now"]}):e.jsx(C,{children:"idle"}),e.jsx(te,{children:y.id})]})}),e.jsx(U,{message:r}),e.jsxs(oe,{cols:"wide",children:[e.jsx(R,{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(Z,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:L.map($=>{const I=_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":I.tone,title:I.label})," ",e.jsx(ie,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent($.id)}`,children:$.name||"Memby TV"})]}),e.jsxs("p",{children:[$.version?`Memby ${$.version}`:"Legacy Memby client"," · ",I.label," · last seen ",P($.lastSeen)," · signed in ",P($.signedInAt)]}),($.versions??[]).length>0?e.jsx("div",{className:"chips",children:($.versions??[]).map(H=>e.jsxs(te,{tone:H.version===$.version?"ok":void 0,children:[H.version,H.version===$.version?" · now":""]},H.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(A,{size:"sm",disabled:!$.id,onClick:()=>p({id:$.id,name:$.name}),children:"Rename"}),e.jsx(A,{size:"sm",variant:"danger",disabled:!$.id,onClick:()=>b({kind:"remove-device",deviceId:$.id,name:$.name}),children:"Sign out"})]})]},$.id||$.name)})})}),e.jsxs(R,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:X.completed?e.jsx(A,{busy:i==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):X.prompted?e.jsx(A,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(A,{variant:"primary",busy:i==="prompt",onClick:()=>void S("prompt",()=>q.put(`${d}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:X.completed?e.jsx(C,{tone:"ok",children:"completed"}):X.prompted?e.jsx(C,{tone:"warn",children:"prompt queued"}):e.jsx(C,{children:"not invited"})}),e.jsx(mn,{prompt:X})]})]}),e.jsx(R,{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:f!=null&&f.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:i==="notifications",onClick:()=>void S("notifications",()=>q.put(`${d}/notifications`,f),"Notification settings saved.",()=>h(null)),children:"Save notifications"}),e.jsx(A,{onClick:()=>{h(null),l()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(z,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:$=>h(I=>I&&{...I,enabled:$})}),e.jsx(z,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:f.showReturnAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,showReturnAlerts:$})}),e.jsx(z,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,sonarrAlerts:$})}),e.jsx(z,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,radarrAlerts:$})}),e.jsx(z,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:f.updateAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,updateAlerts:$})}),e.jsx(z,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,libraryAlerts:$})}),e.jsx(z,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:f.systemAlerts,disabled:!f.enabled,onChange:$=>h(I=>I&&{...I,systemAlerts:$})})]}):null}),e.jsx(R,{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:ne.saved?e.jsxs(C,{tone:ne.source==="admin"?"warn":"ok",children:["r",w(ne.revision)," · ",ne.source||"device"," · ",P(ne.updatedAt)]}):e.jsx(C,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="push",onClick:()=>void S("push",()=>q.put(`${d}/preferences`,{preferences:g??{}}),"Pushed to their televisions.",()=>v(null)),children:"Push to their televisions"}),e.jsx(A,{onClick:()=>{v(null),l()},children:"Discard changes"}),e.jsx(A,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(ie,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:B.map($=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:$.name}),$.definitions.map(I=>e.jsx(pn,{definition:I,value:g==null?void 0:g[I.key],onChange:H=>v(M=>({...M??{},[I.key]:H}))},I.key))]},$.name))}),e.jsx(R,{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:[w((y.themes??[]).length)," of ",w(N.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="themes",onClick:()=>(u??[]).length===0?b({kind:"no-themes"}):void S("themes",()=>q.put(`${d}/themes`,{themes:u??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx(A,{onClick:()=>m(N.map($=>$.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:N.map($=>{var I,H,M;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(u??[]).includes($.id),onChange:F=>m(T=>F.target.checked?[...T??[],$.id]:(T??[]).filter(_=>_!==$.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":De((I=$.palette)==null?void 0:I.surface),"--swatch-accent":De((H=$.palette)==null?void 0:H.accent),"--swatch-hairline":De((M=$.palette)==null?void 0:M.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:$.name}),e.jsx("p",{children:$.description})]})]},$.id)})})}),e.jsx(R,{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:()=>b({kind:"remove-account"}),children:"Remove Memby access"})}),k?e.jsx(xn,{initial:k.name,busy:i==="rename",onCancel:()=>p(null),onConfirm:$=>void S("rename",()=>q.put(`${d}/devices/${encodeURIComponent(k.id)}`,{deviceName:$}),"Device renamed.",()=>p(null))}):null,j?e.jsx(jn,{pending:j,busy:i,username:y.username,onCancel:()=>b(null),onConfirm:()=>{switch(j.kind){case"remove-device":return void S("remove-device",()=>q.del(`${d}/devices/${encodeURIComponent(j.deviceId)}`),"Device signed out.");case"remove-account":return void S("remove-account",()=>q.del(`${d}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void S("reset-rec",()=>q.del(`${d}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void S("reset-prefs",()=>q.del(`${d}/preferences`),"Defaults restored.",()=>v(null));case"no-themes":return void S("themes",()=>q.put(`${d}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function mn({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(a=>e.jsxs(te,{tone:"warn",children:[a.title," · ",w(a.rating)," ★"]},`r:${a.title}`)),...o.flatMap(([a,d])=>(d??[]).map(t=>e.jsxs(te,{children:[a,": ",t]},`${a}:${t}`)))];return i.length===0?e.jsx(Z,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function pn({definition:s,value:n,onChange:o}){if(s.kind==="toggle")return e.jsx(z,{label:s.name,hint:s.description,checked:!!n,onChange:o});if(s.kind==="choice"||s.kind==="number"){const a=s.unit??"",d=s.kind==="number"?(s.numbers??[]).map(t=>({value:String(t),label:t===0?"No limit":a?`${t} ${a}`:String(t)})):s.options??[];return e.jsx(D,{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:d.map(t=>e.jsx("option",{value:t.value,children:t.label},t.value))})})}if(s.kind==="multi"){const a=Array.isArray(n)?n:[],d=[...a,...(s.options??[]).map(t=>t.value).filter(t=>!a.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:d.map(t=>{const r=(s.options??[]).find(c=>c.value===t);return r?e.jsx(z,{label:r.label,checked:a.includes(t),onChange:c=>o(c?[...a,t]:a.filter(l=>l!==t))},t):null})})]})}if(s.kind==="text")return e.jsx(D,{label:s.name,hint:s.description,children:e.jsx("input",{type:"text",value:String(n??""),maxLength:s.maxLength,placeholder:"Generated from their name",onChange:a=>o(a.target.value.toLocaleUpperCase("en-NZ"))})});const i=Array.isArray(n)?n:[];return e.jsx(D,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` +`),onChange:a=>o(a.target.value.split(` +`).map(d=>d.trim()).filter(Boolean))})})}function xn({initial:s,busy:n,onConfirm:o,onCancel:i}){const[a,d]=x.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(D,{label:"Device name",children:e.jsx("input",{type:"text",value:a,autoFocus:!0,maxLength:80,onChange:t=>d(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:!a.trim(),onClick:()=>o(a.trim()),children:"Rename"})]})]})})}function jn({pending:s,busy:n,username:o,onConfirm:i,onCancel:a}){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:a})}function vn(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const i=(s.options??[]).find(a=>a.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 a;return((a=(s.options??[]).find(d=>d.value===i))==null?void 0:a.label)??i}).join(", ")}function bn(){const{userId:s=""}=$e(),{wrap:n}=Y(),{busy:o,run:i}=Q(),a=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:d,error:t,loading:r,reload:c}=J(`${a}/preferences/history`,{pollMs:3e4}),[l,g]=x.useState(new Set),[v,u]=x.useState(null),m=(d==null?void 0:d.username)||"this account",f=(d==null?void 0:d.devices)??[],h=(d==null?void 0:d.revisions)??[],j=(d==null?void 0:d.catalogue)??[],b=p=>g(y=>{const E=new Set(y);return E.has(p)?E.delete(p):E.add(p),E}),k=p=>i("restore",async()=>{await n(()=>q.post(`${a}/preferences/revisions/${p}/restore`),`Restored r${p}.`),u(null),await c()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(ie,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(U,{message:t}),r?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(R,{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:d!=null&&d.saved?e.jsxs(C,{tone:d.currentSource==="admin"?"warn":"ok",children:["now on r",w(d.currentRevision)," · ",d.currentSource||"device"]}):e.jsx(C,{children:"defaults · never synced"}),children:f.length===0?e.jsx(Z,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(p=>{const y=p.never?void 0:p.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})," ",p.name||"Memby TV"," ",p.signedOut?e.jsx(C,{children:"signed out"}):null]}),e.jsxs("p",{children:[p.never?"Has not fetched these settings yet":`Holding r${w(p.revision)} · taken ${P(p.ackedAt)}`,p.clientVersion?` · Memby ${p.clientVersion}`:"",p.signedOut?"":` · last seen ${P(p.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:p.never?e.jsx(C,{children:"never taken one"}):p.behind?e.jsxs(C,{tone:"bad",children:[w(p.behind)," behind"]}):e.jsx(C,{tone:"ok",children:"up to date"})})]},p.deviceId||p.name)})})}),e.jsx(R,{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(G,{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:h.length===0?e.jsx(se,{columns:6,children:"Nothing has been changed on this account yet."}):h.flatMap(p=>{const y=l.has(p.revision),E=p.acks??[],N=p.changes??[],B=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(p.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",w(p.revision)," ",p.current?e.jsx(C,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(C,{tone:p.source==="admin"?"warn":"ok",children:p.author}),p.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",w(p.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:p.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):N.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:N.map((S,L)=>e.jsxs(te,{children:[S.name,": ",S.before," → ",S.after]},`${S.name}:${L}`))})}),e.jsx("td",{className:"num",children:E.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:E.map(S=>S.deviceName||S.deviceId).join(", "),children:w(E.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(A,{size:"sm",onClick:()=>b(p.revision),children:y?"Hide":"Show"}),p.current?null:e.jsx(A,{size:"sm",onClick:()=>u(p.revision),children:"Restore"})]})})]},p.revision)];return y&&B.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:j.map(S=>{var L;return e.jsxs(te,{children:[S.name,":"," ",vn(S,(L=p.preferences)==null?void 0:L[S.key])]},S.key)})})})},`${p.revision}:detail`)),B})})]})})})]}),v!==null?e.jsx(he,{title:`Restore revision ${v}?`,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 k(v),onCancel:()=>u(null)}):null]})}function gn({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(te,{tone:o.version===s.version?"ok":void 0,children:o.version},o.version))})}function fn(){const{status:s,error:n,loading:o}=ae(),i=(s==null?void 0:s.clients)??[],a=i.filter(t=>(t.capabilities??[]).includes("server_features_v1")),d=new Set(i.map(t=>t.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(U,{message:n}),o?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"devices known",value:w(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(i.filter(t=>Ne(t.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:w(a.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:w(d.size),icon:"download",tone:"note"}]}),e.jsx(R,{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(G,{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(se,{columns:6,children:"No devices have signed in yet."}):i.map(t=>{const r=(t.capabilities??[]).includes("server_features_v1"),c=_e(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":c.tone,title:c.label}),e.jsx(ie,{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(gn,{client:t})}),e.jsx("td",{children:e.jsx(C,{tone:r?"ok":"warn",children:r?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:P(t.lastSeen)})]},`${t.deviceId}:${t.username}`)})})]})})})]})]})}const Qe={user:"",q:"",ip:"",outcome:"",from:"",to:""},yn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function wn(){var p,y,E,N,B,S;const[s,n]=x.useState("log"),[o,i]=x.useState(7),[a,d]=x.useState(Qe),[t,r]=x.useState(0),c=100,l=x.useMemo(()=>Se({...a,days:a.from?void 0:o||void 0,limit:c,offset:t*c}),[a,o,t]),g=J(`/admin/api/logins${l}`,{enabled:s==="log"}),v=J(`/admin/api/logins/devices${l}`,{enabled:s==="devices"}),u=((p=g.data)==null?void 0:p.users)??((y=v.data)==null?void 0:y.users)??[],m=((E=g.data)==null?void 0:E.totals)??((N=v.data)==null?void 0:N.totals),f=((B=g.data)==null?void 0:B.retentionDays)??((S=v.data)==null?void 0:S.retentionDays)??90,h=s==="log"?g.loading:v.loading,j=s==="log"?g.error:v.error,b=L=>{d(K=>({...K,...L})),r(0)},k=Object.entries(a).some(([,L])=>L!=="")||!!a.from;return e.jsxs(e.Fragment,{children:[e.jsx(O,{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(Ie,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(U,{message:j}),m?e.jsx(re,{tiles:[{label:"Successful sign-ins",value:w(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:w(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:w(m.devices),icon:"tv",tone:"info"},{label:"People",value:w(m.users),icon:"people",tone:"note"},{label:"Addresses",value:w(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(D,{label:"Window",children:e.jsx(Ie,{value:a.from?-1:o,options:yn.map(L=>({value:L.value,label:L.label})),onChange:L=>{i(L),b({from:"",to:""})}})}),e.jsx(D,{label:"Person",children:e.jsxs("select",{value:a.user,onChange:L=>b({user:L.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),u.map(L=>e.jsx("option",{value:L.id,children:L.username||L.id},L.id))]})}),e.jsx(D,{label:"Outcome",children:e.jsxs("select",{value:a.outcome,onChange:L=>b({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(D,{label:"Address",children:e.jsx("input",{type:"text",value:a.ip,placeholder:"10.0.0.4",onChange:L=>b({ip:L.target.value})})}),e.jsx(D,{label:"From",children:e.jsx("input",{type:"date",value:a.from,onChange:L=>b({from:L.target.value})})}),e.jsx(D,{label:"To",children:e.jsx("input",{type:"date",value:a.to,onChange:L=>b({to:L.target.value})})}),e.jsx(D,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:a.q,placeholder:"Name, device or address",onChange:L=>b({q:L.target.value})})}),e.jsx("div",{className:"filter-actions",children:k?e.jsx(A,{variant:"quiet",size:"sm",onClick:()=>{d(Qe),r(0)},children:"Clear"}):null})]}),h?e.jsx(W,{}):s==="log"?e.jsx(kn,{data:g.data,page:t,limit:c,onPage:r}):e.jsx(Nn,{data:v.data})]})}function kn({data:s,page:n,limit:o,onPage:i}){if(!s)return null;const a=s.events.length,d=s.total===0?0:n*o+1;return e.jsxs(e.Fragment,{children:[e.jsxs(oe,{cols:"wide",children:[e.jsx(R,{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(Cs,{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(R,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(Z,{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:[w(t.logins)," in",t.failures>0?` · ${w(t.failures)} refused`:""]})]}),t.failures>0&&t.logins===0?e.jsx(C,{tone:"bad",children:"only refused"}):null]},t.ipAddress))})})]}),e.jsx(R,{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":`${w(d)}–${w(d+a-1)} of ${w(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(G,{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(se,{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:P(t.occurredAt)}),e.jsx("td",{children:t.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:t.deviceId?e.jsx(ie,{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(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:t.failureReason||"refused"})})]},t.id))})]})})})]})}function Nn({data:s}){return s?e.jsx(R,{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(G,{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(se,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(ie,{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?w(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:w(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:w(n.failures)}):"—"}),e.jsx("td",{className:"num",children:w(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?P(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const Sn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function Cn(){const{deviceId:s=""}=$e(),[n,o]=x.useState(7),i=x.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:a,error:d,loading:t}=J(i,{enabled:!!s}),r=a==null?void 0:a.summary,c=(r==null?void 0:r.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:c,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(ie,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(ie,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:c})]}),actions:e.jsx(Ie,{value:n,options:Sn.map(l=>({value:l.value,label:l.label})),onChange:o})}),e.jsx(U,{message:d}),t?e.jsx(W,{}):a?e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Sign-ins today",value:w((r==null?void 0:r.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:w((r==null?void 0:r.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:w((r==null?void 0:r.failures)??0),icon:"shield",tone:((r==null?void 0:r.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:w((r==null?void 0:r.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:r!=null&&r.firstLogin?P(r.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:r!=null&&r.lastLogin?P(r.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(oe,{cols:"wide",children:[e.jsx(R,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(Cs,{data:a.days,labelOf:l=>l.day,valueOf:l=>l.logins+l.failures,toneOf:l=>l.failures>l.logins?"bad":void 0,title:l=>`${l.day}: ${l.logins} in${l.failures?`, ${l.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(R,{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:(r==null?void 0:r.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:a.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:(r==null?void 0:r.clientVersion)||"unknown"})]})})]})}),e.jsx(R,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:a.versions.length===0?e.jsx(Z,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:a.versions.map(l=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:l.version}),e.jsxs("p",{children:[P(l.firstSeen)," → ",P(l.lastSeen)]})]})},l.version))})})]})]}),e.jsx(R,{title:"Addresses",icon:"globe",tone:"data",children:a.addresses.length===0?e.jsx(Z,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:a.addresses.map(l=>e.jsxs(te,{tone:l.failures>0?"warn":"data",children:[l.ipAddress," · ",w(l.logins),l.failures>0?` (+${w(l.failures)} refused)`:""]},l.ipAddress))})}),e.jsx(R,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[w(a.total)," in this window"]}),children:e.jsx(G,{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:a.events.length===0?e.jsx(se,{columns:6,children:"This television has not connected in the selected window."}):a.events.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(l.occurredAt)}),e.jsx("td",{children:l.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:l.ipAddress||"—"}),e.jsx("td",{className:"mono",children:l.clientVersion||"—"}),e.jsx("td",{className:"muted",children:l.method}),e.jsx("td",{className:"nowrap",children:l.success?l.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:l.failureReason||"refused"})})]},l.id))})]})})})]}):null]})}function Mn(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(!1),l=(s==null?void 0:s.library.byType)??{},g=!!(s!=null&&s.syncRunning),v=u=>t(u,async()=>{await a(()=>q.post("/admin/api/sync",{kind:u}),u==="full"?"Full re-import started.":"Import started."),c(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(U,{message:n}),o||!s?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"items",value:w(s.library.total),icon:"library",tone:"data"},...Object.keys(l).sort().map(u=>({label:u,value:w(l[u]),icon:"list"})),{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(R,{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:g?"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:g,busy:d==="incremental",onClick:()=>void v("incremental"),children:"Sync new items"}),e.jsx(A,{icon:"database",disabled:g,onClick:()=>c(!0),children:"Full re-import"})]})})]}),r?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:d==="full",onConfirm:()=>void v("full"),onCancel:()=>c(!1)}):null]})}const En={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 An(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(!1),[l,g]=x.useState(""),[v,u]=x.useState(!1),[m,f]=x.useState([]),[h,j]=x.useState(!1),b=s==null?void 0:s.mdblist;x.useEffect(()=>{h||!b||(c(b.enabled),f(b.sources??[]))},[b,h]);const k=()=>t("save",async()=>{await a(()=>q.post("/admin/api/mdblist-settings",{enabled:r,apiKey:l.trim(),clearApiKey:v,sources:m}),"Ratings settings saved."),g(""),u(!1),j(!1),await i()}),p=(b==null?void 0:b.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(U,{message:n}),o||!b?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"titles stored",value:w(p),icon:"database",tone:"data"},{label:"due to be re-checked",value:w(b.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:w((b.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:b.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:b.apiKeyConfigured?"ok":void 0}]}),e.jsxs(oe,{cols:"2",children:[e.jsxs(R,{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:r?e.jsxs(C,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(C,{children:b.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(z,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:r,onChange:y=>{c(y),j(!0)}}),e.jsx(D,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l,placeholder:b.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>g(y.target.value)})}),e.jsx(z,{label:"Remove the saved key",checked:v,onChange:u})]}),e.jsx(R,{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:(b.availableSources??[]).length===0?e.jsx(Z,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(b.availableSources??[]).map(y=>e.jsx(z,{label:En[y]??y,checked:m.includes(y),onChange:E=>{j(!0),f(N=>E?[...N,y]:N.filter(B=>B!==y))}},y))})})]}),e.jsx(R,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>void k(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:p?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function Rn(){var v;const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),r=(s==null?void 0:s.requestUsers)??[],c=((v=s==null?void 0:s.requestPolicy)==null?void 0:v.allowedUserIds)??[],l=x.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(u=>[u.userId,u])),[s==null?void 0:s.requestUsage]),g=u=>t(`access-${u}`,async()=>{const m=c.includes(u)?c.filter(f=>f!==u):[...c,u];await a(()=>q.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(O,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(R,{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(Z,{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(R,{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:r.length===0?e.jsx(Z,{children:"No one has signed in yet."}):e.jsx(G,{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:r.map(u=>{const m=l.get(u.id),f=c.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:P(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?P(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx(A,{size:"sm",variant:f?"quiet":"primary",busy:d===`access-${u.id}`,onClick:()=>void g(u.id),children:f?"Remove access":"Give access"})})]},u.id)})})]})})})]})]})}function In(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(!1),l=s==null?void 0:s.forYou,g=!!(s!=null&&s.forYouRunning),v=(u,m,f)=>t(m,async()=>{await a(()=>q.post("/admin/api/for-you",{action:u}),f),c(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tracearr sessions",value:w((l==null?void 0:l.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:w((l==null?void 0:l.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:w((l==null?void 0:l.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:P(l==null?void 0:l.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(R,{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:g?"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:g,busy:d==="import",onClick:()=>void v("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(A,{icon:"database",disabled:g,onClick:()=>c(!0),children:"Full Tracearr backfill"}),e.jsx(A,{icon:"sync",disabled:g,busy:d==="rebuild",onClick:()=>void v("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(R,{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(ie,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),r?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:d==="full",onConfirm:()=>void v("full-import","full","Backfill started."),onCancel:()=>c(!1)}):null]})}const $n=[["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 Tn(s){return s?$n.flatMap(([n,o])=>Object.entries(s[o]??{}).map(([i,a])=>({dimension:n,name:i,weight:a.weight??0,evidence:a.evidence??0}))).sort((n,o)=>Math.abs(o.weight)-Math.abs(n.weight)):[]}const Ye=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function Ln(){const{status:s}=ae(),{wrap:n}=Y(),{busy:o,run:i}=Q(),[a,d]=x.useState(""),[t,r]=x.useState("default"),[c,l]=x.useState("0"),[g,v]=x.useState(""),[u,m]=x.useState(null),[f,h]=x.useState("Choose a person to inspect their recommendations."),[j,b]=x.useState(""),k=(s==null?void 0:s.requestUsers)??[],p=()=>i("run",async()=>{if(!a){b("Choose a person to pressure-test.");return}b(""),h("Running the permission check and the scorer…");const S=new URLSearchParams({userId:a,context:t,minutes:c||"0",limit:"100"});g&&S.set("at",new Date(g).toISOString());const L=await n(()=>q.get(`/admin/api/recommendations?${S.toString()}`));L?(m(L),h(`Scored at ${new Date().toLocaleTimeString()}.`)):h("Pressure test failed.")}),y=Tn((u==null?void 0:u.profile)??null).slice(0,24),E=(u==null?void 0:u.actions)??[],N=(u==null?void 0:u.items)??[],B=(u==null?void 0:u.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(U,{message:j}),e.jsx(R,{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 p(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Person",children:e.jsxs("select",{value:a,onChange:S=>d(S.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),k.map(S=>e.jsx("option",{value:S.id,children:S.username},S.id))]})}),e.jsx(D,{label:"Context",children:e.jsxs("select",{value:t,onChange:S=>r(S.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(D,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:c,onChange:S=>l(S.target.value)})}),e.jsx(D,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:g,onChange:S=>v(S.target.value)})})]})}),u?e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"prepared pool",value:w(u.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:w(u.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:w(N.length),icon:"sparkle",tone:"note"},{label:"source events",value:w(B.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:B.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:P(B.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(R,{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(Z,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(S=>e.jsxs(te,{tone:S.weight<0?"bad":void 0,children:[S.dimension,": ",S.name," ",Ye(S.weight)," · n=",w(S.evidence)]},`${S.dimension}:${S.name}`))}),E.length===0?e.jsx(Z,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:E.map((S,L)=>e.jsxs(te,{tone:"ok",children:[S.action,": ",S.title||S.itemId]},`${S.action}:${L}`))})]}),N.length===0?e.jsx(R,{children:e.jsx(Z,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(oe,{children:N.map((S,L)=>{const K=S.explanation??{},ne=Object.entries(K.components??{}).sort((I,H)=>Math.abs(H[1])-Math.abs(I[1])),X=S.exposure??{},$=[S.type,S.year,S.runtimeMinutes?`${S.runtimeMinutes} min`:null,...S.genres??[]].filter(Boolean).join(" · ");return e.jsxs(R,{title:`#${L+1} · ${S.title}`,intro:$,actions:e.jsx(te,{tone:"ok",children:Number(K.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[S.preparedReason||"No legacy prepared explanation",S.compatibilityLabel?` · ${S.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(K.reasonCodes??[]).map(I=>e.jsx(te,{tone:"ok",children:I},I)),ne.map(([I,H])=>e.jsxs(te,{tone:H<0?"bad":void 0,children:[I,"=",Ye(H)]},I))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",w(S.baseRank??0)," · base ",Number(S.baseScore??0).toFixed(3)," · affinity ",Number(S.affinityScore??0).toFixed(3)," · compatibility"," ",Number(S.compatibilityScore??0).toFixed(3)," · impressions"," ",w(X.impressions??0)," · focuses ",w(X.focuses??0)," · selects"," ",w(X.selects??0)]}),e.jsx("div",{className:"chips",children:(S.eligibleRows??[]).map(I=>e.jsx(te,{tone:"ok",children:I},I))}),S.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",S.preparedEvidenceTitle]}):null]})]},`${L}:${S.title}`)})})]}):null]})}const Dn=4,je=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],fe=()=>({pinnedItems:[],primeSubtitle:""}),Ue=[{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 Xe(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function es(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function qn(s){return s.frequency??"once"}function Fn(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=Ue.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 ss(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),a=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:[a?n:"home"]}}function Pn(){var $,I,H;const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a,show:d}=Y(),{busy:t,run:r}=Q(),[c,l]=x.useState("home"),[g,v]=x.useState({home:fe(),movies:fe(),tv_shows:fe()}),[u,m]=x.useState(!1),[f,h]=x.useState(""),[j,b]=x.useState(null),[k,p]=x.useState([]),[y,E]=x.useState(null),N=s==null?void 0:s.heroPolicy;x.useEffect(()=>{var M,F,T;u||!N||(v({home:((M=N.placements)==null?void 0:M.home)??{pinnedItems:N.pinnedItems??[],primeSubtitle:N.primeSubtitle??""},movies:((F=N.placements)==null?void 0:F.movies)??fe(),tv_shows:((T=N.placements)==null?void 0:T.tv_shows)??fe()}),p(N.schedules??[]))},[N,u]);const B=()=>r("search",async()=>{const M=f.trim();if(!M)return;const F=await a(()=>q.get(`/admin/api/hero/search?q=${encodeURIComponent(M)}`));F&&b(F.items??[])}),S=g[c],L=S.pinnedItems??[],K=M=>{v(F=>({...F,[c]:{...F[c],...M}})),m(!0)},ne=M=>{if(!L.some(F=>F.id===M.id)){if(L.length>=Dn){d("Remove a pinned title before adding another.","bad");return}K({pinnedItems:[...L,M]})}},X=()=>r("save",async()=>{await a(()=>q.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(g).map(([M,F])=>[M,{pinnedItemIds:(F.pinnedItems??[]).map(T=>T.id),primeSubtitle:F.primeSubtitle.trim()}])),schedules:k}),"Hero saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(oe,{children:je.map(M=>{var le;const F=g[M.id],T=k.filter(me=>me.enabled&&(me.placements??["home"]).includes(M.id)).sort((me,Is)=>Is.priority-me.priority)[0],_=(F.pinnedItems??[]).length?"Manual":T?"Schedule ready":"Automatic",ce=(le=N==null?void 0:N.items)==null?void 0:le.find(me=>me.id===(T==null?void 0:T.itemId)),de=(F.pinnedItems??[]).map(me=>me.name).join(", ")||(ce==null?void 0:ce.name)||(T==null?void 0:T.itemId)||"Resolved for each viewer";return e.jsx(R,{title:M.label,intro:`${_} · ${de}`,tone:M.id===c?"info":void 0,children:e.jsxs(A,{size:"sm",variant:"quiet",onClick:()=>l(M.id),children:["Manage ",M.label]})},M.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:je.map(M=>e.jsx(A,{variant:c===M.id?"primary":"quiet",onClick:()=>l(M.id),children:M.label},M.id))}),e.jsxs(R,{title:`${($=je.find(M=>M.id===c))==null?void 0:$.label} hero`,intro:`Pinned ${(I=je.find(M=>M.id===c))==null?void 0:I.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 X(),children:"Save hero"}),e.jsx(A,{onClick:()=>{K({pinnedItems:[]})},children:"Clear pins"}),u?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[L.length===0?e.jsx(Z,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:L.map((M,F)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:F+1}),e.jsxs("span",{children:[e.jsx("b",{children:M.name}),e.jsxs("small",{children:[M.type,M.year?` · ${M.year}`:""]})]}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>E(ss(M,c)),children:"Schedule"}),e.jsx(A,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${M.name}`,onClick:()=>K({pinnedItems:L.filter(T=>T.id!==M.id)})})]},M.id))}),e.jsx(D,{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:S.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:M=>{K({primeSubtitle:M.target.value})}})})]}),e.jsx(R,{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:(N==null?void 0:N.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void X(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:k.length===0?e.jsx(Z,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...k].sort((M,F)=>Number(F.enabled)-Number(M.enabled)||F.priority-M.priority).map(M=>{const F=[...(N==null?void 0:N.items)??[],...L,...j??[]].find(T=>T.id===M.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":M.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:M.frequency==="weekly"?"Weekly":M.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:M.frequency?M.startTime:M.startAt?new Date(M.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(F==null?void 0:F.name)??M.itemId}),e.jsx(C,{tone:M.enabled?"ok":void 0,children:M.enabled?"enabled":"paused"})]}),e.jsx("p",{children:Fn(M)}),e.jsxs("div",{className:"chips",children:[(M.placements??["home"]).map(T=>{var _;return e.jsx("span",{className:"chip",children:(_=je.find(ce=>ce.id===T))==null?void 0:_.label},T)}),M.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",M.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(A,{size:"sm",onClick:()=>E({...M}),children:"Edit"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{p(T=>T.map(_=>_.id===M.id?{..._,enabled:!_.enabled}:_)),m(!0)},children:M.enabled?"Pause":"Enable"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{p(T=>T.filter(_=>_.id!==M.id)),m(!0)},children:"Remove"})]})]},M.id)})})}),e.jsxs(R,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(H=je.find(M=>M.id===c))==null?void 0:H.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(D,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:M=>h(M.target.value),onKeyDown:M=>{M.key==="Enter"&&B()}})}),e.jsx(A,{busy:t==="search",icon:"search",onClick:()=>void B(),children:"Search"})]}),j===null?null:j.length===0?e.jsx(Z,{children:"No playable films or series matched that search."}):e.jsx(oe,{children:j.map(M=>e.jsx(R,{title:M.name,intro:`${M.type||"Title"} · ${M.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(A,{size:"sm",icon:"plus",disabled:L.some(F=>F.id===M.id)||c==="movies"&&M.type!=="Movie"||c==="tv_shows"&&M.type!=="Series",onClick:()=>ne(M),children:L.some(F=>F.id===M.id)?"Pinned":"Add to hero"}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>E(ss(M,c)),children:"Schedule"})]})},M.id))})]})]}),y?e.jsx(On,{schedule:y,item:[...(N==null?void 0:N.items)??[],...L,...j??[]].find(M=>M.id===y.itemId),timeZone:(N==null?void 0:N.timeZone)||"server local time",isNew:!k.some(M=>M.id===y.id),onCancel:()=>E(null),onSave:M=>{p(F=>F.some(T=>T.id===M.id)?F.map(T=>T.id===M.id?M:T):[...F,M]),E(null),m(!0)}}):null]})}function On({schedule:s,item:n,timeZone:o,isNew:i,onSave:a,onCancel:d}){const[t,r]=x.useState({...s,weekdays:[...s.weekdays??[]]}),c=qn(t),l=h=>{const j=new Date,b=new Date(j.getTime()+2*60*60*1e3);r(k=>{var p;return h==="once"?{...k,frequency:void 0,startAt:es(k.startAt)?k.startAt:j.toISOString(),endAt:es(k.endAt)?k.endAt:b.toISOString()}:{...k,frequency:h,startTime:k.startTime||"18:00",endTime:k.endTime||"22:00",weekdays:h==="weekly"?(p=k.weekdays)!=null&&p.length?k.weekdays:[1,2,3,4,5]:[]}})},g=t.placements??["home"],v=h=>h==="home"||h==="movies"&&(n==null?void 0:n.type)==="Movie"||h==="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&&(c!=="weekly"||(t.weekdays??[]).length>0)),f=c==="once"?u:m;return e.jsx("div",{className:"scrim",onPointerDown:h=>h.target===h.currentTarget&&d(),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(h=>e.jsxs("button",{type:"button","aria-pressed":c===h,onClick:()=>l(h),children:[e.jsx("b",{children:h==="once"?"One time":h==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:h==="once"?"A date range":h==="daily"?"Same time daily":"Choose days"})]},h))}),c==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:t.startAt?Xe(new Date(t.startAt)):"",onChange:h=>r(j=>({...j,startAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})}),e.jsx(D,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:t.endAt?Xe(new Date(t.endAt)):"",onChange:h=>r(j=>({...j,endAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Starts each time",children:e.jsx("input",{type:"time",value:t.startTime??"",onChange:h=>r(j=>({...j,startTime:h.target.value}))})}),e.jsx(D,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:t.endTime??"",onChange:h=>r(j=>({...j,endTime:h.target.value}))})})]}),c==="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:()=>r(h=>({...h,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>r(h=>({...h,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>r(h=>({...h,weekdays:Ue.map(j=>j.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:Ue.map(h=>{const j=(t.weekdays??[]).includes(h.value);return e.jsx("button",{type:"button","aria-pressed":j,title:h.label,onClick:()=>r(b=>({...b,weekdays:j?(b.weekdays??[]).filter(k=>k!==h.value):[...b.weekdays??[],h.value]})),children:h.short},h.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:je.map(h=>e.jsx(z,{label:h.label,checked:g.includes(h.id),disabled:!v(h.id),onChange:j=>r(b=>{const k=b.placements??["home"],p=j?[...k,h.id]:k.filter(y=>y!==h.id);return{...b,placements:p.length?[...new Set(p)]:k}})},h.id))})]}),e.jsx(D,{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:h=>r(j=>({...j,priority:Number(h.target.value)}))})})]}),e.jsx(z,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:t.enabled,onChange:h=>r(j=>({...j,enabled:h}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:d,children:"Cancel"}),e.jsx(A,{variant:"primary",disabled:!f,onClick:()=>a(t),children:i?"Add rule":"Save rule"})]})]})})}function Un(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(null),l=s==null?void 0:s.features,g=(l==null?void 0:l.features)??[],v=(s==null?void 0:s.clients)??[],u=(l==null?void 0:l.revision)??0,m=(b,k,p,y)=>t(k,async()=>{await a(()=>q.post("/admin/api/features",{action:b,expectedRevision:u,overrides:y??{}}),p),c(null),await i()}),f=v.filter(b=>(b.capabilities??[]).includes("server_features_v1")).length,h=!!(l!=null&&l.safeMode),j=(b,k)=>({...Object.fromEntries(g.filter(p=>p.source==="override").map(p=>[p.key,p.enabled])),[b]:k});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(U,{message:n}),o||!l?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(R,{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:h?void 0:"danger",busy:d==="safe",onClick:()=>h?void m("leave-safe-mode","safe","Safe mode ended."):c({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:h?"Leave safe mode":"Enable safe mode"}),children:e.jsx(Ms,{tiles:[{label:"features active",value:`${g.filter(b=>b.enabled).length} / ${g.length}`},{label:"explicit overrides",value:w(g.filter(b=>b.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${v.length}`},{label:"published revision",value:`r${w(u)}`}]})}),e.jsx(oe,{cols:"2",children:g.length===0?e.jsx(R,{title:"Nothing registered",icon:"sliders",children:e.jsx(Z,{children:"No server features are registered."})}):g.map(b=>e.jsxs(R,{title:b.name,intro:b.description,actions:e.jsx(C,{tone:b.enabled?"ok":void 0,children:b.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",b.recovery]}),children:[e.jsx(z,{label:b.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:b.enabled,disabled:d===b.key,onChange:k=>c({action:"feature",key:b.key,enabled:k,title:`${k?"Turn on":"Turn off"} ${b.name}?`,body:`${k?"Enable":"Disable"} this feature for every compatible television. ${b.recovery}`,label:k?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(te,{children:b.key}),e.jsxs(te,{children:["protocol ",w(b.minimumProtocol),"+"]}),e.jsx(te,{tone:b.compatible?"ok":"warn",children:b.compatible?"server compatible":"compatibility blocked"}),e.jsx(te,{tone:"note",children:b.area})]})]},b.key))}),e.jsx(R,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{disabled:!l.canRollback,onClick:()=>c({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:()=>c({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"}),h?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["live · revision r",w(u)]})]})})]}),r?e.jsx(he,{title:r.title,body:r.body,confirmLabel:r.label,destructive:r.action!=="rollback",busy:d===r.action,onConfirm:()=>void m(r.action==="feature"?"save":r.action,r.action==="feature"?r.key??"feature":r.action,`${r.label} done.`,r.action==="feature"&&r.key?j(r.key,!!r.enabled):void 0),onCancel:()=>c(null)}):null]})}function Vn(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a,show:d}=Y(),{busy:t,run:r}=Q(),[c,l]=x.useState(!0),[g,v]=x.useState("6.5"),[u,m]=x.useState(!1);x.useEffect(()=>{var h,j;u||!s||(l(((h=s.playbackPolicy)==null?void 0:h.prerollEnabled)!==!1),v(String((((j=s.playbackPolicy)==null?void 0:j.prerollDurationMs)??6500)/1e3)))},[s,u]);const f=()=>r("save",async()=>{const h=Number(g);if(!Number.isFinite(h)||h<1||h>30){d("The preroll duration must be between 1 and 30 seconds.","bad");return}await a(()=>q.post("/admin/api/playback-policy",{prerollEnabled:c,prerollDurationMs:Math.round(h*1e3)}),"Playback policy saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:1}):e.jsxs(R,{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:c?e.jsxs(C,{tone:"ok",children:["on · ",g,"s"]}):e.jsx(C,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(z,{label:"Show the preroll before a title starts",checked:c,onChange:h=>{l(h),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(D,{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:g,onChange:h=>{v(h.target.value),m(!0)}})})})]})]})}function Bn(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(null),[l,g]=x.useState(null),[v,u]=x.useState(!1),m=s==null?void 0:s.subtitles,f=m==null?void 0:m.stored;x.useEffect(()=>{r||!m||c({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,r]);const h=p=>c(y=>y&&{...y,...p}),j=()=>t("save",async()=>{r&&(await a(()=>q.post("/admin/api/subtitle-settings",{bazarrEnabled:r.bazarr,openSubtitlesEnabled:r.openSubtitles,openSubtitlesApiKey:r.key.trim(),clearOpenSubtitlesApiKey:r.clearKey,openSubtitlesUsername:r.username.trim(),openSubtitlesPassword:r.password,clearOpenSubtitlesLogin:r.clearLogin}),"Subtitle settings saved."),c(null),await i())}),b=()=>t("test",async()=>{g(null);const p=await a(()=>q.post("/admin/api/subtitle-test"));g((p==null?void 0:p.results)??[])}),k=()=>t("clear",async()=>{await a(()=>q.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),u(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(U,{message:n}),o||!m||!r?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:w((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:w((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:P(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx(R,{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?r.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(z,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:r.bazarr,disabled:!m.bazarrConfigured,onChange:p=>h({bazarr:p})})}),e.jsxs(R,{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(z,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:r.openSubtitles,onChange:p=>h({openSubtitles:p})}),e.jsx(D,{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:r.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:p=>h({key:p.target.value})})}),e.jsx(z,{label:"Remove the saved key",checked:r.clearKey,onChange:p=>h({clearKey:p})}),e.jsxs("div",{className:"fields",children:[e.jsx(D,{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:r.username,placeholder:"Not signed in",onChange:p=>h({username:p.target.value})})}),e.jsx(D,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:r.password,onChange:p=>h({password:p.target.value})})})]}),e.jsx(z,{label:"Sign out and forget the account",checked:r.clearLogin,onChange:p=>h({clearLogin:p})})]})]}),e.jsxs(R,{children:[e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>void j(),children:"Save subtitle settings"}),e.jsx(A,{busy:d==="test",icon:"pulse",onClick:()=>void b(),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."})]}),l===null?null:l.length===0?e.jsx(Z,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:l.map(p=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:p.provider}),e.jsx("p",{children:p.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(C,{tone:p.ok?"ok":"bad",children:p.ok?"reachable":"not reachable"})})]},p.provider))})]}),e.jsx(R,{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:f!=null&&f.count?e.jsxs(C,{tone:"data",children:[w(f.count)," files · ",ye(f.bytes)]}):e.jsx(C,{children:"nothing held"}),footer:e.jsx(A,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>u(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),v?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:d==="clear",onConfirm:()=>void k(),onCancel:()=>u(!1)}):null]})}function _n(){const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(null),[l,g]=x.useState(!1),v=s==null?void 0:s.updatePolicy;x.useEffect(()=>{r||!v||c({version:v.latestVersion??"",url:v.downloadUrl??"",notes:v.notes??"",retireBelow:v.retireBelowVersion??"",required:!!v.minimumVersion&&v.minimumVersion===v.latestVersion,destructive:!!v.retireBelowVersion&&v.retireBelowVersion===v.latestVersion})},[v,r]);const u=j=>t(j?"save":"off",async()=>{r&&(await a(()=>q.post("/admin/api/update-policy",{enabled:j,latestVersion:r.version.trim(),downloadUrl:r.url.trim(),notes:r.notes.trim(),required:r.required,destructive:r.destructive,retireBelowVersion:r.retireBelow.trim()}),j?"Update policy saved.":"Update prompts turned off."),g(!1),c(null),await i())}),m=!!(v!=null&&v.minimumVersion)&&(v==null?void 0:v.minimumVersion)===(v==null?void 0:v.latestVersion),f=!!(v!=null&&v.retireBelowVersion)&&(v==null?void 0:v.retireBelowVersion)===(v==null?void 0:v.latestVersion),h=j=>c(b=>b&&{...b,...j});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(U,{message:n}),o||!r?e.jsx(W,{rows:1}):e.jsxs(R,{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:v!=null&&v.enabled?e.jsxs(C,{tone:m?"warn":"ok",children:[f?"sign-out · ":m?"required · ":"optional · ",v.latestVersion]}):e.jsx(C,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>r.required?g(!0):void u(!0),children:"Save policy"}),e.jsx(A,{busy:d==="off",onClick:()=>void u(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Latest version",children:e.jsx("input",{type:"text",value:r.version,placeholder:"0.2.63",onChange:j=>h({version:j.target.value})})}),e.jsx(D,{label:"APK URL",children:e.jsx("input",{type:"text",value:r.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:j=>h({url:j.target.value})})})]}),e.jsx(D,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:r.notes,placeholder:"One line the viewer reads",onChange:j=>h({notes:j.target.value})})}),e.jsx(D,{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:r.retireBelow,placeholder:"0.2.44",onChange:j=>h({retireBelow:j.target.value})})}),e.jsx(z,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:r.required,onChange:j=>h({required:j})}),e.jsx(z,{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:r.destructive,onChange:j=>h(j?{destructive:!0,required:!0,retireBelow:r.version.trim()}:{destructive:!1,retireBelow:r.retireBelow.trim()===r.version.trim()?"":r.retireBelow})})]}),l&&r?e.jsx(he,{title:r.destructive?"Sign every older television out?":"Require this update?",body:r.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:r.destructive,busy:d==="save",onConfirm:()=>void u(!0),onCancel:()=>g(!1)}):null]})}const Wn=2e4,zn=3e3,Hn=[{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 Kn(s){const n=[...Hn];for(const o of[s.defaultIntervalSeconds,s.intervalSeconds])o>0&&!n.some(i=>i.value===o)&&n.push({value:o,label:Pe(o).replace(/^every /,"Every ")});return n.sort((o,i)=>o.value-i.value)}function ns(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Gn(){const{wrap:s}=Y(),{busy:n,run:o}=Q(),[i,a]=x.useState(!1),{data:d,error:t,loading:r,reload:c}=J("/admin/api/tasks?limit=60",{pollMs:i?zn:Wn}),l=(d==null?void 0:d.tasks)??[],g=l.some(y=>y.running);g!==i&&a(g);const v=y=>o(y.id,async()=>{await s(()=>q.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await c()}),u=(y,E)=>o(`${y.id}:enabled`,async()=>{await s(()=>q.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{enabled:E}),E?`${y.name} switched on.`:`${y.name} switched off.`),await c()}),m=(y,E)=>o(`${y.id}:interval`,async()=>{await s(()=>q.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:E}),`${y.name} now runs ${Pe(E)}.`),await c()}),f=y=>o(`${y.id}:interval`,async()=>{await s(()=>q.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:0}),`${y.name} back to its default cadence.`),await c()}),h=l.filter(y=>{var E;return((E=y.lastRun)==null?void 0:E.status)==="failed"}).length,j=l.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,b=l.filter(y=>!y.enabled).length,k=(d==null?void 0:d.groups)??[],p=l.filter(y=>!y.group);return e.jsxs(e.Fragment,{children:[e.jsx(O,{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(U,{message:t}),r?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tasks",value:w(l.length),icon:"clock",tone:"info"},{label:"Running now",value:w(l.filter(y=>y.running).length),icon:"pulse",tone:g?"ok":void 0},{label:"Last run failed",value:w(h),icon:"alert",tone:h>0?"bad":void 0},{label:"Switched off",value:w(b),icon:"power",tone:b>0?"warn":void 0},{label:"Retimed",value:w(j),icon:"clock",tone:j>0?"note":void 0}]}),h>0?e.jsx(ue,{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,[...k,...p.length>0?[""]:[]].map(y=>{const E=l.filter(N=>N.group===y);return E.length===0?null:e.jsx(R,{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:E.map(N=>{var B;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[N.name," ",N.running?e.jsx(C,{tone:"info",children:"running"}):null,N.enabled?null:e.jsx(C,{tone:"warn",children:"off"}),N.defaultIntervalSeconds>0&&N.intervalSeconds!==N.defaultIntervalSeconds?e.jsx(C,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:N.description}),e.jsxs("p",{className:"quiet",children:[Pe(N.intervalSeconds),N.enabled&&N.nextRun?` · next ${pe(N.nextRun).replace(" ago","")}`:"",N.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:P(N.lastRun.startedAt),children:pe(N.lastRun.startedAt)}),` in ${be(N.lastRun.durationMs)}`,N.lastRun.detail?` — ${N.lastRun.detail}`:""]}):" · never run"]}),(B=N.lastRun)!=null&&B.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(C,{tone:"bad",children:N.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[N.lastRun?e.jsx(C,{tone:ns(N.lastRun.status),children:N.lastRun.status}):e.jsx(C,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${N.name} runs`,value:N.intervalSeconds,disabled:n===`${N.id}:interval`||N.running,onChange:S=>void m(N,Number(S.target.value)),children:Kn(N).map(S=>e.jsxs("option",{value:S.value,children:[S.label,S.value===N.defaultIntervalSeconds?" (default)":""]},S.value))}),N.defaultIntervalSeconds>0&&N.intervalSeconds!==N.defaultIntervalSeconds?e.jsx(A,{size:"sm",icon:"refresh",busy:n===`${N.id}:interval`,onClick:()=>void f(N),children:"Default"}):null,e.jsx(z,{label:"",checked:N.enabled,disabled:n===`${N.id}:enabled`,onChange:S=>void u(N,S)}),e.jsx(A,{size:"sm",icon:"play",busy:n===N.id,disabled:N.running,onClick:()=>void v(N),children:"Run now"})]})]},N.id)})})},y||"other")}),e.jsx(R,{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(G,{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:((d==null?void 0:d.runs.length)??0)===0?e.jsx(se,{columns:6,children:"No task has run yet."}):d==null?void 0:d.runs.map(y=>{var E;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(y.startedAt),children:pe(y.startedAt)}),e.jsx("td",{children:((E=l.find(N=>N.id===y.taskId))==null?void 0:E.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(C,{tone:ns(y.status),children:y.status})}),e.jsx("td",{className:"num muted",children:be(y.durationMs)}),e.jsx("td",{className:"muted",children:y.error||y.detail||"—"})]},y.id)})})]})})})]})]})}const Zn={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Jn(){const{wrap:s,show:n}=Y(),{busy:o,run:i}=Q(),{data:a,error:d,loading:t,reload:r}=J("/admin/api/integrations",{pollMs:6e4}),[c,l]=x.useState(null),[g,v]=x.useState(null),u=(a==null?void 0:a.catalogue)??[],m=(a==null?void 0:a.integrations)??[],f=k=>l({id:k.id,name:k.name,url:"",enabled:k.enabled,events:k.events??[]}),h=()=>i("save",async()=>{if(!c)return;await s(()=>q.post("/admin/api/integrations",c),c.id?"Integration saved.":"Integration added.")&&(l(null),await r())}),j=k=>i("remove",async()=>{await s(()=>q.del(`/admin/api/integrations/${encodeURIComponent(k.id)}`),`${k.name} removed.`),v(null),await r()}),b=k=>i(`test:${k.id}`,async()=>{const p=await s(()=>q.post(`/admin/api/integrations/${encodeURIComponent(k.id)}/test`));p&&n(p.message,p.ok?"ok":"bad"),await r()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{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:()=>l(Zn),children:"Add a webhook"})}),e.jsx(U,{message:d}),e.jsx(Qn,{}),e.jsx(Yn,{}),e.jsx(Xn,{}),((a==null?void 0:a.dropped)??0)>0?e.jsxs(ue,{tone:"warn",children:[w((a==null?void 0:a.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(W,{}):m.length===0&&!c?e.jsx(R,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(Z,{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(k=>e.jsx(et,{integration:k,catalogue:u,busy:o,onEdit:()=>f(k),onTest:()=>void b(k),onRemove:()=>v(k)},k.id)),c?e.jsx(st,{draft:c,catalogue:u,busy:o==="save",onChange:l,onSave:()=>void h(),onCancel:()=>l(null)}):null,g?e.jsx(he,{title:`Remove ${g.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 j(g),onCancel:()=>v(null)}):null]})}function Qn(){const{wrap:s}=Y(),{busy:n,run:o}=Q(),{data:i,error:a,loading:d,reload:t}=J("/admin/api/arr-integrations"),r=c=>o("arr-integrations",async()=>{i&&(await s(()=>q.post("/admin/api/arr-integrations",{sonarrEnabled:c.sonarrEnabled??i.sonarrEnabled,radarrEnabled:c.radarrEnabled??i.radarrEnabled}),"Integration settings saved."),await t())});return e.jsxs(R,{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(U,{message:a??""}),d?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(z,{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:c=>void r({sonarrEnabled:c})}),e.jsx(z,{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:c=>void r({radarrEnabled:c})})]})]})}function Yn(){const{wrap:s}=Y(),{busy:n,run:o}=Q(),{data:i,error:a,loading:d,reload:t}=J("/admin/api/sonarr-request-policy"),[r,c]=x.useState(0),[l,g]=x.useState(!1);x.useEffect(()=>{i&&(c(i.qualityProfileId),g(i.searchImmediately))},[i]);const v=()=>o("sonarr-request-policy",async()=>{await s(()=>q.post("/admin/api/sonarr-request-policy",{qualityProfileId:r,searchImmediately:l}),"Sonarr TV request policy saved."),await t()}),u=i==null?void 0:i.profiles.find(m=>m.id===r);return e.jsxs(R,{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(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:d||r<=0,onClick:()=>void v(),children:"Save Sonarr policy"}),children:[e.jsx(U,{message:a??(i==null?void 0:i.error)??""}),d?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(D,{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:r,onChange:m=>c(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(z,{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:l,onChange:g}),u?e.jsxs(ue,{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 ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Xn(){const{wrap:s}=Y(),{busy:n,run:o}=Q(),{data:i,error:a,loading:d,reload:t}=J("/admin/api/radarr-request-policy"),[r,c]=x.useState(0),[l,g]=x.useState(!1);x.useEffect(()=>{i&&(c(i.qualityProfileId),g(i.searchImmediately))},[i]);const v=()=>o("radarr-request-policy",async()=>{await s(()=>q.post("/admin/api/radarr-request-policy",{qualityProfileId:r,searchImmediately:l}),"Radarr movie request policy saved."),await t()}),u=i==null?void 0:i.profiles.find(m=>m.id===r);return e.jsxs(R,{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(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:d||r<=0,onClick:()=>void v(),children:"Save Radarr policy"}),children:[e.jsx(U,{message:a??(i==null?void 0:i.error)??""}),d?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(D,{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:r,onChange:m=>c(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(z,{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:l,onChange:g}),u?e.jsxs(ue,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), remain monitored, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function et({integration:s,catalogue:n,busy:o,onEdit:i,onTest:a,onRemove:d}){const t=s.health,r=!t.lastFailure||t.lastSuccess&&t.lastSuccess>t.lastFailure,c=s.events??[];return e.jsxs(R,{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"}),t.deliveries>0?e.jsx(C,{tone:r?"ok":"bad",children:r?"delivering":"failing"}):e.jsx(C,{children:"never used"}),e.jsx(A,{size:"sm",icon:"pulse",busy:o===`test:${s.id}`,onClick:a,children:"Test"}),e.jsx(A,{size:"sm",onClick:i,children:"Edit"}),e.jsx(A,{size:"sm",variant:"danger",icon:"trash",onClick:d,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:c.length===0?"None selected — this destination is configured but will never post anything.":c.map(l=>{var g;return((g=n.find(v=>v.type===l))==null?void 0:g.label)??l}).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?P(t.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:t.deliveries>0?e.jsxs("span",{className:"quiet",children:[w(t.deliveries)," attempts, ",w(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:[P(t.lastFailure),t.lastError?` — ${t.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(G,{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(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(l.attemptedAt),children:pe(l.attemptedAt)}),e.jsx("td",{className:"muted",children:l.eventType}),e.jsx("td",{children:l.success?e.jsx(C,{tone:"ok",children:l.statusCode||"ok"}):e.jsx(C,{tone:"bad",children:l.error||l.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:be(l.durationMs)})]},l.id))})]})}):e.jsx(G,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(se,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function st({draft:s,catalogue:n,busy:o,onChange:i,onSave:a,onCancel:d}){const t=[...new Set(n.map(c=>c.group))],r=(c,l)=>i({...s,events:l?[...s.events,c]:s.events.filter(g=>g!==c)});return e.jsxs(R,{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:a,children:s.id?"Save":"Add"}),e.jsx(A,{variant:"quiet",onClick:d,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(D,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:c=>i({...s,name:c.target.value})})}),e.jsx(D,{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:c=>i({...s,url:c.target.value})})})]}),e.jsx(z,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:c=>i({...s,enabled:c})}),t.map(c=>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:c})})}),n.filter(l=>l.group===c).map(l=>e.jsx(z,{label:l.label,hint:l.description,checked:s.events.includes(l.type),onChange:g=>r(l.type,g)},l.type))]},c))]})}function nt(){var K,ne,X,$;const{status:s,error:n,loading:o,reload:i}=ae(),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(""),[l,g]=x.useState(!1),[v,u]=x.useState(!1),[m,f]=x.useState(!1),[h,j]=x.useState("23:00"),[b,k]=x.useState("07:00"),[p,y]=x.useState(""),[E,N]=x.useState(!1),B=!!((K=s==null?void 0:s.maintenance)!=null&&K.enabled);x.useEffect(()=>{var I;!v&&s&&c(((I=s.maintenance)==null?void 0:I.message)??"")},[s,v]),x.useEffect(()=>{E||!(s!=null&&s.quietTime)||(f(s.quietTime.enabled),j(s.quietTime.startTime),k(s.quietTime.endTime),y(s.quietTime.message))},[s,E]);const S=I=>t(I?"on":"off",async()=>{await a(()=>q.post("/admin/api/maintenance",{enabled:I,message:r}),I?"Memby is offline for every television.":"Memby is back online."),g(!1),u(!1),await i()}),L=()=>t("quiet",async()=>{await a(()=>q.post("/admin/api/quiet-time",{enabled:m,startTime:h,endTime:b,message:p}),m?"Quiet time saved.":"Quiet time turned off."),N(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:1}):e.jsx(R,{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:B?"bad":"warn",actions:B?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:B,onClick:()=>g(!0),children:"Go offline"}),e.jsx(A,{disabled:!B,busy:d==="off",onClick:()=>void S(!1),children:"Bring back online"})]}),children:e.jsx(D,{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:r,placeholder:"Back shortly — upgrading the server",onChange:I=>{c(I.target.value),u(!0)}})})}),o?null:e.jsxs(R,{title:"Quiet time",intro:`Pause new television requests and server background work every day in ${((ne=s==null?void 0:s.quietTime)==null?void 0:ne.timeZone)??"the household timezone"}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`,icon:"clock",tone:(X=s==null?void 0:s.quietTime)!=null&&X.active?"warn":"info",actions:($=s==null?void 0:s.quietTime)!=null&&$.active?e.jsx(C,{tone:"warn",children:"active now"}):m?e.jsx(C,{tone:"ok",children:"scheduled"}):e.jsx(C,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:d==="quiet",onClick:()=>void L(),children:"Save quiet time"}),children:[e.jsx(z,{label:"Pause server activity during quiet time",checked:m,onChange:I=>{f(I),N(!0)}}),e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Starts",hint:"Uses the household's 24-hour clock.",children:e.jsx("input",{type:"time",value:h,onChange:I=>{j(I.target.value),N(!0)}})}),e.jsx(D,{label:"Ends",hint:"May be on the following day, for example 23:00 to 07:00.",children:e.jsx("input",{type:"time",value:b,onChange:I=>{k(I.target.value),N(!0)}})})]}),e.jsx(D,{label:"Message shown on the television",hint:"Shown when a television contacts Memby during quiet time.",children:e.jsx("input",{type:"text",value:p,placeholder:"Quiet time — try again after 7 am",onChange:I=>{y(I.target.value),N(!0)}})})]}),l?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:d==="on",onConfirm:()=>void S(!0),onCancel:()=>g(!1)}):null]})}const tt=-1;function Me(s){return s===0?"":s<0?"off":String(s)}function Ee(s,n){const o=s.trim().toLowerCase();if(o==="")return 0;if(n&&(o==="off"||o==="none"||o==="0"))return tt;const i=Number.parseInt(o,10);return Number.isFinite(i)?i:0}function Ae(s,n){return s<=0?"off":`${s} ${n}${s===1?"":"s"}`}function qe(s){return{timezone:s.timezone??"",logLevel:s.logLevel??"",sessionIdleDays:Me(s.sessionIdleDays),sonarrAlertMinutes:Me(s.sonarrAlertMinutes),radarrAlertMinutes:Me(s.radarrAlertMinutes),embyHealthSeconds:Me(s.embyHealthSeconds)}}function it(){const{data:s,error:n,loading:o,reload:i}=J("/admin/api/gateway-settings"),{wrap:a}=Y(),{busy:d,run:t}=Q(),[r,c]=x.useState(null);x.useEffect(()=>{!r&&s&&c(qe(s.settings))},[s,r]);const l=(h,j)=>c(b=>b&&{...b,[h]:j}),g=()=>t("save",async()=>{if(!r)return;const h={timezone:r.timezone.trim(),logLevel:r.logLevel.trim(),sessionIdleDays:Ee(r.sessionIdleDays,!1),sonarrAlertMinutes:Ee(r.sonarrAlertMinutes,!0),radarrAlertMinutes:Ee(r.radarrAlertMinutes,!0),embyHealthSeconds:Ee(r.embyHealthSeconds,!0)},j=await a(()=>q.post("/admin/api/gateway-settings",h),"Gateway settings saved.");j&&c(qe(j.settings)),await i()}),v=()=>t("clear",async()=>{const h=await a(()=>q.post("/admin/api/gateway-settings",{timezone:"",logLevel:"",sessionIdleDays:0,sonarrAlertMinutes:0,radarrAlertMinutes:0,embyHealthSeconds:0}),"Every setting is back to what this container was deployed with.");h&&c(qe(h.settings)),await i()}),u=s==null?void 0:s.deployed,m=s==null?void 0:s.effective,f=(s==null?void 0:s.logLevels)??[];return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Gateway settings",intro:"Server-level settings for this gateway, changeable without a redeployment."}),e.jsx(U,{message:n}),o||!r||!u||!m?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(R,{title:"This gateway",intro:"What the process is running and what it currently believes.",icon:"chip",tone:"info",actions:e.jsx(C,{tone:"info",children:(s==null?void 0:s.version)??"unknown"}),children:e.jsx(Oe,{rows:[{label:"Household timezone",value:m.timezone||"not set"},{label:"Log level",value:m.logLevel},{label:"Sign-in expiry",value:Ae(m.sessionIdleDays,"day")},{label:"Emby health probe",value:Ae(m.embyHealthSeconds,"second")},{label:"Episode alert window",value:Ae(m.sonarrAlertMinutes,"minute")},{label:"Film alert window",value:Ae(m.radarrAlertMinutes,"minute")}]})}),e.jsxs(R,{title:"Overrides",intro:"Leave a field empty to use the value this container was deployed with, shown beneath it. Changes take effect immediately — nothing here needs a restart.",icon:"sliders",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>void g(),children:"Save settings"}),e.jsx(A,{busy:d==="clear",onClick:()=>void v(),children:"Use deployed values"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Household timezone",hint:`Deployed: ${u.timezone||"not set"}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`,children:e.jsx("input",{type:"text",value:r.timezone,placeholder:u.timezone,onChange:h=>l("timezone",h.target.value)})}),e.jsx(D,{label:"Log level",hint:`Deployed: ${u.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`,children:e.jsxs("select",{value:r.logLevel,onChange:h=>l("logLevel",h.target.value),children:[e.jsxs("option",{value:"",children:["Deployed (",u.logLevel,")"]}),f.map(h=>e.jsx("option",{value:h,children:h},h))]})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Sign a television out after (days)",hint:`Deployed: ${u.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:r.sessionIdleDays,placeholder:String(u.sessionIdleDays),onChange:h=>l("sessionIdleDays",h.target.value)})}),e.jsx(D,{label:"Emby health probe (seconds)",hint:`Deployed: ${u.embyHealthSeconds||"off"}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:r.embyHealthSeconds,placeholder:String(u.embyHealthSeconds),onChange:h=>l("embyHealthSeconds",h.target.value)})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Episode alert window (minutes)",hint:`Deployed: ${u.sonarrAlertMinutes||"off"}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:r.sonarrAlertMinutes,placeholder:String(u.sonarrAlertMinutes),onChange:h=>l("sonarrAlertMinutes",h.target.value)})}),e.jsx(D,{label:"Film alert window (minutes)",hint:`Deployed: ${u.radarrAlertMinutes||"off"}. The same, for a film Radarr has just imported.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:r.radarrAlertMinutes,placeholder:String(u.radarrAlertMinutes),onChange:h=>l("radarrAlertMinutes",h.target.value)})})]}),e.jsxs(ue,{tone:"note",children:["These override the deployed configuration in the database, so they survive a restart — but a deployment rewrites ",e.jsx("code",{children:".env"}),", not this, and the two can then disagree. Anything meant to be permanent belongs in ",e.jsx("code",{children:".env.example"})," ","as well."]}),s!=null&&s.settings.updatedBy?e.jsxs(ue,{children:["Last changed by ",s.settings.updatedBy,s.settings.updatedAt?` on ${new Date(s.settings.updatedAt).toLocaleString("en-NZ")}`:"","."]}):null]})]})]})}function at(){const{status:s,error:n,loading:o}=ae(),i=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(U,{message:n}),o?e.jsx(W,{rows:1}):e.jsx(R,{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(G,{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(se,{columns:8,children:"Nothing has been imported yet."}):i.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(a.startedAt)}),e.jsx("td",{children:a.kind}),e.jsx("td",{className:"muted",children:a.trigger}),e.jsx("td",{children:e.jsx(C,{tone:a.status==="success"?"ok":a.status==="running"?"warn":"bad",children:a.status})}),e.jsx("td",{className:"num",children:w(a.itemsSeen)}),e.jsx("td",{className:"num",children:w(a.itemsUpserted)}),e.jsx("td",{className:"num",children:w(a.itemsRemoved)}),e.jsx("td",{className:"muted",children:a.error||""})]},a.id||a.startedAt))})]})})})]})}const ts={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},is=2e4,rt=5e3,we=48,lt=31,as=8,ot=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],rs=new Map(ot.map((s,n)=>[s,n])),ct=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ls=new WeakMap;function ke(s){const n=ls.get(s);if(n)return n;const o=Object.entries(s.attributes??{}).sort((a,d)=>{const t=rs.get(a[0])??(a[0]==="error"?1e3:100),r=rs.get(d[0])??(d[0]==="error"?1e3:100);return t-r||a[0].localeCompare(d[0])}),i={fields:o,summary:o.map(([a,d])=>`${a}=${String(d)}`).join(" "),haystack:[s.message,...o.flat()].join(" ").toLowerCase(),occurred:ct.format(new Date(s.occurredAt))};return ls.set(s,i),i}const dt=s=>s.replace(/_/g," "),ht=x.memo(function({event:n,index:o,onInspect:i}){const a=ke(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${o*we}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[a.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:a.summary||"No structured details",onClick:()=>i(n.sequence),children:a.summary||"View record"})]})});function ut(){var F;const[s,n]=x.useState([]),[o,i]=x.useState(0),[a,d]=x.useState(!1),[t,r]=x.useState("INFO"),[c,l]=x.useState(""),[g,v]=x.useState(""),[u,m]=x.useState({top:0,height:600}),[f,h]=x.useState(null),j=x.useDeferredValue(c.trim().toLowerCase()),b=x.useRef(0),k=x.useRef(!1),p=x.useRef(0),y=x.useRef(null),E=x.useRef(!0),N=x.useRef(void 0),B=x.useCallback(async()=>{if(a||k.current||document.hidden)return;k.current=!0;const T=p.current,_=[];let ce=0;try{let de=0,le;do le=await q.get(`/admin/api/events?after=${b.current}&limit=1000`),b.current=le.next||b.current,ce+=le.dropped||0,_.push(...le.events??[]),de+=1;while(le.hasMore&&de<20);v("")}catch(de){v(de instanceof Error?de.message:String(de))}finally{_.length>0&&T===p.current&&n(de=>{const le=de.concat(_);return le.length>is?le.slice(le.length-is):le}),ce>0&&T===p.current&&i(de=>de+ce),k.current=!1}},[a]);x.useEffect(()=>{if(a)return;let T;const _=()=>{window.clearInterval(T),T=document.hidden?void 0:window.setInterval(()=>void B(),rt)},ce=()=>{_(),document.hidden||B()};return B(),_(),document.addEventListener("visibilitychange",ce),()=>{window.clearInterval(T),document.removeEventListener("visibilitychange",ce)}},[B,a]);const S=x.useMemo(()=>{const T=ts[t]??20;return s.filter(_=>(ts[_.level]??0)>=T&&(!j||ke(_).haystack.includes(j)))},[s,t,j]),L=((F=S.at(-1))==null?void 0:F.sequence)??0,K=Math.max(0,u.top-lt),ne=Math.ceil(u.height/we)+as*2,X=Math.min(Math.max(0,Math.floor(K/we)-as),Math.max(0,S.length-ne)),$=S.slice(X,X+ne),I=x.useMemo(()=>s.find(T=>T.sequence===f),[s,f]);x.useLayoutEffect(()=>{const T=y.current;!T||!E.current||(T.scrollTop=T.scrollHeight,m({top:T.scrollTop,height:T.clientHeight}))},[L,j,t]),x.useEffect(()=>()=>window.cancelAnimationFrame(N.current??0),[]);const H=()=>{const T=y.current;T&&(E.current=T.scrollHeight-T.scrollTop-T.clientHeight{m({top:T.scrollTop,height:T.clientHeight})}))},M=()=>{const T=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),_=document.createElement("a");_.href=URL.createObjectURL(T),_.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,_.click(),window.setTimeout(()=>URL.revokeObjectURL(_.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(U,{message:g}),e.jsxs(R,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(D,{label:"Level",children:e.jsxs("select",{value:t,onChange:T=>r(T.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(D,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:c,placeholder:"Person, television, title, component, path…",onChange:T=>l(T.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(A,{onClick:()=>d(T=>!T),icon:a?"play":"clock",children:a?"Resume":"Pause"}),e.jsx(A,{onClick:()=>{p.current+=1,n([]),i(0),h(null)},children:"Clear view"}),e.jsx(A,{onClick:M,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:y,onScroll:H,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"})]}),S.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:`${S.length*we}px`},children:$.map((T,_)=>e.jsx(ht,{event:T,index:X+_,onInspect:h},T.sequence))})]}),e.jsxs("p",{className:"hint",children:[w(s.length)," retained · ",w(S.length)," matching",S.length?` · ${w($.length)} rows mounted`:"",o?` · ${w(o)} overwritten before delivery`:"",a?" · paused":""]}),I?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${I.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:I.message}),e.jsxs("span",{children:[ke(I).occurred," · ",I.level," · record #",I.sequence]})]}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>h(null),children:"Close"})]}),ke(I).fields.length?e.jsx("dl",{children:ke(I).fields.map(([T,_])=>e.jsxs(x.Fragment,{children:[e.jsx("dt",{children:dt(T)}),e.jsx("dd",{children:String(_)})]},T))}):e.jsx(ue,{children:"No structured details were attached to this record."})]}):null]})]})}const os=["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 xe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function mt(){const[s,n]=x.useState(30),[o,i]=x.useState(""),a=Ve(),d=x.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:o})}`,[s,o]),{data:t,error:r,loading:c}=J(d),l=t==null?void 0:t.stats,g=(t==null?void 0:t.users)??[],v=(t==null?void 0:t.actions)??[],u=(t==null?void 0:t.paths)??[],m=u[0],f=x.useMemo(()=>{const h=new Map(((t==null?void 0:t.features)??[]).map(b=>[b.feature,b])),j=new Map(os.map((b,k)=>[b,k]));return[...new Set([...os,...h.keys()])].map(b=>({name:b,stat:h.get(b)})).sort((b,k)=>{var y,E;const p=(((y=k.stat)==null?void 0:y.uses)??0)-(((E=b.stat)==null?void 0:E.uses)??0);return p||(j.get(b.name)??Number.MAX_SAFE_INTEGER)-(j.get(k.name)??Number.MAX_SAFE_INTEGER)})},[t==null?void 0:t.features]);return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(U,{message:r}),e.jsx(R,{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(D,{label:"Window",children:e.jsxs("select",{value:s,onChange:h=>n(Number(h.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(D,{label:"User",children:e.jsxs("select",{value:o,onChange:h=>{const j=h.target.value;i(j),j&&a(`/admin/journeys/${encodeURIComponent(j)}`)},children:[e.jsx("option",{value:"",children:"All users"}),g.map(h=>e.jsx("option",{value:h.userId,children:h.username||h.userId},h.userId))]})})]}),children:c?e.jsx(W,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"journeys",value:w(l==null?void 0:l.journeys),icon:"list",tone:"data"},{label:"viewers",value:w(l==null?void 0:l.viewers),icon:"people",tone:"info"},{label:"completion",value:Re(l==null?void 0:l.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:w(l==null?void 0:l.abandoned),icon:"alert",tone:"note"},{label:"active now",value:w(l==null?void 0:l.active),icon:"pulse",tone:"info"},{label:"average steps",value:((l==null?void 0:l.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:be(l==null?void 0:l.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:Re(l==null?void 0:l.completionRate)})]}),e.jsx(nn,{value:(l==null?void 0:l.completed)??0,total:(l==null?void 0:l.journeys)??0}),e.jsxs("p",{children:[w(l==null?void 0:l.completed)," completed · ",w(l==null?void 0:l.abandoned)," abandoned ·"," ",w(l==null?void 0:l.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?`${xe(m.from)} → ${xe(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${w(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(oe,{cols:"2",children:[e.jsx(R,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(G,{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:v.length===0?e.jsx(se,{columns:3,children:"No significant actions in this window."}):v.map(h=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:xe(h.action)}),e.jsx("span",{className:"table-sub",children:xe(h.category)})]}),e.jsx("td",{className:"num",children:w(h.events)}),e.jsx("td",{className:"num",children:w(h.journeys)})]},`${h.category}:${h.action}`))})]})})}),e.jsx(R,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(G,{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(se,{columns:2,children:"No repeated paths in this window."}):u.map((h,j)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[xe(h.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",xe(h.to)]}),e.jsx("td",{className:"num",children:w(h.count)})]},`${h.from}:${h.to}:${j}`))})]})})})]}),e.jsx(R,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(G,{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:f.map(({name:h,stat:j})=>{const b=(j==null?void 0:j.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:xe(h)}),e.jsx("td",{className:"num",children:w(b)}),e.jsx("td",{className:"muted nowrap",children:j?P(j.lastUsedAt):"—"}),e.jsx("td",{children:b===0?e.jsx(C,{tone:"warn",children:"not used"}):b<3?e.jsx(C,{tone:"note",children:"rare"}):e.jsx(C,{tone:"ok",children:"used"})})]},h)})})]})})}),o?null:e.jsx(R,{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 ve=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},As=s=>ve((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),cs=s=>s.itemName?`${ve(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${ve(s.source)} → ${ve(s.target)}`:As(s),pt=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]??ve(s.action);function xt(s){var o;const n=(o=[...s].reverse().find(i=>i.outcome))==null?void 0:o.outcome;return n==="success"||n==="completed"?{label:ve(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:ve(n),tone:"note"}:s.some(i=>i.action==="stop"&&i.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function jt(s){const n=s.reduce((o,i,a)=>(i.category==="playback"&&i.action==="request"&&o.push(a),o),[]);return n.length===0?[s]:n.map((o,i)=>s.slice(i===0?0:o,n[i+1]??s.length))}function vt(){var c,l;const{userId:s=""}=$e(),n=x.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:o,error:i,loading:a}=J(n),d=((l=(c=o==null?void 0:o.users)==null?void 0:c.find(g=>g.userId===s))==null?void 0:l.username)||s,t=x.useMemo(()=>{const g=new Map;for(const v of(o==null?void 0:o.events)??[])g.set(v.journeyId,[...g.get(v.journeyId)??[],v]);return[...g.values()].map(v=>v.sort((u,m)=>u.sequence-m.sequence)).sort((v,u)=>{var m,f;return(((m=u[0])==null?void 0:m.occurredAt)??"").localeCompare(((f=v[0])==null?void 0:f.occurredAt)??"")})},[o==null?void 0:o.events]),r=t.flatMap(g=>jt(g).map((v,u)=>{var m;return{events:v,key:`${(m=g[0])==null?void 0:m.journeyId}:${u}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:`${d}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(ie,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(U,{message:i}),a?e.jsx(W,{}):e.jsx(R,{title:"Viewing journeys",intro:`${t.length} app session${t.length===1?"":"s"} · ${r.length} viewing journey${r.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:r.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):r.map((g,v)=>{const u=g.events,m=u[0],f=[...u].reverse().find(j=>j.itemName||j.action==="select"||j.category==="playback"&&j.action==="request"),h=xt(u);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:P(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",v+1," · ",u.length," recorded steps"]})]}),e.jsx(C,{tone:h.tone,children:h.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(ee,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:As(m)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(ee,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?cs(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(ee,{name:h.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:h.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:u.map(j=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":j.action}),e.jsxs("div",{children:[e.jsx("b",{children:pt(j)}),e.jsx("span",{children:cs(j)})]}),e.jsx("time",{children:P(j.occurredAt)})]},`${j.journeyId}:${j.sequence}`))})]},g.key)})})})]})}function ds(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function bt(){const[s,n]=x.useState(30),{data:o,error:i,loading:a}=J(`/admin/api/analytics?days=${s}`),d=(o==null?void 0:o.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(U,{message:i}),e.jsx(R,{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(D,{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:a?e.jsx(W,{rows:1}):e.jsx(G,{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:d.length===0?e.jsx(se,{columns:8,children:"No events in this window."}):d.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:ds(t.rowId)}),e.jsx("td",{className:"muted",children:ds(t.rowKind)}),e.jsx("td",{className:"num",children:be(t.dwellMs)}),e.jsx("td",{className:"num",children:w(t.impressions)}),e.jsx("td",{className:"num",children:w(t.focuses)}),e.jsx("td",{className:"num",children:w(t.selects)}),e.jsx("td",{className:"num",children:Re(t.selectRate)}),e.jsx("td",{className:"num",children:w(t.viewers)})]},`${t.rowId}:${t.rowKind}`))})]})})})]})}function gt(){const[s,n]=x.useState(7),{data:o,error:i,loading:a}=J(`/admin/api/searches?days=${s}`),d=(o==null?void 0:o.terms)??[],t=(o==null?void 0:o.recent)??[],r=o==null?void 0:o.totals;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(U,{message:i}),e.jsx(re,{tiles:[{label:"searches",value:w((r==null?void 0:r.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:w((r==null?void 0:r.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:w((r==null?void 0:r.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(o==null?void 0:o.retentionDays)??30} days`,small:!0,icon:"clock"}]}),a?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(R,{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(D,{label:"Window",children:e.jsxs("select",{value:s,onChange:c=>n(Number(c.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(G,{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:d.length===0?e.jsx(se,{columns:4,children:"Nothing searched in this window."}):d.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.query}),e.jsx("td",{className:"num",children:w(c.searches)}),e.jsx("td",{className:"num",children:w(c.viewers)}),e.jsx("td",{className:"muted nowrap",children:P(c.lastAt)})]},c.query))})]})})}),e.jsx(R,{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(G,{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(se,{columns:3,children:"No searches in this window."}):t.map((c,l)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:P(c.occurredAt)}),e.jsx("td",{children:c.username||e.jsx(C,{tone:"warn",children:c.userId||"unknown"})}),e.jsx("td",{children:c.query})]},`${c.occurredAt}:${l}`))})]})})})]})]})}function hs(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 ft(){const{data:s,error:n,loading:o}=J("/admin/api/views",{pollMs:6e4}),i=(s==null?void 0:s.daily)??[],a=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(U,{message:n}),o?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:hs((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:w(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:hs((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:w(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(R,{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(G,{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(se,{columns:3,children:"No home-screen visits yet."}):i.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.label}),e.jsx("td",{className:"num",children:w(d.visits)}),e.jsx("td",{className:"num",children:w(d.viewers)})]},d.label))})]})})}),e.jsx(R,{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(G,{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:a.length===0?e.jsx(se,{columns:3,children:"No home-screen visits yet today."}):a.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.label}),e.jsx("td",{className:"num",children:w(d.visits)}),e.jsx("td",{className:"num",children:w(d.viewers)})]},d.label))})]})})})]})]})}const yt=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function wt(){var a;const s=J("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:o}=Q(),i=(d,t)=>o(`${d.id}-${t}`,async()=>{await q.post(`/admin/api/media-reports/${d.id}/status`,{status:t}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(W,{rows:4}):e.jsx(R,{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:(a=s.data)!=null&&a.reports.length?e.jsx(G,{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(d=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:yt(d)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:d.title})]}),e.jsxs("td",{children:[d.reason.replaceAll("_"," "),d.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:d.comment})]}):null]}),e.jsxs("td",{children:[d.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:d.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(C,{tone:d.replacementRequested?"note":void 0,children:d.replacementRequested?d.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(C,{tone:d.status==="resolved"?"ok":d.status==="dismissed"?void 0:"warn",children:d.status})}),e.jsx("td",{className:"nowrap muted",children:P(d.createdAt)}),e.jsxs("td",{children:[e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${d.id}-acknowledged`,onClick:()=>void i(d,"acknowledged"),children:"Acknowledge"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${d.id}-resolved`,onClick:()=>void i(d,"resolved"),children:"Resolve"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${d.id}-dismissed`,onClick:()=>void i(d,"dismissed"),children:"Dismiss"})]})]},d.id))})]})}):e.jsx(Z,{children:"No media problems have been reported."})})]})}const kt=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",Nt=s=>s==="no_match"?"no match":s,us=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",ms=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function St(){var h,j,b,k;const s=J("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=Y(),{busy:o,run:i}=Q(),[a,d]=x.useState(),[t,r]=x.useState(!1);x.useEffect(()=>{!t&&s.data&&d(s.data.settings)},[s.data,t]);const c=p=>{d(y=>y&&{...y,...p}),r(!0)},l=()=>{a&&i("save",async()=>{const p=await n(()=>q.put("/admin/api/credits",a),"Credits scanning settings saved.");p&&(s.set(p),d(p.settings),r(!1))})},g=((h=s.data)==null?void 0:h.history)??[],v=((j=s.data)==null?void 0:j.pending)??[],u=g.filter(p=>p.outcome==="detected").length,m=g.filter(p=>p.outcome==="no_match").length,f=g.filter(p=>p.outcome==="failed").length;return e.jsxs(e.Fragment,{children:[e.jsx(O,{title:"Credits detection",intro:"Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."}),e.jsx(U,{message:s.error}),s.loading||!a?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[(b=s.data)!=null&&b.enabled?null:e.jsx(ue,{tone:"warn",children:"Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled."}),e.jsx(re,{tiles:[{label:"Waiting candidates",value:w((k=s.data)==null?void 0:k.queueDepth),icon:"clock",tone:v.length?"info":void 0},{label:"Detected in this history",value:w(u),icon:"check",tone:"ok"},{label:"No match",value:w(m),icon:"search",tone:m?"warn":void 0},{label:"Failed",value:w(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(oe,{cols:"wide",children:[e.jsx(R,{title:"Candidate controls",intro:"The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead.",icon:"sliders",tone:"note",footer:e.jsx(A,{variant:"primary",icon:"check",busy:o==="save",disabled:!t,onClick:l,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Candidate limit",hint:"Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.",children:e.jsx("input",{type:"number",min:1,max:100,value:a.candidateLimit,onChange:p=>c({candidateLimit:Number(p.target.value)})})}),e.jsx(D,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:a.prefetchEpisodes,onChange:p=>c({prefetchEpisodes:Number(p.target.value)})})}),e.jsx(D,{label:"Maximum look-ahead",hint:"Upper bound for fast binge viewing; must not be below the ordinary look-ahead.",children:e.jsx("input",{type:"number",min:a.prefetchEpisodes,max:20,value:a.maxPrefetch,onChange:p=>c({maxPrefetch:Number(p.target.value)})})}),e.jsx(D,{label:"Retry delay (hours)",hint:"After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.",children:e.jsx("input",{type:"number",min:0,max:720,value:a.retryHours,onChange:p=>c({retryHours:Number(p.target.value)})})})]})}),e.jsxs(R,{title:"How selection works",icon:"sparkle",tone:"data",children:[e.jsx("p",{className:"muted",children:"Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching."}),e.jsx("p",{className:"muted",children:"A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it."})]})]}),e.jsx(R,{title:"Waiting candidates",intro:"The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes.",icon:"list",tone:"info",children:e.jsx(G,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Episode"}),e.jsx("th",{children:"Reason"}),e.jsx("th",{className:"num",children:"Priority"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Demand seen"}),e.jsx("th",{children:"Item ID"})]})}),e.jsx("tbody",{children:v.length===0?e.jsx(se,{columns:6,children:"No episodes are waiting to be scanned."}):v.map(p=>e.jsxs("tr",{children:[e.jsx("td",{children:ms(p.season,p.episode)}),e.jsx("td",{children:e.jsx(C,{tone:"info",children:us(p.reason)})}),e.jsx("td",{className:"num",children:w(p.priority)}),e.jsx("td",{className:"num",children:w(p.userCount)}),e.jsx("td",{className:"nowrap muted",title:P(p.lastViewed),children:pe(p.lastViewed)}),e.jsx("td",{className:"mono muted",children:p.itemId})]},p.itemId))})]})})}),e.jsx(R,{title:"Scan history",intro:"Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately.",icon:"history",tone:"note",children:e.jsx(G,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Finished"}),e.jsx("th",{children:"Programme"}),e.jsx("th",{children:"Selected because"}),e.jsx("th",{children:"Result"}),e.jsx("th",{children:"Marker"}),e.jsx("th",{children:"Evidence"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:g.length===0?e.jsx(se,{columns:7,children:"No credits scans have completed yet."}):g.map(p=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(p.finishedAt),children:pe(p.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:p.seriesName||p.itemName||p.itemId}),e.jsxs("span",{className:"table-sub",children:[ms(p.season,p.episode),p.itemName&&p.seriesName?` · ${p.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(C,{tone:"info",children:us(p.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",p.priority]})]}),e.jsxs("td",{children:[e.jsx(C,{tone:kt(p.outcome),children:Nt(p.outcome)}),p.error?e.jsx("span",{className:"table-sub",children:p.error}):null]}),e.jsx("td",{className:"nowrap",children:p.markerMs>0?be(p.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[p.method||"visual",p.confidence>0?` · ${Re(p.confidence)}`:"",p.frames>0?` · ${p.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:be(p.durationMs)})]},p.id))})]})})})]})]})}function Ct(){return e.jsx(O,{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 Mt(){return e.jsx(Ls,{children:e.jsx(Zs,{children:e.jsx(en,{children:e.jsx(ln,{children:e.jsx(Ds,{children:e.jsxs(V,{path:"/admin",element:e.jsx(an,{}),children:[e.jsx(V,{index:!0,element:e.jsx(on,{})}),e.jsx(V,{path:"activity",element:e.jsx(dn,{})}),e.jsx(V,{path:"accounts",element:e.jsx(hn,{})}),e.jsx(V,{path:"accounts/:userId",element:e.jsx(un,{})}),e.jsx(V,{path:"accounts/:userId/settings",element:e.jsx(bn,{})}),e.jsx(V,{path:"clients",element:e.jsx(fn,{})}),e.jsx(V,{path:"logins",element:e.jsx(wn,{})}),e.jsx(V,{path:"devices/:deviceId",element:e.jsx(Cn,{})}),e.jsx(V,{path:"library",element:e.jsx(Mn,{})}),e.jsx(V,{path:"ratings",element:e.jsx(An,{})}),e.jsx(V,{path:"requests",element:e.jsx(Rn,{})}),e.jsx(V,{path:"recommendations",element:e.jsx(In,{})}),e.jsx(V,{path:"inspector",element:e.jsx(Ln,{})}),e.jsx(V,{path:"hero",element:e.jsx(Pn,{})}),e.jsx(V,{path:"features",element:e.jsx(Un,{})}),e.jsx(V,{path:"playback",element:e.jsx(Vn,{})}),e.jsx(V,{path:"subtitles",element:e.jsx(Bn,{})}),e.jsx(V,{path:"credits",element:e.jsx(St,{})}),e.jsx(V,{path:"updates",element:e.jsx(_n,{})}),e.jsx(V,{path:"tasks",element:e.jsx(Gn,{})}),e.jsx(V,{path:"integrations",element:e.jsx(Jn,{})}),e.jsx(V,{path:"maintenance",element:e.jsx(nt,{})}),e.jsx(V,{path:"settings",element:e.jsx(it,{})}),e.jsx(V,{path:"imports",element:e.jsx(at,{})}),e.jsx(V,{path:"logs",element:e.jsx(ut,{})}),e.jsx(V,{path:"journeys",element:e.jsx(mt,{})}),e.jsx(V,{path:"journeys/:userId",element:e.jsx(vt,{})}),e.jsx(V,{path:"views",element:e.jsx(ft,{})}),e.jsx(V,{path:"engagement",element:e.jsx(bt,{})}),e.jsx(V,{path:"searches",element:e.jsx(gt,{})}),e.jsx(V,{path:"media-reports",element:e.jsx(wt,{})}),e.jsx(V,{path:"overview",element:e.jsx(qs,{to:"/admin",replace:!0})}),e.jsx(V,{path:"*",element:e.jsx(Ct,{})})]})})})})})})}const Rs=document.getElementById("root");if(!Rs)throw new Error("the console has no root element to render into");vs(Rs).render(e.jsx(x.StrictMode,{children:e.jsx(Mt,{})})); diff --git a/admin-ui/dist/assets/index-Cg_z5PGS.css b/admin-ui/dist/assets/index-Cg_z5PGS.css new file mode 100644 index 0000000..aeda15e --- /dev/null +++ b/admin-ui/dist/assets/index-Cg_z5PGS.css @@ -0,0 +1 @@ +@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:grid;place-items:center;width:38px;height:38px;flex:0 0 38px;padding:0;border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:default;opacity:1}.topbar-status .dot{width:9px;height:9px;border-radius:50%;background:var(--quiet);box-shadow:0 0 0 3px #7d85901f}.topbar-status[data-tone=ok] .dot{background:var(--accent-ink);box-shadow:0 0 0 3px var(--accent-wash),0 0 9px #56d36447}.topbar-status[data-tone=bad] .dot{background:var(--danger);box-shadow:0 0 0 3px var(--danger-wash)}.account-menu{position:relative;display:flex;align-items:center;height:38px;flex:0 0 auto}.account-trigger{max-width:190px;height:38px;padding:0 9px 0 5px;border-color:var(--line);background:var(--surface);color:var(--muted)}.account-trigger:hover:not(:disabled),.account-menu[data-open=true] .account-trigger{border-color:#56d36480;background:var(--surface-hi);color:var(--text)}.account-avatar{display:grid;place-items:center;width:27px;height:27px;flex:0 0 27px;border:1px solid rgba(86,211,100,.36);border-radius:50%;background:var(--accent-wash);color:var(--accent-ink);font-size:11px;font-weight:750}.account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}.account-caret{width:12px;height:12px;transition:transform .16s ease}.account-menu[data-open=true] .account-caret{transform:rotate(180deg)}.account-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(280px,calc(100vw - 24px));padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.account-identity{display:flex;align-items:center;gap:11px;min-width:0;padding:9px 10px 12px;border-bottom:1px solid var(--line-soft)}.account-avatar-large{width:34px;height:34px;flex-basis:34px;font-size:13px}.account-identity span:last-child{min-width:0}.account-identity small,.account-identity b{display:block}.account-identity small{color:var(--quiet);font-size:11px}.account-identity b{overflow:hidden;text-overflow:ellipsis;font-size:13.5px}.account-panel>a,.account-panel form button{display:flex;align-items:center;gap:9px;justify-content:flex-start;width:100%;height:38px;padding:0 10px;border-radius:8px;color:var(--muted);font-size:13px;text-decoration:none}.account-panel>a:hover{background:var(--surface-hi);color:var(--text)}.account-panel>a{margin-top:6px}.account-panel form{margin-top:2px}.account-panel form button{justify-content:flex-start;width:100%;height:38px;border-color:transparent;background:transparent;color:var(--muted)}.account-panel form button:hover:not(:disabled){background:var(--surface-hi);color:var(--text)}@media (max-width: 900px){.topbar-version{display:none}}.omni{position:relative;width:100%}.omni-input{display:flex;align-items:center;gap:8px;width:100%;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.omni[data-open=true] .omni-input{border-color:var(--accent)}.omni-input input[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right:auto;left:0;width:100%;max-height:min(50vh,420px);overflow-y:auto;padding:6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.omni-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--radius-sm);color:var(--text);text-decoration:none}.omni-item .ico{color:var(--quiet);flex:0 0 16px}.omni-item.on,.omni-item:hover{background:var(--surface-hi)}.omni-item b{font-size:13.5px;font-weight:600}.omni-item small{display:block;color:var(--quiet);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-item span{flex:1 1 auto;min-width:0}.omni-group{flex:0 0 auto;color:var(--quiet);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.bell{position:relative;display:flex;align-items:center;height:38px}.bell-button{position:relative;display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}.bell-button:hover{background:var(--surface-hi);color:var(--text)}.bell[data-open=true] .bell-button{border-color:var(--accent);color:var(--text)}.bell-badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.bell-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(420px,92vw);border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40;overflow:hidden}.bell-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line)}.bell-head b{font-size:13px}.bell-list{max-height:min(58vh,460px);overflow-y:auto}.bell-item{display:flex;gap:11px;padding:11px 14px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.bell-item:last-child{border-bottom:0}.bell-item:hover{background:var(--surface-lift)}.bell-item[data-unread=true]{box-shadow:inset 2px 0 0 var(--accent)}.bell-item .glyph{flex:0 0 auto}.bell-body{flex:1 1 auto;min-width:0}.bell-body b{display:block;font-size:13px;font-weight:600}.bell-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px;overflow-wrap:anywhere}.bell-body time{display:block;margin-top:3px;color:var(--quiet);font-size:11px}.bell-foot{padding:9px 14px;border-top:1px solid var(--line);text-align:center}.bell-foot a{font-size:12.5px;text-decoration:none}.rail{position:fixed;top:var(--top);bottom:0;left:0;width:var(--rail);padding:12px 12px max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);min-width:0;margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-key,.topbar-version{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{width:40px;flex-basis:40px;min-width:40px;height:40px;border:1px solid var(--line);background:var(--surface)}.omni-input,.bell,.bell-button,.account-menu,.account-trigger{height:40px}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 1100px){.account-trigger{width:40px;padding:0}.account-name,.account-caret{display:none}}@media (max-width: 820px){.brand-word{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}@media (max-width: 680px){:root{--top: calc(60px + var(--safe-top))}.topbar{align-items:center;flex-wrap:nowrap;gap:clamp(4px,1.5vw,8px);padding:calc(var(--safe-top) + 8px) max(8px,var(--safe-right)) 8px max(8px,var(--safe-left))}.rail-toggle{order:1}.topbar-brand{order:2;height:40px;flex:0 0 auto}.topbar .omni{order:3;flex:1 1 96px;width:0;min-width:0;max-width:none}.topbar-status{order:4;margin-left:0}.bell{order:5}.account-menu{order:6}.brand-mark{width:30px;height:30px;flex-basis:30px}.account-panel,.bell-panel{position:fixed;top:calc(var(--top) + 8px)}.account-panel{right:max(12px,var(--safe-right))}}@media (max-width: 370px){.tiles{grid-template-columns:minmax(0,1fr)}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono);contain:layout paint style}.loghead{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);gap:12px;padding:7px 12px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:760px}.logbody{position:relative;min-width:760px}.logline{display:flex;gap:12px;padding:3px 12px;border-bottom:1px solid rgba(255,255,255,.03);white-space:pre-wrap;overflow-wrap:anywhere}.logline[data-virtual=true]{position:absolute;top:0;right:0;left:0;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);align-items:center;height:48px;padding-top:4px;padding-bottom:4px;white-space:nowrap;contain:strict}.logline:hover{background:#ffffff08}.logline time{flex:0 0 auto;color:var(--quiet)}.logline time small{display:block;color:var(--muted);font-size:10px}.logline .lvl{flex:0 0 46px;font-weight:700}.logline[data-level=ERROR] .lvl{color:var(--danger-ink)}.logline[data-level=WARN] .lvl{color:var(--warn-ink)}.logline[data-level=INFO] .lvl{color:var(--info-ink)}.logline[data-level=DEBUG] .lvl{color:var(--quiet)}.logline .msg{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}.logline .attrs{color:var(--quiet)}@media (min-width: 900px){.logline{display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr)}}.logline .attrs b{color:var(--muted);font-weight:400}.logattrs-button{display:block;width:100%;min-width:0;height:28px;padding:0;overflow:hidden;border:0;background:transparent;color:var(--quiet);font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logattrs-button:hover:not(:disabled){border:0;background:transparent;color:var(--text)}.logattrs-button:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.log-inspector{margin-top:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.log-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.log-inspector-head>div{min-width:0}.log-inspector-head b,.log-inspector-head span{display:block;overflow-wrap:anywhere}.log-inspector-head span{margin-top:3px;color:var(--quiet);font:11px/1.5 var(--mono)}.log-inspector dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:5px 14px;margin:12px 0 0;padding-top:10px;border-top:1px solid var(--line);font:12px/1.5 var(--mono)}.log-inspector dt{color:var(--muted)}.log-inspector dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.loghead,.logbody{min-width:680px}.loghead,.logline[data-virtual=true]{grid-template-columns:128px 44px minmax(160px,1fr) minmax(220px,1.4fr);gap:10px}.logview{height:min(66dvh,720px)}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:0}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar .account-trigger{width:44px;height:44px;padding:0}.topbar .bell,.topbar .account-menu,.topbar .omni-input{height:44px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (pointer: fine) and (min-width: 821px) and (max-height: 800px){.page{padding-top:20px;padding-bottom:52px}.page-head{margin-bottom:16px}.card{padding-top:14px;padding-bottom:14px}.card-head{margin-bottom:11px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}} diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index 8db0cbc..0898f4e 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -13,9 +13,9 @@ 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/App.tsx b/admin-ui/src/App.tsx index 76170c0..b13062a 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -26,6 +26,7 @@ import { UpdatesPage } from './pages/Updates'; import { TasksPage } from './pages/Tasks'; import { IntegrationsPage } from './pages/Integrations'; import { MaintenancePage } from './pages/Maintenance'; +import { SettingsPage } from './pages/Settings'; import { ImportsPage } from './pages/Imports'; import { LogsPage } from './pages/Logs'; import { JourneysPage } from './pages/Journeys'; @@ -86,6 +87,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 6bb07eb..8217939 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -528,3 +528,38 @@ export interface RuntimeStatus { memoryLimit: number; configuredLimit?: string; } + +/* ---------- gateway settings ---------- */ + +/** GatewaySettings is the operator's overrides. Every field is optional in meaning: an + * empty string or a zero means "whatever the container was started with", and -1 means + * off for the three that can be switched off. */ +export interface GatewaySettings { + timezone: string; + logLevel: string; + sessionIdleDays: number; + sonarrAlertMinutes: number; + radarrAlertMinutes: number; + embyHealthSeconds: number; + updatedAt?: string; + updatedBy?: string; +} + +/** GatewaySettingValues is the same vocabulary as plain values, used twice: once for what + * the container was deployed with and once for what is actually in force. */ +export interface GatewaySettingValues { + timezone: string; + logLevel: string; + sessionIdleDays: number; + sonarrAlertMinutes: number; + radarrAlertMinutes: number; + embyHealthSeconds: number; +} + +export interface GatewaySettingsResponse { + settings: GatewaySettings; + deployed: GatewaySettingValues; + effective: GatewaySettingValues; + logLevels: string[] | null; + version: string; +} diff --git a/admin-ui/src/components/Layout.tsx b/admin-ui/src/components/Layout.tsx index 9832235..a02244a 100644 --- a/admin-ui/src/components/Layout.tsx +++ b/admin-ui/src/components/Layout.tsx @@ -255,6 +255,13 @@ export function Layout() { Signed in as{currentUser} + {/* The gateway's own settings live here rather than on the rail: the rail + is the household — its users, its content, its televisions — and this + is the server process those pages are served by. */} + setAccountOpen(false)}> + + Gateway settings +
+ + + } + > +
+ + set('timezone', event.target.value)} + /> + + + + + +
+ +
+ + set('sessionIdleDays', event.target.value)} + /> + + + + set('embyHealthSeconds', event.target.value)} + /> + +
+ +
+ + set('sonarrAlertMinutes', event.target.value)} + /> + + + + set('radarrAlertMinutes', event.target.value)} + /> + +
+ + + These override the deployed configuration in the database, so they survive a + restart — but a deployment rewrites .env, not this, and the two can + then disagree. Anything meant to be permanent belongs in .env.example{' '} + as well. + + {data?.settings.updatedBy ? ( + + Last changed by {data.settings.updatedBy} + {data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}. + + ) : null} + + + )} + + ); +} diff --git a/admin-ui/src/styles.css b/admin-ui/src/styles.css index efbee51..49ae719 100644 --- a/admin-ui/src/styles.css +++ b/admin-ui/src/styles.css @@ -337,9 +337,33 @@ a { text-overflow: ellipsis; font-size: 13.5px; } -.account-panel form { +/* A link and a submit button sit in this menu side by side, and they have to be one row + shape: the operator reads them as two entries of the same list, not as a link above a + form. */ +.account-panel > a, +.account-panel form button { + display: flex; + align-items: center; + gap: 9px; + justify-content: flex-start; + width: 100%; + height: 38px; + padding: 0 10px; + border-radius: 8px; + color: var(--muted); + font-size: 13px; + text-decoration: none; +} +.account-panel > a:hover { + background: var(--surface-hi); + color: var(--text); +} +.account-panel > a { margin-top: 6px; } +.account-panel form { + margin-top: 2px; +} .account-panel form button { justify-content: flex-start; width: 100%; diff --git a/server/README.md b/server/README.md index f5bb556..d4969de 100644 --- a/server/README.md +++ b/server/README.md @@ -666,7 +666,7 @@ can review and revoke signed-in TVs from the app's Settings screen. | `MEMBY_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners | | `MEMBY_RADARR_URL` | *empty* | Radarr address reachable by the gateway; empty disables integration | | `MEMBY_RADARR_API_KEY` | *empty* | Radarr Settings → General → Security API key | -| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the five-day digital-release calendar | +| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the month-long digital-release calendar | | `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports | | `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this | | `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis | diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index ad3cacd..46defeb 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -50,7 +50,11 @@ func main() { return } - logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL")) + // A variable rather than a fixed level: the console can move it at runtime, which is + // the only way turning debug on is any use — a restart to watch something happen + // restarts the thing being watched. + logLevel := &slog.LevelVar{} + logLevel.Set(logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))) logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000) logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT")) logHistoryPath := strings.TrimSpace(os.Getenv("MEMBY_LOG_HISTORY_PATH")) @@ -70,13 +74,13 @@ func main() { // this" is a real question that a reader should never have to scroll for. log = log.With("version", buildinfo.Version()) - if err := run(log, events); err != nil { + if err := run(log, events, logLevel); err != nil { log.Error("fatal", "error", err) os.Exit(1) } } -func run(log *slog.Logger, events *logging.Buffer) error { +func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) error { cfg, err := config.Load() if err != nil { return err @@ -267,6 +271,7 @@ func run(log *slog.Logger, events *logging.Buffer) error { AdminEvents: adminBus, Scheduler: sched, Integrations: dispatcher, + LogLevel: logLevel, }) if err := server.LoadQuietTime(ctx); err != nil { return err @@ -301,10 +306,16 @@ func run(log *slog.Logger, events *logging.Buffer) error { if err := server.LoadMaintenance(ctx); err != nil { return err } + // The operator's runtime amendments to what this container was started with. Loaded + // before the watchers below, because two of them read it on their first tick. + if err := server.LoadGatewaySettings(ctx); err != nil { + return err + } + go server.WatchGatewaySettings(ctx, 30*time.Second) go server.WatchMaintenance(ctx, 30*time.Second) go server.WatchQuietTime(ctx, 30*time.Second) // One probe per gateway, not per TV: the answer is the same for the whole house. - go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval) + go server.WatchEmbyReachability(ctx) // One Sonarr catalogue reading per day records lifecycle changes for the household and // materialises cancellation notifications for every known viewer. go server.WatchSonarrLifecycle(ctx, 24*time.Hour) diff --git a/server/internal/adminevents/adminevents.go b/server/internal/adminevents/adminevents.go index 55d4e2b..8c3bd87 100644 --- a/server/internal/adminevents/adminevents.go +++ b/server/internal/adminevents/adminevents.go @@ -38,11 +38,14 @@ const ( TypeLoginFailed = "auth.login_failed" TypeLogout = "auth.logout" TypeDeviceRegistered = "device.registered" + TypeDeviceFirstUse = "device.first_use" + TypeDeviceUpdated = "device.updated" TypeDeviceRemoved = "device.removed" TypeDeviceRenamed = "device.renamed" TypeAdminSignIn = "admin.sign_in" TypeServerStarted = "server.started" TypeMaintenanceChanged = "server.maintenance" + TypeSettingsChanged = "server.settings" TypeTaskCompleted = "task.completed" TypeTaskFailed = "task.failed" TypeIntegrationFailed = "integration.failed" diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 86a63f8..56dfc42 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -56,6 +56,8 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou)) + mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings)) + mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime)) mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert)) @@ -867,4 +869,5 @@ func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) { type syncerHandle interface { Running() bool Sync(ctx context.Context, kind, trigger string) (library.Result, error) + Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error) } diff --git a/server/internal/api/admin_accounts.go b/server/internal/api/admin_accounts.go index 5e91d26..0c0de0d 100644 --- a/server/internal/api/admin_accounts.go +++ b/server/internal/api/admin_accounts.go @@ -286,6 +286,10 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) s.loggerFor(r.Context()).Warn("device version cleanup failed", "removed_device_id", deviceID, "error", err) } + if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil { + s.loggerFor(r.Context()).Warn("device activity cleanup failed", + "removed_device_id", deviceID, "error", err) + } w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/api/admin_gateway_settings.go b/server/internal/api/admin_gateway_settings.go new file mode 100644 index 0000000..3db6cbb --- /dev/null +++ b/server/internal/api/admin_gateway_settings.go @@ -0,0 +1,149 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/ponzischeme89/memby/server/internal/adminevents" + "github.com/ponzischeme89/memby/server/internal/buildinfo" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// adminGatewaySettingsResponse is deliberately three things at once: what the operator has +// chosen, what the container was started with, and what is therefore in force. A settings +// page that showed only the first would leave every empty field looking like a value of +// nothing, when an empty field here means "whatever .env says" — and the operator has no +// other way to see what that is without opening a file on the NAS. +type adminGatewaySettingsResponse struct { + Settings store.GatewaySettings `json:"settings"` + Deployed deployedGatewaySettings `json:"deployed"` + Effective deployedGatewaySettings `json:"effective"` + // LogLevels is the vocabulary rather than a list the console keeps its own copy of, + // for the reason the preference catalogue is served with the accounts page: a value + // the server would refuse must never be offerable. + LogLevels []string `json:"logLevels"` + // Version and Timezone name the process this page is about, so the page can identify + // the gateway it is changing without a second request. + Version string `json:"version"` +} + +func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse { + settings := s.gatewaySettings.get() + return adminGatewaySettingsResponse{ + Settings: settings, + Deployed: s.deployedSettings(), + Effective: deployedGatewaySettings{ + Timezone: s.householdTimezoneName(), + LogLevel: s.effectiveLogLevel(), + SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)), + SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute), + RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute), + EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second), + }, + LogLevels: store.GatewayLogLevels, + Version: buildinfo.Version(), + } +} + +func (s *Server) effectiveLogLevel() string { + if s.logLevel != nil { + return levelName(s.logLevel.Level()) + } + return levelName(s.deployedLogLevel) +} + +// handleAdminGatewaySettings serves the page and accepts a write on the same path, which +// is the shape the *arr policy routes already take. +func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, s.gatewaySettingsResponse()) + return + } + var req store.GatewaySettings + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + previous := s.gatewaySettings.get() + _, operator, _ := s.browserSession(r, adminSessionPurpose) + req.UpdatedBy = operator + stored, err := s.store.SetGatewaySettings(r.Context(), req) + if err != nil { + s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save gateway settings") + return + } + // Applied here rather than waited for: the watcher would pick this up within thirty + // seconds, and an operator who has just turned debug logging on and gone to look at + // the log would spend that half minute believing it had not worked. + s.gatewaySettings.set(stored) + s.applyLogLevel(stored.LogLevel) + + s.loggerFor(r.Context()).Info("gateway settings changed", + "timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(), + "session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)), + "operator", operator) + if summary := gatewaySettingsChanges(previous, stored); summary != "" { + s.publishAdmin(r.Context(), adminevents.Event{ + Type: adminevents.TypeSettingsChanged, + Severity: adminevents.SeverityWarning, + Title: "Gateway settings changed", + Summary: summary, + Actor: operator, + Link: "/admin/settings", + }) + } + writeJSON(w, http.StatusOK, s.gatewaySettingsResponse()) +} + +// gatewaySettingsChanges is what the notification says, and returns empty when nothing +// moved. The console re-posts the whole document, so a save that changed nothing is an +// ordinary event — the preferenceChanges rule, for the same reason: a feed that records +// every press of Save is one nobody reads. +func gatewaySettingsChanges(before, after store.GatewaySettings) string { + changes := []string{} + if before.Timezone != after.Timezone { + changes = append(changes, "timezone") + } + if before.LogLevel != after.LogLevel { + changes = append(changes, "log level") + } + if before.SessionIdleDays != after.SessionIdleDays { + changes = append(changes, "session expiry") + } + if before.SonarrAlertMinutes != after.SonarrAlertMinutes { + changes = append(changes, "episode alert window") + } + if before.RadarrAlertMinutes != after.RadarrAlertMinutes { + changes = append(changes, "film alert window") + } + if before.EmbyHealthSeconds != after.EmbyHealthSeconds { + changes = append(changes, "Emby health probe") + } + switch len(changes) { + case 0: + return "" + case 1: + return "The gateway's " + changes[0] + " was changed" + default: + return "The gateway's " + joinPhrase(changes) + " were changed" + } +} + +func joinPhrase(values []string) string { + switch len(values) { + case 0: + return "" + case 1: + return values[0] + } + out := "" + for index, value := range values[:len(values)-1] { + if index > 0 { + out += ", " + } + out += value + } + return out + " and " + values[len(values)-1] +} diff --git a/server/internal/api/admin_integrations.go b/server/internal/api/admin_integrations.go index 7fcbcce..9d26fb8 100644 --- a/server/internal/api/admin_integrations.go +++ b/server/internal/api/admin_integrations.go @@ -30,6 +30,8 @@ type integrationEvent struct { var integrationEventCatalogue = []integrationEvent{ {adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"}, {adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"}, + {adminevents.TypeDeviceFirstUse, "First use today", "A television opened Memby for the first time that day.", "Access"}, + {adminevents.TypeDeviceUpdated, "App updated", "A television finished updating to a new build.", "Access"}, {adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"}, {adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"}, {adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"}, @@ -37,6 +39,7 @@ var integrationEventCatalogue = []integrationEvent{ {adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"}, {adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"}, {adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"}, + {adminevents.TypeSettingsChanged, "Gateway settings changed", "A server-level setting was changed in the console.", "System"}, {adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"}, {adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"}, {adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"}, diff --git a/server/internal/api/admin_recommendations.go b/server/internal/api/admin_recommendations.go index 41b9240..af4715e 100644 --- a/server/internal/api/admin_recommendations.go +++ b/server/internal/api/admin_recommendations.go @@ -131,7 +131,7 @@ func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Reque contextName = "default" } intent := recommend.RankIntent{ - ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation, + ID: "admin:" + contextName, Now: now, Location: s.householdLocation(), HouseholdScores: household, Compatibility: map[string]float64{}, } switch contextName { diff --git a/server/internal/api/alerts.go b/server/internal/api/alerts.go index a13df34..5f46cb9 100644 --- a/server/internal/api/alerts.go +++ b/server/internal/api/alerts.go @@ -180,7 +180,8 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert { // sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row // uses, so polling clients never cost a Sonarr request of their own. func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert { - if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 { + window := s.sonarrAlertWindow() + if !s.sonarrEnabled(ctx) || window <= 0 { return nil } row, err := s.sonarrAiringTodayRow(ctx) @@ -199,11 +200,7 @@ func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert { } items = append(items, item) } - location := s.cfg.SonarrLocation - if location == nil { - location = time.Local - } - return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow) + return buildSonarrAlerts(items, time.Now().In(s.householdLocation()), window) } // buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 388e4dc..1584ccb 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -99,7 +99,14 @@ type Server struct { recommendationBuilds recommendationBuilds maintenance maintenanceState quietTime quietTimeState - updatePolicy updatePolicyCache + // gatewaySettings is the operator's runtime amendment to what the container was + // started with. deployedLogLevel is remembered beside the live level variable + // because clearing an override has to restore something, and the level variable + // itself has by then been moved. + gatewaySettings gatewaySettingsState + logLevel *slog.LevelVar + deployedLogLevel slog.Level + updatePolicy updatePolicyCache // embyHealth is the reachability probe's live finding, which /v1/status publishes so // a TV can show why playback stopped even if it missed the announcement. embyHealth embyHealth @@ -129,6 +136,10 @@ type Deps struct { AdminEvents *adminevents.Bus Scheduler *scheduler.Scheduler Integrations *integrations.Dispatcher + // LogLevel is the live level of the process's own logger, so the console can turn + // debug on and watch the thing it turned it on for. Nil is allowed and means the + // level is fixed at whatever the container was started with. + LogLevel *slog.LevelVar } func New(cfg config.Config, deps Deps) *Server { @@ -152,9 +163,19 @@ func New(cfg config.Config, deps Deps) *Server { adminEvents: deps.AdminEvents, scheduler: deps.Scheduler, integrations: deps.Integrations, + + logLevel: deps.LogLevel, + deployedLogLevel: deployedLevel(deps.LogLevel), } } +func deployedLevel(level *slog.LevelVar) slog.Level { + if level == nil { + return slog.LevelInfo + } + return level.Level() +} + // publishAdmin reports something an operator would want to know about. // // Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is @@ -409,6 +430,9 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor s.log.Warn("device version record failed", "device_id", sess.DeviceID, "error", err) } + // And it is the only place an operator would otherwise learn that an update + // they offered was actually taken. + s.announceDeviceUpdate(r.Context(), sess, previousVersion) } } return sess diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 5587193..aa1f23c 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -297,6 +297,10 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr s.loggerFor(r.Context()).Warn("device version cleanup failed", "removed_device_id", deviceID, "error", err) } + if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil { + s.loggerFor(r.Context()).Warn("device activity cleanup failed", + "removed_device_id", deviceID, "error", err) + } // A device disappearing from a household is worth a line: the next thing that TV // reports is a sign-in, and the two together explain each other. s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID) @@ -335,6 +339,9 @@ func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.Su if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil { s.loggerFor(ctx).Warn("device version cleanup failed", "error", err) } + if err := s.store.DeleteDeviceActivityDays(ctx, ids...); err != nil { + s.loggerFor(ctx).Warn("device activity cleanup failed", "error", err) + } s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids) } diff --git a/server/internal/api/calendar.go b/server/internal/api/calendar.go index 9871b2e..660bad1 100644 --- a/server/internal/api/calendar.go +++ b/server/internal/api/calendar.go @@ -86,10 +86,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store. } func (s *Server) sonarrLocation() *time.Location { - if s.cfg.SonarrLocation != nil { - return s.cfg.SonarrLocation - } - return time.Local + return s.householdLocation() } func (s *Server) sonarrCalendarMonth( diff --git a/server/internal/api/device_activity.go b/server/internal/api/device_activity.go new file mode 100644 index 0000000..d4b762f --- /dev/null +++ b/server/internal/api/device_activity.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/ponzischeme89/memby/server/internal/adminevents" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// noteDailyFirstUse announces the first time a television opened Memby on a given day. +// +// It is called from the home request rather than from the auth middleware, which every +// call passes through, because "opened Memby" means the launcher composing. A set left on +// overnight polls /v1/status every ten seconds, so anchoring on any authenticated request +// would announce that set at midnight — an event nobody did, timed to the hour nobody is +// reading the feed. +// +// It is called before the cached response is served, deliberately: a household whose home +// row cache is still warm from the last set to switch on has still just been opened by +// this one, and the mark is a single indexed insert either way. +// +// Everything about it is best-effort. It cannot fail a launcher. +func (s *Server) noteDailyFirstUse(ctx context.Context, sess store.Session) { + if s.store == nil || sess.DeviceID == "" { + return + } + first, err := s.store.MarkDeviceDay( + ctx, sess.DeviceID, sess.EmbyUserID, time.Now().In(s.sonarrLocation()), + ) + if err != nil { + s.loggerFor(ctx).Warn("device day mark failed", + "device_id", sess.DeviceID, "error", err) + return + } + if !first { + return + } + s.loggerFor(ctx).Info("first use today", + "device_id", sess.DeviceID, "client", clientLogValue(sess.ClientVersion)) + s.publishAdmin(ctx, adminevents.Event{ + Type: adminevents.TypeDeviceFirstUse, + Title: "Opened Memby", + Summary: fmt.Sprintf("%s opened Memby on %s for the first time today", + displayName(sess.Username), displayName(sess.DeviceName)), + Actor: sess.Username, Target: sess.DeviceName, + Link: "/admin/devices/" + url.PathEscape(sess.DeviceID), + Metadata: adminevents.Meta(map[string]any{ + "deviceId": sess.DeviceID, "userId": sess.EmbyUserID, + "version": sess.ClientVersion, + }), + }) +} + +// announceDeviceUpdate reports a television that has finished updating itself. +// +// A set that updates in place never signs in again, so the first request carrying the new +// build is the only evidence there is that the update worked — and an update that was +// offered and never taken looks exactly like one that was, until this arrives. +// +// `from` being empty is not an update: it is a television this gateway had never been +// told the build of, which is an old APK or a session predating identity capture, and +// announcing it as an upgrade would claim a version moved when nothing is known to have +// moved. A version that went *backwards* is still announced, because a sideloaded +// downgrade is news an operator wants at least as much as an upgrade. +func (s *Server) announceDeviceUpdate(ctx context.Context, sess store.Session, from string) { + if from == "" || sess.ClientVersion == "" || from == sess.ClientVersion { + return + } + s.loggerFor(ctx).Info("client build changed", + "device_id", sess.DeviceID, "from", from, "to", sess.ClientVersion) + s.publishAdmin(ctx, adminevents.Event{ + Type: adminevents.TypeDeviceUpdated, + Title: "App updated", + Summary: fmt.Sprintf("%s updated from %s to %s", + displayName(sess.DeviceName), from, sess.ClientVersion), + Actor: sess.Username, Target: sess.DeviceName, + Link: "/admin/devices/" + url.PathEscape(sess.DeviceID), + Metadata: adminevents.Meta(map[string]any{ + "deviceId": sess.DeviceID, "userId": sess.EmbyUserID, + "from": from, "to": sess.ClientVersion, + }), + }) +} diff --git a/server/internal/api/emby_health.go b/server/internal/api/emby_health.go index 6d24601..c4e86ac 100644 --- a/server/internal/api/emby_health.go +++ b/server/internal/api/emby_health.go @@ -63,6 +63,28 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) { } } +// retune follows the operator changing the probe's cadence, or switching it off, without +// disturbing what the probe has already found. It is deliberately not `begin`: that +// starts a fresh watch and drops the Emby version with it, which the About page reads and +// which is still true whatever the interval is now. +func (h *embyHealth) retune(interval time.Duration, now time.Time) { + h.mu.Lock() + defer h.mu.Unlock() + if interval <= 0 { + h.state.monitored = false + h.state.retryEvery = 0 + return + } + if !h.state.monitored { + h.state = embyHealthState{ + monitored: true, reachable: true, since: now, retryEvery: interval, + version: h.state.version, + } + return + } + h.state.retryEvery = interval +} + // record folds one probe result in and reports whether the published verdict changed. func (h *embyHealth) record(ok bool, version string, now time.Time) { h.mu.Lock() diff --git a/server/internal/api/gateway_settings.go b/server/internal/api/gateway_settings.go new file mode 100644 index 0000000..c68ddd0 --- /dev/null +++ b/server/internal/api/gateway_settings.go @@ -0,0 +1,186 @@ +package api + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/logging" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// gatewaySettingsState caches the operator's overrides in memory, the way maintenance is +// cached: several of these are read on the request path — the household's timezone is +// read by every home response — and none of them is worth a query. +type gatewaySettingsState struct { + mu sync.RWMutex + value store.GatewaySettings +} + +func (g *gatewaySettingsState) get() store.GatewaySettings { + g.mu.RLock() + defer g.mu.RUnlock() + return g.value +} + +func (g *gatewaySettingsState) set(value store.GatewaySettings) { + g.mu.Lock() + defer g.mu.Unlock() + g.value = value +} + +// LoadGatewaySettings primes the cache and applies the settings that live in the running +// process rather than being read where they are used. Called at boot and after a write. +func (s *Server) LoadGatewaySettings(ctx context.Context) error { + if s.store == nil { + return nil + } + settings, err := s.store.GatewaySettings(ctx) + if err != nil { + return err + } + s.gatewaySettings.set(settings) + s.applyLogLevel(settings.LogLevel) + return nil +} + +// WatchGatewaySettings re-reads the overrides periodically, so a change made directly in +// the database — or by another instance — is picked up without a restart. The same reason +// WatchMaintenance exists. +func (s *Server) WatchGatewaySettings(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.LoadGatewaySettings(ctx); err != nil { + s.log.Warn("gateway settings refresh failed", + "component", "settings", "error", err) + } + } + } +} + +// applyLogLevel moves the running process's log level. It is a no-op on a gateway wired +// without a level variable — every unit test in this package — and an empty override +// restores the level the container was started with, which is what makes clearing the +// setting in the console a real undo rather than a value the operator has to remember. +func (s *Server) applyLogLevel(level string) { + if s.logLevel == nil { + return + } + if level == "" { + s.logLevel.Set(s.deployedLogLevel) + return + } + s.logLevel.Set(logging.ParseLevel(level)) +} + +// --- effective values ------------------------------------------------------- +// +// Each of these is "the override, or what was deployed". They are the only readers of the +// cached document, so a setting is added by adding one of these beside its config field +// rather than by teaching every call site that an override exists. + +// householdLocation is the household's idea of what day it is. Everything that groups by +// a local day reads it: the schedule rows, the hero rotation, sign-in history and the +// first-use notification. +func (s *Server) householdLocation() *time.Location { + if name := s.gatewaySettings.get().Timezone; name != "" { + if location, err := time.LoadLocation(name); err == nil { + return location + } + } + if s.cfg.SonarrLocation != nil { + return s.cfg.SonarrLocation + } + return time.Local +} + +// householdTimezoneName is what the console and the sign-in history print. It names the +// zone in force rather than the one deployed, so a page cannot claim a grouping that is +// not the one the rows were grouped by. +func (s *Server) householdTimezoneName() string { + return s.householdLocation().String() +} + +func (s *Server) sessionIdleExpiry() time.Duration { + if days := s.gatewaySettings.get().SessionIdleDays; days > 0 { + return time.Duration(days) * 24 * time.Hour + } + return s.cfg.SessionIdleExpiry +} + +func (s *Server) sonarrAlertWindow() time.Duration { + return overrideWindow(s.gatewaySettings.get().SonarrAlertMinutes, time.Minute, + s.cfg.SonarrAlertWindow) +} + +func (s *Server) radarrAlertWindow() time.Duration { + return overrideWindow(s.gatewaySettings.get().RadarrAlertMinutes, time.Minute, + s.cfg.RadarrAlertWindow) +} + +func (s *Server) embyHealthInterval() time.Duration { + return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second, + s.cfg.EmbyHealthInterval) +} + +// overrideWindow reads one of the three settings that can be switched off: a negative +// value is off, zero is "whatever was deployed", anything else is the override in the +// given unit. +func overrideWindow(value int, unit, deployed time.Duration) time.Duration { + switch { + case value < 0: + return 0 + case value > 0: + return time.Duration(value) * unit + default: + return deployed + } +} + +// deployedGatewaySettings describes what the container was started with, so the console +// can show the value a cleared field falls back to. Deliberately not the same shape as +// the overrides: these are facts, not choices, and nothing may write them back. +type deployedGatewaySettings struct { + Timezone string `json:"timezone"` + LogLevel string `json:"logLevel"` + SessionIdleDays int `json:"sessionIdleDays"` + SonarrAlertMinutes int `json:"sonarrAlertMinutes"` + RadarrAlertMinutes int `json:"radarrAlertMinutes"` + EmbyHealthSeconds int `json:"embyHealthSeconds"` +} + +func (s *Server) deployedSettings() deployedGatewaySettings { + timezone := "" + if s.cfg.SonarrLocation != nil { + timezone = s.cfg.SonarrLocation.String() + } + return deployedGatewaySettings{ + Timezone: timezone, + LogLevel: levelName(s.deployedLogLevel), + SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)), + SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute), + RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute), + EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second), + } +} + +func levelName(level slog.Level) string { + switch { + case level < slog.LevelDebug: + return "trace" + case level < slog.LevelInfo: + return "debug" + case level < slog.LevelWarn: + return "info" + case level < slog.LevelError: + return "warn" + default: + return "error" + } +} diff --git a/server/internal/api/gateway_settings_test.go b/server/internal/api/gateway_settings_test.go new file mode 100644 index 0000000..70f6470 --- /dev/null +++ b/server/internal/api/gateway_settings_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "log/slog" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/logging" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// The three meanings a duration-like override carries. Zero is not "off" — that is the +// whole point of the -1, and reading it as off would silently disable an alert window an +// operator had merely left alone. +func TestOverrideWindowSeparatesOffFromDeployed(t *testing.T) { + deployed := 15 * time.Minute + if got := overrideWindow(0, time.Minute, deployed); got != deployed { + t.Fatalf("zero should fall back to the deployed value, got %s", got) + } + if got := overrideWindow(store.GatewaySettingsOff, time.Minute, deployed); got != 0 { + t.Fatalf("a negative override should switch the window off, got %s", got) + } + if got := overrideWindow(45, time.Minute, deployed); got != 45*time.Minute { + t.Fatalf("a positive override should be taken in the given unit, got %s", got) + } +} + +// The probe loop must keep ticking while it is switched off, or turning it back on would +// need a restart of the gateway — which is the thing the setting exists to avoid. +func TestEmbyProbeDelayKeepsTickingWhileOff(t *testing.T) { + if got := embyProbeDelay(0); got != time.Minute { + t.Fatalf("a switched-off probe should still wake up, got %s", got) + } + if got := embyProbeDelay(30 * time.Second); got != 30*time.Second { + t.Fatalf("a live probe should wait its interval, got %s", got) + } +} + +func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) { + before := store.GatewaySettings{Timezone: "Pacific/Auckland", SessionIdleDays: 30} + if summary := gatewaySettingsChanges(before, before); summary != "" { + t.Fatalf("an unchanged save must not be news, got %q", summary) + } + one := before + one.LogLevel = "debug" + if summary := gatewaySettingsChanges(before, one); summary != "The gateway's log level was changed" { + t.Fatalf("one change reads wrong: %q", summary) + } + two := one + two.SessionIdleDays = 60 + summary := gatewaySettingsChanges(before, two) + if summary != "The gateway's log level and session expiry were changed" { + t.Fatalf("two changes read wrong: %q", summary) + } +} + +func TestLevelNameCoversTheVocabulary(t *testing.T) { + for _, level := range store.GatewayLogLevels { + if got := levelName(parseTestLevel(t, level)); got != level { + t.Fatalf("%s round-tripped as %s", level, got) + } + } +} + +func parseTestLevel(t *testing.T, name string) slog.Level { + t.Helper() + return logging.ParseLevel(name) +} diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go index 1231fb0..0a4e2a4 100644 --- a/server/internal/api/hero.go +++ b/server/internal/api/hero.go @@ -1055,10 +1055,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he if s.sonarr == nil || s.store == nil { return nil } - location := s.cfg.SonarrLocation - if location == nil { - location = time.Local - } + location := s.householdLocation() dayStart := localDayStart(now.In(location), location) key := heroPremiereCachePrefix + dayStart.Format("2006-01-02") diff --git a/server/internal/api/hero_admin.go b/server/internal/api/hero_admin.go index 91c79b6..228a8ff 100644 --- a/server/internal/api/hero_admin.go +++ b/server/internal/api/hero_admin.go @@ -97,20 +97,61 @@ func heroScheduleTimeZone(location *time.Location) string { return location.String() } +// heroSearchLimit is what either half of the picker's search may contribute, and what the +// merged answer is trimmed back to. +const heroSearchLimit = 20 + +// handleAdminHeroSearch answers the picker from the imported catalogue and from Emby. +// +// The catalogue alone is up to a sync interval stale, so a film imported this afternoon +// was simply not findable here until the next hourly pass — and pinning is the one hero +// decision an operator makes about a title *because* it has just arrived. Emby is asked +// as well and `Find` imports what it returns, which is what makes a fresh id usable by the +// policy validation and by the hero row itself rather than only by this list. +// +// Either half may fail without failing the search: a stale answer and a live one are both +// better than an error, and the two are deliberately asked in that order so a gateway with +// no Emby credentials configured still has a picker. func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) { - items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20) + term := strings.TrimSpace(r.URL.Query().Get("q")) + items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit) if err != nil { s.loggerFor(r.Context()).Error("hero library search failed", "error", err) writeError(w, http.StatusInternalServerError, "could not search the library") return } - results := make([]heroAdminItem, 0, len(items)) - for _, raw := range items { - if item, ok := adminHeroItem(raw); ok { - results = append(results, item) + if s.syncer != nil && term != "" { + found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit) + if findErr != nil { + s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr) } + items = append(items, found...) } - writeJSON(w, http.StatusOK, map[string]any{"items": results}) + writeJSON(w, http.StatusOK, map[string]any{"items": mergeHeroSearchResults(items, heroSearchLimit)}) +} + +// mergeHeroSearchResults turns both halves of the search into one list. +// +// Almost every title comes back from both, so the dedupe is the ordinary case rather than +// the exception, and it keeps the first sighting: the catalogue answers first, and its +// ranking is the one an operator has been reading all along. Anything an id appears in +// only once is either a title Emby has and the last import missed — the whole point — or +// one deleted from Emby that the catalogue has not swept yet. +func mergeHeroSearchResults(items []json.RawMessage, limit int) []heroAdminItem { + results := make([]heroAdminItem, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, raw := range items { + if len(results) >= limit { + break + } + item, ok := adminHeroItem(raw) + if !ok || seen[item.ID] { + continue + } + seen[item.ID] = true + results = append(results, item) + } + return results } func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/api/hero_admin_test.go b/server/internal/api/hero_admin_test.go new file mode 100644 index 0000000..c252145 --- /dev/null +++ b/server/internal/api/hero_admin_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func heroSearchPayload(id, name, itemType string) json.RawMessage { + return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + `"}`) +} + +// The picker asks two sources that mostly agree, so one title arriving twice is the +// ordinary case rather than a fault, and the catalogue's ranking is the one kept. +func TestHeroSearchMergeKeepsTheFirstSighting(t *testing.T) { + merged := mergeHeroSearchResults([]json.RawMessage{ + heroSearchPayload("1", "Arrival", "Movie"), + heroSearchPayload("2", "Severance", "Series"), + heroSearchPayload("1", "Arrival", "Movie"), + }, 20) + + if len(merged) != 2 { + t.Fatalf("merged %d titles, want 2", len(merged)) + } + if merged[0].ID != "1" || merged[1].ID != "2" { + t.Fatalf("merge reordered the catalogue's answer: %+v", merged) + } +} + +// The whole reason Emby is asked: a title imported since the last pass is in one half of +// the answer only, and it must survive into the list an operator can pin from. +func TestHeroSearchMergeAdoptsATitleTheCatalogueMissed(t *testing.T) { + merged := mergeHeroSearchResults([]json.RawMessage{ + heroSearchPayload("1", "Arrival", "Movie"), + heroSearchPayload("1", "Arrival", "Movie"), + heroSearchPayload("9", "Arrival of the Birds", "Movie"), + }, 20) + + if len(merged) != 2 || merged[1].ID != "9" { + t.Fatalf("a title only Emby knew about was dropped: %+v", merged) + } +} + +// A hero is a film or a show. An episode matching the term, and a synthetic schedule card +// that cannot be played at all, are both things the picker must not offer. +func TestHeroSearchMergeOffersOnlyPinnableTitles(t *testing.T) { + merged := mergeHeroSearchResults([]json.RawMessage{ + heroSearchPayload("1", "Severance S01E01", "Episode"), + json.RawMessage(`{"Id":"2","Name":"Dune","Type":"Movie","MembySource":"radarr"}`), + heroSearchPayload("3", "Dune", "Movie"), + }, 20) + + if len(merged) != 1 || merged[0].ID != "3" { + t.Fatalf("picker offered something unpinnable: %+v", merged) + } +} + +func TestHeroSearchMergeTrimsToTheLimit(t *testing.T) { + items := make([]json.RawMessage, 0, 30) + for index := range 30 { + items = append(items, heroSearchPayload(string(rune('a'+index)), "Title", "Movie")) + } + + if merged := mergeHeroSearchResults(items, 20); len(merged) != 20 { + t.Fatalf("merged %d titles, want the limit of 20", len(merged)) + } +} diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 3fbf1c8..9302fce 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -69,6 +69,7 @@ type homeResponse struct { // handleHome answers the entire launcher in one round trip. func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() + s.noteDailyFirstUse(ctx, sess) limit := queryInt(r, "limit", 24, 100) sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r) radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r) @@ -203,10 +204,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S wg.Add(1) go func() { defer wg.Done() - location := s.cfg.SonarrLocation - if location == nil { - location = time.UTC - } + location := s.householdLocation() window := homeForYouWindowAt(time.Now().In(location)) prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes) if err != nil { diff --git a/server/internal/api/housekeeping.go b/server/internal/api/housekeeping.go index 81ef1da..8fdb30a 100644 --- a/server/internal/api/housekeeping.go +++ b/server/internal/api/housekeeping.go @@ -51,6 +51,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) { }, }) + sched.Register(scheduler.Task{ + ID: "device-activity-cleanup", + Name: "Device activity cleanup", + Group: "Housekeeping", + Description: fmt.Sprintf( + "Removes the daily first-use marks that are older than %d days.", + int(store.DeviceActivityRetention/(24*time.Hour))), + Interval: 24 * time.Hour, + Run: func(ctx context.Context) (string, error) { + removed, err := s.store.PruneDeviceActivityDays(ctx, store.DeviceActivityRetention) + return countDetail(removed, "activity mark"), err + }, + }) + sched.Register(scheduler.Task{ ID: "integration-cleanup", Name: "Integration delivery cleanup", @@ -103,10 +117,13 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) { Group: "Housekeeping", Description: fmt.Sprintf( "Retires gateway tokens unused for %d days, along with the Emby token each one holds.", - int(s.cfg.SessionIdleExpiry/(24*time.Hour))), + int(s.sessionIdleExpiry()/(24*time.Hour))), Interval: 6 * time.Hour, Run: func(ctx context.Context) (string, error) { - removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.SessionIdleExpiry) + // Read at run time rather than closed over: the description above is written + // once when the task is registered, but the sweep itself must follow the + // operator's setting without a restart. + removed, err := s.store.DeleteIdleSessions(ctx, s.sessionIdleExpiry()) return countDetail(removed, "idle session"), err }, }) diff --git a/server/internal/api/logins.go b/server/internal/api/logins.go index 7774e00..6aaa0f2 100644 --- a/server/internal/api/logins.go +++ b/server/internal/api/logins.go @@ -87,10 +87,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time { if parsed, err := time.Parse(time.RFC3339, raw); err == nil { return parsed } - location := s.cfg.SonarrLocation - if location == nil { - location = time.UTC - } + location := s.householdLocation() parsed, err := time.ParseInLocation("2006-01-02", raw, location) if err != nil { return time.Time{} @@ -105,10 +102,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time { // asked to do the grouping in it rather than the console doing it in the browser's zone, // for the reason store.LoginDays gives. func (s *Server) zoneName() string { - if s.cfg.SonarrLocation != nil { - return s.cfg.SonarrLocation.String() - } - return "UTC" + return s.householdTimezoneName() } type adminLoginsResponse struct { diff --git a/server/internal/api/radarr.go b/server/internal/api/radarr.go index f4d3ca3..2b47f04 100644 --- a/server/internal/api/radarr.go +++ b/server/internal/api/radarr.go @@ -13,9 +13,23 @@ import ( ) const radarrCalendarCachePrefix = "radarr:calendar:v3:" -const radarrScheduleDays = 5 + +// radarrScheduleDays is a month where the Sonarr schedule row's is a week, because films +// and episodes arrive at quite different rates. A household's Sonarr calendar fills a week +// several times over; its Radarr calendar, measured against the real catalogue, yields +// about one title a fortnight — at five days the row was empty or held a single card most +// of the time, which reads as a broken shelf rather than a quiet month. +const radarrScheduleDays = 30 + +// radarrTheatricalDelayDays is the median cinema-to-digital gap for recent releases, used +// only to estimate a digital date Radarr has not published. It shares a figure with +// radarrScheduleDays by coincidence, not by meaning — they are free to move apart. const radarrTheatricalDelayDays = 30 +// radarrWeekdayLabelDays is how far out a bare weekday still names an unambiguous day. +// Beyond it the label carries a date, or a release five weeks away reads as this Friday. +const radarrWeekdayLabelDays = 6 + type radarrRelease struct { at time.Time estimated bool @@ -185,7 +199,7 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti } localRelease := release.at.In(location) item.MembyAirsAt = localRelease.Format(time.RFC3339) - item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location) + item.MembyAirDayLabel = radarrReleaseDayLabel(localRelease, now, location) item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated) switch { case movie.HasFile: @@ -205,11 +219,27 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti return item } +// radarrReleaseDayLabel is the card's own day chip. It is Radarr's rather than +// scheduleAirDayLabel because that one answers for a seven-day Sonarr window, where every +// date it can be handed is inside the coming week and a weekday is never ambiguous. +func radarrReleaseDayLabel(release, now time.Time, location *time.Location) string { + today := localDayStart(now.In(location), location) + releaseDay := localDayStart(release.In(location), location) + switch { + case releaseDay.Equal(today): + return "Today" + case releaseDay.Equal(today.AddDate(0, 0, 1)): + return "Tomorrow" + case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)): + return releaseDay.Format("Monday") + default: + return releaseDay.Format("2 Jan") + } +} + func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string { - release = release.In(location) - now = now.In(location) - today := localDayStart(now, location) - releaseDay := localDayStart(release, location) + today := localDayStart(now.In(location), location) + releaseDay := localDayStart(release.In(location), location) prefix := "Digital release " if estimated { prefix = "Estimated digital release " @@ -219,8 +249,10 @@ func digitalReleaseLabel(release, now time.Time, location *time.Location, estima return prefix + "today" case releaseDay.Equal(today.AddDate(0, 0, 1)): return prefix + "tomorrow" + case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)): + return prefix + releaseDay.Format("Monday") default: - return prefix + release.Format("Monday") + return prefix + releaseDay.Format("2 January") } } diff --git a/server/internal/api/radarr_alerts.go b/server/internal/api/radarr_alerts.go index a47fe9e..5e1eed7 100644 --- a/server/internal/api/radarr_alerts.go +++ b/server/internal/api/radarr_alerts.go @@ -73,12 +73,13 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) return } - if s.cfg.RadarrAlertWindow <= 0 { + window := s.radarrAlertWindow() + if window <= 0 { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) return } - s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow) + s.publishAlert(r.Context(), alert, window) s.loggerFor(r.Context()).Info("radarr import announced", "movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true}) diff --git a/server/internal/api/radarr_test.go b/server/internal/api/radarr_test.go index 3666f53..5619bb5 100644 --- a/server/internal/api/radarr_test.go +++ b/server/internal/api/radarr_test.go @@ -8,50 +8,91 @@ import ( "github.com/ponzischeme89/memby/server/internal/radarr" ) -func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) { +func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindow(t *testing.T) { location := time.FixedZone("NZST", 12*60*60) now := time.Date(2026, 7, 30, 9, 0, 0, 0, location) - digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC() - digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC() - outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC() - theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC() - oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC() - modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC() + day := func(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, location).UTC() + } + digitalToday := day(2026, 7, 30) + digitalSunday := day(2026, 8, 2) + // Three weeks out: inside the month window, past the point a weekday still names a day. + digitalLater := day(2026, 8, 20) + // The day the window closes, which is exclusive. + beyondWindow := day(2026, 8, 29) + theatricalOnly := day(2026, 7, 1) + oldDigital := day(1993, 4, 9) + modernRerelease := day(2026, 7, 2) row, err := buildRadarrRow([]radarr.Movie{ {ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true}, {ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true}, - {ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true}, + {ID: 3, Title: "Three Weeks Out", DigitalRelease: &digitalLater, Monitored: true}, {ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true}, {ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true}, + {ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true}, }, now, location) if err != nil { t.Fatal(err) } if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" || - row.Title != "Upcoming Movie releases" || len(row.Items) != 3 { + row.Title != "Upcoming Movie releases" || len(row.Items) != 4 { t.Fatalf("unexpected row: %+v", row) } - var first, second radarrScheduleItem - if err := json.Unmarshal(row.Items[0], &first); err != nil { - t.Fatal(err) + + items := make([]radarrScheduleItem, len(row.Items)) + for i, raw := range row.Items { + if err := json.Unmarshal(raw, &items[i]); err != nil { + t.Fatal(err) + } } - if err := json.Unmarshal(row.Items[1], &second); err != nil { - t.Fatal(err) + want := []struct { + id, airLabel, dayLabel string + }{ + {"radarr:1", "Digital release today", "Today"}, + {"radarr:4", "Estimated digital release tomorrow", "Tomorrow"}, + {"radarr:2", "Digital release Sunday", "Sunday"}, + {"radarr:3", "Digital release 20 August", "20 Aug"}, } - if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" { - t.Fatalf("unexpected first item: %+v", first) + for i, expect := range want { + got := items[i] + if got.ID != expect.id || got.MembyAirLabel != expect.airLabel || + got.MembyAirDayLabel != expect.dayLabel { + t.Fatalf("item %d: got id=%s air=%q day=%q, want id=%s air=%q day=%q", + i, got.ID, got.MembyAirLabel, got.MembyAirDayLabel, + expect.id, expect.airLabel, expect.dayLabel) + } } - if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" || - second.MembyAvailabilityText != "Estimated digital release" { - t.Fatalf("unexpected second item: %+v", second) + if items[1].MembyAvailabilityText != "Estimated digital release" { + t.Fatalf("unexpected estimated availability: %+v", items[1]) } - var third radarrScheduleItem - if err := json.Unmarshal(row.Items[2], &third); err != nil { - t.Fatal(err) +} + +// A weekday only names an unambiguous day inside the coming week. The month-long window +// routinely holds dates past that, where "Friday" would read as this Friday. +func TestRadarrReleaseLabelsCarryADateBeyondTheComingWeek(t *testing.T) { + location := time.FixedZone("NZST", 12*60*60) + now := time.Date(2026, 7, 30, 9, 0, 0, 0, location) + cases := []struct { + offset int + dayLabel string + airLabel string + }{ + {0, "Today", "Digital release today"}, + {1, "Tomorrow", "Digital release tomorrow"}, + {2, "Saturday", "Digital release Saturday"}, + {radarrWeekdayLabelDays, "Wednesday", "Digital release Wednesday"}, + {radarrWeekdayLabelDays + 1, "6 Aug", "Digital release 6 August"}, + {21, "20 Aug", "Digital release 20 August"}, } - if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" { - t.Fatalf("unexpected third item: %+v", third) + for _, tc := range cases { + release := now.AddDate(0, 0, tc.offset) + if got := radarrReleaseDayLabel(release, now, location); got != tc.dayLabel { + t.Errorf("offset %d: day label = %q, want %q", tc.offset, got, tc.dayLabel) + } + if got := digitalReleaseLabel(release, now, location, false); got != tc.airLabel { + t.Errorf("offset %d: air label = %q, want %q", tc.offset, got, tc.airLabel) + } } } diff --git a/server/internal/api/ranking.go b/server/internal/api/ranking.go index c5de3b8..6b16eb1 100644 --- a/server/internal/api/ranking.go +++ b/server/internal/api/ranking.go @@ -169,7 +169,7 @@ func (s *Server) personalizeTitles( profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID) cfg := s.weightedConfig() now := time.Now() - location := s.cfg.SonarrLocation + location := s.householdLocation() for index := range rows { row := &rows[index] if progressRow(row.ID) { diff --git a/server/internal/api/server_alerts.go b/server/internal/api/server_alerts.go index d5d19e5..185deef 100644 --- a/server/internal/api/server_alerts.go +++ b/server/internal/api/server_alerts.go @@ -112,27 +112,34 @@ func librarySyncTitle(changed int) string { // // Only *transitions* are announced. A server that is down stays down, and repeating it // every minute would bury everything else. -func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Duration) { - if interval <= 0 { - return - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - +// The cadence is read on every tick rather than fixed at start-up, because it is an +// operator setting the console can change: a loop that captured it would leave the one +// probe an operator is most likely to want to slow down or switch off needing a restart +// of the gateway to do either. A probe that is off still ticks, slowly, so that turning +// it back on does not need one either. +func (s *Server) WatchEmbyReachability(ctx context.Context) { // Start out assuming reachable: a gateway booting while Emby is down should not // open with a banner about a state nobody has seen change. reachable := true failures := 0 + interval := s.embyHealthInterval() // The same probe feeds the live state on /v1/status, so one request to Emby answers // both "did this just change" and "is it working right now". Declared before the // first tick so a client asking during the opening minute learns the retry interval. - s.embyHealth.begin(interval, time.Now().UTC()) + s.embyHealth.retune(interval, time.Now().UTC()) + timer := time.NewTimer(embyProbeDelay(interval)) + defer timer.Stop() for { select { case <-ctx.Done(): return - case <-ticker.C: - if s.quietTimeActive() { + case <-timer.C: + if next := s.embyHealthInterval(); next != interval { + interval = next + s.embyHealth.retune(interval, time.Now().UTC()) + } + timer.Reset(embyProbeDelay(interval)) + if interval <= 0 || s.quietTimeActive() { continue } probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -160,6 +167,16 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati } } +// embyProbeDelay is how long to wait before looking again. With the probe switched off it +// is the interval at which the loop asks whether it has been switched back on, which is a +// minute because nothing is watching and nothing is being asked of Emby. +func embyProbeDelay(interval time.Duration) time.Duration { + if interval <= 0 { + return time.Minute + } + return interval +} + // reachabilityAlert is timestamped per transition, so the "back online" banner never // collides with the "not responding" one it replaces. func (s *Server) reachabilityAlert(up bool) clientAlert { diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 0665a48..9c4d21f 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.50 +0.1.52 diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go index d010aaf..4860e83 100644 --- a/server/internal/library/syncer.go +++ b/server/internal/library/syncer.go @@ -273,6 +273,66 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) { }, nil } +// Find searches Emby for a term and imports whatever comes back, answering with the +// payloads as they were stored. +// +// The catalogue is a copy, and a copy is only ever as fresh as the last import — up to +// MEMBY_SYNC_INTERVAL behind, an hour by default. That is invisible to a viewer, whose +// rows are read from Emby live, and very visible to an operator, who cannot pin a film to +// the hero until the gateway has heard of it. So the picker asks Emby itself, and what it +// finds is *adopted* rather than merely displayed: an id that came back from here resolves +// through LibraryItemsByID immediately, which is what the policy validation and the hero +// row both read, so nothing downstream has to know the title arrived early. +// +// It imports with exactly the fields a scheduled pass uses. Writing a thinner payload +// would leave an adopted title missing People, MediaStreams and ProviderIds until Emby +// next reported it changed — which for a film nobody edits again is never. +func (s *Syncer) Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error) { + trimmed := strings.TrimSpace(term) + if trimmed == "" || limit <= 0 { + return nil, nil + } + cred, err := s.credentials(ctx) + if err != nil { + return nil, err + } + page, err := s.emby.Items(ctx, cred, url.Values{ + "SearchTerm": {trimmed}, + "IncludeItemTypes": {syncItemTypes}, + "Recursive": {"true"}, + "Limit": {strconv.Itoa(limit)}, + "Fields": {syncFields}, + "ImageTypeLimit": {"1"}, + "EnableImages": {"true"}, + "EnableImageTypes": {syncImageTypes}, + "EnableTotalRecordCount": {"false"}, + "EnableUserData": {"false"}, + }) + if err != nil { + return nil, fmt.Errorf("library: search emby: %w", err) + } + items := make([]store.LibraryItem, 0, len(page.Items)) + found := make([]json.RawMessage, 0, len(page.Items)) + for _, raw := range page.Items { + item, ok := toLibraryItem(raw) + if !ok { + continue + } + items = append(items, item) + found = append(found, item.Payload) + } + if len(items) == 0 { + return nil, nil + } + // Stamped now, like any other import. A full pass sweeps rows carrying a stamp older + // than the pass itself, so a title adopted while one is running is never its victim. + if _, err := s.store.UpsertLibraryItems(ctx, items, time.Now().UTC()); err != nil { + return nil, err + } + s.log.Info("library search adopted titles", "term", trimmed, "found", len(found)) + return found, nil +} + // Schedule runs an incremental import on an interval until ctx is cancelled. // // New episodes tend to land through the day and films weekly; an hourly incremental pass diff --git a/server/internal/store/device_activity.go b/server/internal/store/device_activity.go new file mode 100644 index 0000000..024aac0 --- /dev/null +++ b/server/internal/store/device_activity.go @@ -0,0 +1,73 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// DeviceActivityRetention is how long the per-day marks are kept. They are not history +// anybody reads — the notification they produced is the record, and that is itself pruned +// at thirty days — so this only has to be long enough that a clock correction or a +// database restored from a backup cannot make yesterday look like a day that never +// happened. +const DeviceActivityRetention = 90 * 24 * time.Hour + +// MarkDeviceDay records that a television was in use on a household-local day, and +// reports whether this is the first time it has been seen today. +// +// The answer comes from the insert rather than from a read followed by a write, because +// every set in the house can reach this at once and two of them racing on the same row +// must not both be told they were first. `ON CONFLICT DO NOTHING` makes the primary key +// the arbiter: exactly one insert affects a row. +// +// It is keyed on the viewer as well as the television because a household set that two +// people sign into is two people opening Memby, and an operator reading the feed wants to +// know which of them it was. +func (s *Store) MarkDeviceDay( + ctx context.Context, deviceID, userID string, day time.Time, +) (bool, error) { + if deviceID == "" { + return false, nil + } + tag, err := s.pool.Exec(ctx, ` + INSERT INTO device_activity_days (device_id, emby_user_id, day) + VALUES ($1, $2, $3) + ON CONFLICT (device_id, emby_user_id, day) DO NOTHING`, + deviceID, userID, day.Format("2006-01-02")) + if err != nil { + return false, fmt.Errorf("store: mark device day: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +// PruneDeviceActivityDays drops marks older than the retention window. +func (s *Store) PruneDeviceActivityDays( + ctx context.Context, retention time.Duration, +) (int64, error) { + if retention <= 0 { + retention = DeviceActivityRetention + } + tag, err := s.pool.Exec(ctx, + `DELETE FROM device_activity_days WHERE first_seen_at < now() - $1::interval`, + fmt.Sprintf("%d seconds", int64(retention.Seconds()))) + if err != nil { + return 0, fmt.Errorf("store: prune device activity days: %w", err) + } + return tag.RowsAffected(), nil +} + +// DeleteDeviceActivityDays retires a television's marks along with the television, the +// way its build history is retired: a set that is gone must not be able to announce a +// first use it can no longer have. +func (s *Store) DeleteDeviceActivityDays(ctx context.Context, deviceIDs ...string) error { + if len(deviceIDs) == 0 { + return nil + } + _, err := s.pool.Exec(ctx, + `DELETE FROM device_activity_days WHERE device_id = ANY($1::text[])`, deviceIDs) + if err != nil { + return fmt.Errorf("store: delete device activity days: %w", err) + } + return nil +} diff --git a/server/internal/store/gateway_settings.go b/server/internal/store/gateway_settings.go new file mode 100644 index 0000000..4b3cce1 --- /dev/null +++ b/server/internal/store/gateway_settings.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// GatewaySettingsKey is the app_settings row backing the gateway's own settings. +const GatewaySettingsKey = "gateway_settings" + +// GatewaySettings is the handful of server-level decisions an operator can change +// without a redeployment. +// +// Every field is an *override* of the value the container was started with, and the zero +// value means "whatever was deployed". That is the whole design: `.env` remains the +// configuration — it is what a fresh container comes up with, what deploy-server.ps1 +// writes and what an operator locked out of the console still has — and this row is a +// runtime amendment to it, so the console can always show the deployed value beside the +// one in force and an operator can put a setting back by clearing it rather than by +// remembering what it used to be. +// +// A window that can legitimately be *off* therefore needs a value distinct from "not +// overridden", which is why the two alert windows and the health interval take -1 for off +// and 0 for deployed. A plain zero would make "turn this off" indistinguishable from +// "leave it alone", and the operator would find the setting quietly ignored. +type GatewaySettings struct { + // Timezone is an IANA name. It decides the household's local day, which is what the + // schedule rows, the hero rotation, the sign-in history and the first-use + // notifications are all grouped by. + Timezone string `json:"timezone"` + // LogLevel is one of trace, debug, info, warn, error. It takes effect immediately on + // the running process, which is the point of it being here: turning debug on to watch + // something happen is worth nothing if it costs a restart of the thing being watched. + LogLevel string `json:"logLevel"` + // SessionIdleDays is how long a television may go unused before it is signed out. + SessionIdleDays int `json:"sessionIdleDays"` + // SonarrAlertMinutes and RadarrAlertMinutes are how long a "just aired" or "new movie + // added" banner stays on offer to a television that was switched off at the time. + SonarrAlertMinutes int `json:"sonarrAlertMinutes"` + RadarrAlertMinutes int `json:"radarrAlertMinutes"` + // EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there. + EmbyHealthSeconds int `json:"embyHealthSeconds"` + + UpdatedAt time.Time `json:"updatedAt"` + UpdatedBy string `json:"updatedBy,omitempty"` +} + +// GatewaySettingsOff is the value a duration-like override takes to mean "switched off", +// as distinct from the zero that means "use the deployed value". +const GatewaySettingsOff = -1 + +// GatewayLogLevels is the vocabulary the console offers, in order of severity. It is here +// rather than in the API because normalisation has to refuse a level nothing can parse, +// and refusing is worth nothing if the list it refuses against lives somewhere else. +var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"} + +func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings { + settings.Timezone = strings.TrimSpace(settings.Timezone) + if settings.Timezone != "" { + // A timezone that will not load is dropped rather than stored: the alternative is + // a row that every later read has to fail on, and the failure would surface as a + // household whose idea of "today" quietly reverted with nothing saying why. + if _, err := time.LoadLocation(settings.Timezone); err != nil { + settings.Timezone = "" + } + } + settings.LogLevel = strings.ToLower(strings.TrimSpace(settings.LogLevel)) + if settings.LogLevel != "" && !containsString(GatewayLogLevels, settings.LogLevel) { + settings.LogLevel = "" + } + settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false) + settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true) + settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true) + settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true) + return settings +} + +// clampOverride keeps 0 meaning "deployed" and, where the setting can be switched off, +// keeps every negative number meaning off rather than only -1 — an operator typing -5 has +// said the same thing, and storing it verbatim would produce a second value with the same +// meaning that every reader would have to know about. +func clampOverride(value, low, high int, offAllowed bool) int { + if value == 0 { + return 0 + } + if value < 0 { + if offAllowed { + return GatewaySettingsOff + } + return 0 + } + return max(low, min(value, high)) +} + +func containsString(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} + +func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) { + var raw []byte + err := s.pool.QueryRow(ctx, + `SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return GatewaySettings{}, nil + } + if err != nil { + return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err) + } + var settings GatewaySettings + if err := json.Unmarshal(raw, &settings); err != nil { + return GatewaySettings{}, fmt.Errorf("store: decode gateway settings: %w", err) + } + return normalizeGatewaySettings(settings), nil +} + +// SetGatewaySettings writes the overrides and returns what was actually stored, because +// normalisation can refuse a value and the console must show what is in force rather than +// what was typed. +func (s *Store) SetGatewaySettings( + ctx context.Context, settings GatewaySettings, +) (GatewaySettings, error) { + settings = normalizeGatewaySettings(settings) + settings.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(settings) + if err != nil { + return GatewaySettings{}, err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_at) + VALUES ($1, $2::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + GatewaySettingsKey, string(raw)) + if err != nil { + return GatewaySettings{}, fmt.Errorf("store: write gateway settings: %w", err) + } + return settings, nil +} diff --git a/server/internal/store/gateway_settings_test.go b/server/internal/store/gateway_settings_test.go new file mode 100644 index 0000000..a97ddbf --- /dev/null +++ b/server/internal/store/gateway_settings_test.go @@ -0,0 +1,47 @@ +package store + +import "testing" + +// Normalisation is what stands between a hand-edited row (or a console built against an +// older vocabulary) and a gateway that cannot decide what day it is. +func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) { + settings := normalizeGatewaySettings(GatewaySettings{ + Timezone: " Not/AZone ", LogLevel: "LOUD", SessionIdleDays: -4, + }) + if settings.Timezone != "" { + t.Fatalf("an unloadable timezone should be dropped, got %q", settings.Timezone) + } + if settings.LogLevel != "" { + t.Fatalf("an unknown level should be dropped, got %q", settings.LogLevel) + } + // Session expiry cannot be switched off — a household with no expiry at all is a + // database full of live Emby tokens — so a negative reads as "leave it alone". + if settings.SessionIdleDays != 0 { + t.Fatalf("session expiry should not be switchable off, got %d", settings.SessionIdleDays) + } +} + +func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) { + settings := normalizeGatewaySettings(GatewaySettings{ + Timezone: "Pacific/Auckland", LogLevel: " Debug ", + SonarrAlertMinutes: -9, RadarrAlertMinutes: 0, EmbyHealthSeconds: 5, + }) + if settings.Timezone != "Pacific/Auckland" { + t.Fatalf("a real zone should survive, got %q", settings.Timezone) + } + if settings.LogLevel != "debug" { + t.Fatalf("a level should be trimmed and folded, got %q", settings.LogLevel) + } + if settings.SonarrAlertMinutes != GatewaySettingsOff { + t.Fatalf("every negative should collapse to the one off value, got %d", + settings.SonarrAlertMinutes) + } + if settings.RadarrAlertMinutes != 0 { + t.Fatalf("zero must keep meaning deployed, got %d", settings.RadarrAlertMinutes) + } + // Below the floor rather than refused: an operator asking for a five-second probe has + // said "as often as possible", and the answer to that is the fastest allowed. + if settings.EmbyHealthSeconds != 10 { + t.Fatalf("a value under the floor should clamp to it, got %d", settings.EmbyHealthSeconds) + } +} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index a891373..4319658 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -57,6 +57,27 @@ CREATE TABLE IF NOT EXISTS device_versions ( CREATE INDEX IF NOT EXISTS device_versions_recent_idx ON device_versions (device_id, last_seen_at DESC); +-- One row per television per day it was used. +-- +-- It exists to answer "is this the first time this set has opened Memby today", which is +-- a question a counter or a timestamp cannot answer safely: every television in the house +-- asks at once, so the answer has to come from the insert itself. The primary key is what +-- makes it atomic — the row either went in, which means first, or it did not. +-- +-- The day is the *household's* local day, computed against MEMBY_TIMEZONE and stored as a +-- plain date, because "today" is a thing the people watching have an opinion about and +-- UTC does not agree with it for most of a New Zealand evening. +CREATE TABLE IF NOT EXISTS device_activity_days ( + device_id TEXT NOT NULL, + emby_user_id TEXT NOT NULL, + day DATE NOT NULL, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (device_id, emby_user_id, day) +); + +CREATE INDEX IF NOT EXISTS device_activity_days_day_idx + ON device_activity_days (day); + -- The imported library. -- -- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to