From 06b6490ac930f3c56b77a16bf68ec5c617c0c439 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 14 Aug 2026 11:47:32 +1200 Subject: [PATCH] 0.2.63 update --- .env.example | 4 +- CHANGELOG.md | 9 -- admin-ui/dist/assets/index-BIKdFbmA.css | 1 - admin-ui/dist/assets/index-BQ_ZF8Re.js | 11 -- admin-ui/dist/assets/index-BpHOHb2g.js | 11 ++ admin-ui/dist/assets/index-CXwJRCVF.css | 1 + admin-ui/dist/index.html | 4 +- admin-ui/src/api/types.ts | 10 ++ admin-ui/src/components/Layout.tsx | 19 +++- admin-ui/src/pages/Features.tsx | 81 +++++-------- admin-ui/src/pages/Integrations.tsx | 63 ++++++++++- admin-ui/src/pages/Logs.tsx | 37 +++--- admin-ui/src/styles.css | 61 +++++++++- app/build.gradle.kts | 5 + .../memby/data/remote/EmbyServiceFactory.kt | 1 + .../data/remote/GatewayServiceFactory.kt | 24 ++++ .../memby/diagnostics/MembyDiagnostics.kt | 34 ++++++ .../memby/ui/player/PlayerActivity.kt | 19 ++++ server/README.md | 20 ++-- server/internal/api/admin.go | 16 ++- server/internal/api/admin_arr_integrations.go | 44 ++++++++ server/internal/api/admin_radarr_requests.go | 106 ++++++++++++++++++ server/internal/api/alerts.go | 2 +- server/internal/api/api.go | 40 ++++++- server/internal/api/calendar.go | 2 +- server/internal/api/home.go | 4 +- server/internal/api/images.go | 4 +- server/internal/api/logcontext.go | 46 ++++++-- server/internal/api/my_shows.go | 4 +- server/internal/api/playback.go | 46 +++++++- server/internal/api/radarr.go | 4 +- server/internal/api/requests.go | 94 ++++++++++++++-- server/internal/api/requests_list.go | 4 +- server/internal/api/sonarr.go | 6 +- server/internal/api/sonarr_status.go | 2 +- server/internal/logging/console.go | 24 ++-- server/internal/logging/logging.go | 52 ++++++++- server/internal/logging/logging_test.go | 24 ++++ server/internal/radarr/client.go | 51 ++++++--- server/internal/radarr/client_test.go | 12 +- server/internal/store/settings.go | 98 ++++++++++++++++ 41 files changed, 921 insertions(+), 179 deletions(-) delete mode 100644 admin-ui/dist/assets/index-BIKdFbmA.css delete mode 100644 admin-ui/dist/assets/index-BQ_ZF8Re.js create mode 100644 admin-ui/dist/assets/index-BpHOHb2g.js create mode 100644 admin-ui/dist/assets/index-CXwJRCVF.css create mode 100644 app/src/main/java/com/ponzischeme89/memby/diagnostics/MembyDiagnostics.kt create mode 100644 server/internal/api/admin_arr_integrations.go create mode 100644 server/internal/api/admin_radarr_requests.go diff --git a/.env.example b/.env.example index 00bcf2b..bca404c 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,8 @@ POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649b # Fixed NAS host port used by the mserver.sublogue.com reverse proxy. MEMBY_PORT=32768 -# INFO is recommended. DEBUG also logs successful health, status and artwork requests, -# playback progress reports and search terms. +# INFO is recommended. DEBUG adds diagnostic request and playback detail; TRACE adds the +# deepest request and playback-negotiation trace. Sensitive structured values are redacted. MEMBY_LOG_LEVEL=INFO MEMBY_LOG_BUFFER_CAPACITY=5000 # The Compose default is a named-volume-backed JSONL archive restored by the admin log. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7869fd..aeb030b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,3 @@ -## 0.1.42 — 2026-08-14 -- Improved: Server Logs keeps its headings visible and shows each structured log record’s full details, including gateway version, in an expandable view. - -## 0.1.41 — 2026-08-14 -- Improved: Sonarr and Radarr can be enabled or disabled independently from the Integrations page without removing their saved settings. - -## 0.1.40 — 2026-08-14 -- Fixed: TV requests use Memby's safe Sonarr request policy: a selected 720p profile, normal monitoring and no immediate backlog search unless an operator enables it. - ## 0.2.62 — 2026-08-13 - Fixed: Playback is back to the way it worked in 0.2.60. The groundwork for Memby playing video itself caused problems, so it has been withdrawn and will return once it is ready. diff --git a/admin-ui/dist/assets/index-BIKdFbmA.css b/admin-ui/dist/assets/index-BIKdFbmA.css deleted file mode 100644 index a34a561..0000000 --- a/admin-ui/dist/assets/index-BIKdFbmA.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;--bg: #010409;--rail-bg: #010409;--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;--top: 58px;--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:var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased}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:0 clamp(14px,2vw,22px) 0 0;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(960px,calc(100vw - var(--rail) - 300px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:flex;align-items:center;gap:8px;min-width:0;padding:4px 6px;border:0;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;font:inherit;text-align:left}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:wait;opacity:.65}.topbar-status .dot{width:8px;height:8px;border-radius:50%;background:var(--quiet);flex:0 0 8px}.topbar-status[data-tone=ok] .dot{background:var(--accent)}.topbar-status[data-tone=bad] .dot{background:var(--danger)}.topbar-status b{font-size:12.5px;font-weight:600}.topbar-status span{font-size:11.5px;color:var(--quiet);white-space:nowrap}@media (max-width: 900px){.topbar-version,.topbar-status span{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{flex:1 1 auto;min-width:0;border: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:0;width:min(520px,94vw);max-height:min(60vh,520px);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}.bell-button{position:relative;display:grid;place-items:center;width:34px;height:34px;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 20px;overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.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 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);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-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: 820px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .15s ease;width:236px}.rail[data-open=true]{transform:none;box-shadow:0 0 60px #000000b3}.topbar-brand{flex:0 0 auto;padding-left:12px}.page{margin-left: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: 820px){.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 auto 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: 820px){:root{--top: 58px}.topbar{display:flex;gap:8px;height:var(--top);padding:0 12px}.topbar-brand{padding:0}.brand-word{display:none}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;min-width:0}.omni-input{height:36px}.omni-panel{position:fixed;top:calc(var(--top) + 6px);right:12px;left:12px;width:auto;max-height:calc(100dvh - var(--top) - 18px)}.omni-key,.topbar-version,.topbar-status{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:34px;height:34px}.page{padding:18px 14px 48px}.table-wrap{margin:0 -14px -16px;padding:0 14px 16px}table{font-size:13px}th,td{padding-right:16px}}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=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{max-height:62vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono)}.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:hover{background:#ffffff08}.logline time{flex:0 0 auto;color:var(--quiet)}.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}.logline .attrs{color:var(--quiet)}.logline .attrs b{color:var(--muted);font-weight:400}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)} diff --git a/admin-ui/dist/assets/index-BQ_ZF8Re.js b/admin-ui/dist/assets/index-BQ_ZF8Re.js deleted file mode 100644 index ec90776..0000000 --- a/admin-ui/dist/assets/index-BQ_ZF8Re.js +++ /dev/null @@ -1,11 +0,0 @@ -import{r as x,a as rs,u as be,L as K,b as Ve,O as ls,N as os,c as pe,B as cs,R as ds,d as P,e as hs}from"./router-BwjLFE7Y.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))o(r);new MutationObserver(r=>{for(const d of r)if(d.type==="childList")for(const t of d.addedNodes)t.tagName==="LINK"&&t.rel==="modulepreload"&&o(t)}).observe(document,{childList:!0,subtree:!0});function a(r){const d={};return r.integrity&&(d.integrity=r.integrity),r.referrerPolicy&&(d.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?d.credentials="include":r.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function o(r){if(r.ep)return;r.ep=!0;const d=a(r);fetch(r.href,d)}})();var We={exports:{}},xe={};/** - * @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 us=x,ms=Symbol.for("react.element"),ps=Symbol.for("react.fragment"),xs=Object.prototype.hasOwnProperty,js=us.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,vs={key:!0,ref:!0,__self:!0,__source:!0};function _e(s,n,a){var o,r={},d=null,t=null;a!==void 0&&(d=""+a),n.key!==void 0&&(d=""+n.key),n.ref!==void 0&&(t=n.ref);for(o in n)xs.call(n,o)&&!vs.hasOwnProperty(o)&&(r[o]=n[o]);if(s&&s.defaultProps)for(o in n=s.defaultProps,n)r[o]===void 0&&(r[o]=n[o]);return{$$typeof:ms,type:s,key:d,ref:t,props:r,_owner:js.current}}xe.Fragment=ps;xe.jsx=_e;xe.jsxs=_e;We.exports=xe;var e=We.exports,ze,Se=rs;ze=Se.createRoot,Se.hydrateRoot;const Ke={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 Z({name:s,className:n}){const a=Ke[s];return a?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:a})}):null}function je({name:s,tone:n}){return Ke[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(Z,{name:s})}):null}const fe=[{id:"overview",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:"people",label:"People",items:[{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:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one 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.",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.",hidden:!0}]},{id:"content",label:"Content",items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{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:"personalisation",label:"Personalisation",items:[{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{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:"Experience",items:[{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:"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:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"}]},{id:"operations",label:"Operations",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:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"reporting",label:"Reporting",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:"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"},{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"}]}];fe.flatMap(s=>s.items);const gs=fe.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Me extends Error{constructor(n,a){super(n),this.status=a,this.name="ApiError"}}const bs=5*60*1e3;let He=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{He=Date.now()},{passive:!0});const fs=()=>Date.now()-He({}));throw new Me(o.error??`Request failed (${a.status})`,a.status)}if(a.status!==204)return await a.json()}function he(s){const n=new URLSearchParams;for(const[o,r]of Object.entries(s))r==null||r===""||r===!1||n.set(o,String(r));const a=n.toString();return a?`?${a}`:""}const T={get:s=>ue(s),post:(s,n)=>ue(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>ue(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>ue(s,{method:"DELETE"})},ws=3e4,Ge=x.createContext(null);function ks({children:s}){var b;const[n,a]=x.useState(),[o,r]=x.useState(!0),[d,t]=x.useState(""),[l,c]=x.useState(""),[i,v]=x.useState(!0),m=x.useRef(0),h=x.useCallback(async()=>{const g=++m.current;try{const j=await T.get("/admin/api/status");if(g!==m.current)return;a(j),r(!0),c("")}catch(j){if(g!==m.current)return;r(!1),c(j instanceof Error?j.message:String(j))}finally{g===m.current&&(t(new Date().toISOString()),v(!1))}},[]),p=x.useCallback(async g=>{var j;await T.post("/admin/api/maintenance",{enabled:g,message:((j=n==null?void 0:n.maintenance)==null?void 0:j.message)??""}),await h()},[h,(b=n==null?void 0:n.maintenance)==null?void 0:b.message]);x.useEffect(()=>{h();let g;const j=()=>{window.clearInterval(g),g=document.hidden?void 0:window.setInterval(()=>void h(),ws)},k=()=>{j(),document.hidden||h()};return j(),document.addEventListener("visibilitychange",k),()=>{window.clearInterval(g),document.removeEventListener("visibilitychange",k)}},[h]);const f=x.useMemo(()=>({status:n,version:(n==null?void 0:n.serverVersion)??"",online:o,checkedAt:d,error:l,loading:i,reload:h,setMaintenance:p}),[n,o,d,l,i,h,p]);return e.jsx(Ge.Provider,{value:f,children:s})}function Y(){const s=x.useContext(Ge);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Ns(s,n){const a=s.label.toLowerCase(),o=s.group.toLowerCase();return a.startsWith(n)?4:a.includes(n)?3:o.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function Ss(){const s=be(),{status:n}=Y(),[a,o]=x.useState(!1),[r,d]=x.useState(""),[t,l]=x.useState(0),c=x.useRef(null),i=x.useRef(null),v=x.useMemo(()=>{const h=r.trim().toLowerCase();return[...gs,...((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,b)=>({item:f,rank:h?Ns(f,h):1,index:b})).filter(f=>f.rank>0).sort((f,b)=>b.rank-f.rank||f.index-b.index).map(({item:f,rank:b})=>({item:f,rank:b}))},[r]);x.useEffect(()=>l(0),[r]),x.useEffect(()=>{const h=p=>{var f;(f=c.current)!=null&&f.contains(p.target)||o(!1)};return document.addEventListener("pointerdown",h),()=>document.removeEventListener("pointerdown",h)},[]),x.useEffect(()=>{const h=p=>{var b,g;if(p.key!=="S"||!p.shiftKey||p.ctrlKey||p.metaKey||p.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(p.preventDefault(),(b=i.current)==null||b.focus(),(g=i.current)==null||g.select())};return window.addEventListener("keydown",h),()=>window.removeEventListener("keydown",h)},[]);const m=h=>{var p;o(!1),d(""),(p=i.current)==null||p.blur(),s(h)};return e.jsxs("div",{className:"omni",ref:c,"data-open":a||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(Z,{name:"search"}),e.jsx("input",{ref:i,type:"search",value:r,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":a,onFocus:()=>o(!0),onChange:h=>{d(h.target.value),o(!0)},onKeyDown:h=>{var p;if(h.key==="Escape")d(""),o(!1),(p=i.current)==null||p.blur();else if(h.key==="ArrowDown")h.preventDefault(),l(f=>Math.min(f+1,v.length-1));else if(h.key==="ArrowUp")h.preventDefault(),l(f=>Math.max(f-1,0));else if(h.key==="Enter"){const f=v[t];if(!f)return;h.preventDefault(),m(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),a?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((h,p)=>e.jsxs("a",{className:p===t?"omni-item on":"omni-item",href:h.item.path,role:"option","aria-selected":p===t,onPointerEnter:()=>l(p),onClick:f=>{f.preventDefault(),m(h.item.path)},children:[h.item.icon?e.jsx(Z,{name:h.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:h.item.label}),e.jsx("small",{children:h.item.intro})]}),e.jsx("span",{className:"omni-group",children:h.item.group})]},h.item.id))}):null]})}const y=s=>(s??0).toLocaleString(),L=s=>s?new Date(s).toLocaleString():"—",Ms=s=>s?new Date(s).toLocaleTimeString():"—";function ce(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 a=Math.floor(n/60);return a<60?`${a}m ${n%60}s`:`${Math.floor(a/60)}h ${a%60}m`}function Cs(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 a=Math.round(n/60);return a<48?a===1?"hourly":`every ${a} hours`:`every ${Math.round(a/24)} days`}function oe(s){const n=["B","KB","MB","GB"];let a=Number(s??0),o=0;for(;a>=1024&&oString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),ge=s=>`${Math.round((s??0)*100)}%`;function le(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const a=Math.floor(n/1e3);if(a<45)return"just now";const o=Math.floor(a/60);if(o<60)return`${o} min ago`;const r=Math.floor(o/60);if(r<24)return`${r}h ago`;const d=Math.floor(r/24);return d<30?`${d}d ago`:new Date(s).toLocaleDateString()}const Je=15*60*1e3,Rs=3*60*60*1e3,de=s=>!!s&&Date.now()-new Date(s).getTime(){var j;try{const k=await T.get(`/admin/api/notifications?limit=${Re}`);a(k.events),r(k.unread),t(k.types),m.current=Math.max(m.current,((j=k.events[0])==null?void 0:j.id)??0),v("")}catch(k){v(k instanceof Error?k.message:String(k))}},[]),p=x.useCallback(j=>{m.current=Math.max(m.current,j.id),a(k=>k.some(u=>u.id===j.id)?k:[j,...k].sort((u,M)=>M.id-u.id).slice(0,Re)),j.readAt||r(k=>k+1),t(k=>k.some(u=>u.type===j.type)?k.map(u=>u.type===j.type?{...u,count:u.count+1}:u):[...k,{type:j.type,count:1}])},[]);x.useEffect(()=>{h()},[h]),x.useEffect(()=>{let j=null,k,u=!1;return(()=>{u||(j=new EventSource(`/admin/api/notifications/stream?after=${m.current}`),j.addEventListener("open",()=>{c(!0),window.clearInterval(k),k=void 0}),j.addEventListener("admin",w=>{try{p(JSON.parse(w.data))}catch{}}),j.addEventListener("error",()=>{c(!1),k===void 0&&(k=window.setInterval(()=>void h(),Es))}))})(),()=>{u=!0,j==null||j.close(),window.clearInterval(k)}},[p,h]);const f=x.useCallback(async j=>{const k=j.filter(u=>u>0);if(k.length!==0){a(u=>u.map(M=>k.includes(M.id)&&!M.readAt?{...M,readAt:new Date().toISOString()}:M));try{const u=await T.post("/admin/api/notifications/read",{ids:k});r(u.unread)}catch{h()}}},[h]),b=x.useCallback(async()=>{r(0),a(j=>j.map(k=>k.readAt?k:{...k,readAt:new Date().toISOString()}));try{const j=await T.post("/admin/api/notifications/read",{all:!0});r(j.unread)}catch{h()}},[h]),g=x.useMemo(()=>({events:n,unread:o,types:d,connected:l,error:i,markRead:f,markAllRead:b,reload:h}),[n,o,d,l,i,f,b,h]);return e.jsx(Ye.Provider,{value:g,children:s})}function we(){const s=x.useContext(Ye);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function Xe(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function Qe(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 Ee(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 Ae=99;function $s(){const{events:s,unread:n,connected:a,markRead:o,markAllRead:r}=we(),[d,t]=x.useState(!1),l=x.useRef(null);return x.useEffect(()=>{const c=v=>{var m;(m=l.current)!=null&&m.contains(v.target)||t(!1)},i=v=>{v.key==="Escape"&&t(!1)};return document.addEventListener("pointerdown",c),document.addEventListener("keydown",i),()=>{document.removeEventListener("pointerdown",c),document.removeEventListener("keydown",i)}},[]),x.useEffect(()=>{if(!d)return;const c=s.filter(i=>!i.readAt).map(i=>i.id);c.length>0&&o(c)},[d]),e.jsxs("div",{className:"bell",ref:l,"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(Z,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>Ae?`${Ae}+`: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:[a?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void r(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(c=>{const i=e.jsxs(e.Fragment,{children:[e.jsx(je,{name:Qe(c.type),tone:Xe(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:le(c.occurredAt)})]})]});return c.link?e.jsx(K,{className:"bell-item","data-unread":!c.readAt||void 0,to:c.link,onClick:()=>t(!1),children:i},c.id):e.jsx("div",{className:"bell-item","data-unread":!c.readAt||void 0,children:i},c.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(K,{to:"/admin/activity",onClick:()=>t(!1),children:"All activity"})})]}):null]})}function Is({open:s,onNavigate:n}){const{unread:a}=we(),o=Ve(),[r,d]=x.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),t=l=>{d(c=>{const i={...c,[l]:!c[l]};try{localStorage.setItem("memby-admin-nav",JSON.stringify(i))}catch{}return i})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:fe.map(l=>{const c=l.items.filter(m=>!m.hidden);if(c.length===0)return null;const v=c.some(m=>o.pathname===m.path)||!r[l.id];return e.jsxs("div",{className:"rail-group",children:[l.label?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":v,onClick:()=>t(l.id),children:[l.label,e.jsx(Z,{name:"caret",className:"ico caret"})]}):null,v?c.map(m=>e.jsx(os,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:h})=>h?"on":"","aria-current":void 0,children:({isActive:h})=>e.jsxs("span",{style:{display:"contents"},ref:p=>{const f=p==null?void 0:p.parentElement;f&&(h?f.setAttribute("aria-current","page"):f.removeAttribute("aria-current"))},children:[m.icon?e.jsx(Z,{name:m.icon}):null,m.label,m.badge==="notifications"&&a>0?e.jsx("span",{className:"rail-badge",children:a>99?"99+":a}):null]})},m.id)):null]},l.id)})})}function Ls(){var h;const{version:s,online:n,checkedAt:a,status:o,setMaintenance:r}=Y(),[d,t]=x.useState(!1),[l,c]=x.useState(!1),i=Ve(),v=!!((h=o==null?void 0:o.maintenance)!=null&&h.enabled),m=async()=>{if(!(l||!o)&&!(!v&&!window.confirm("Take Memby offline for every television?"))){c(!0);try{await r(!v)}finally{c(!1)}}};return x.useEffect(()=>t(!1),[i.pathname]),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":d,"aria-controls":"rail",onClick:()=>t(p=>!p),children:e.jsx(Z,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Ss,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":n&&!v?"ok":"bad","aria-pressed":v,disabled:!o||l,title:v?"Bring Memby back online":"Take Memby offline",onClick:()=>void m(),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:v?"offline":n?"online":"not responding"}),e.jsx("span",{children:v?"click to bring back online":a?`updated ${Ms(a)}`:""})]}),e.jsx($s,{}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsx("button",{type:"submit","data-variant":"quiet","data-size":"sm",title:"Sign out",children:e.jsx(Z,{name:"logout"})})})]})]}),e.jsx(Is,{open:d,onNavigate:()=>t(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(ls,{})})]})}const es=x.createContext(null),Ts=5e3;function Ds({children:s}){const[n,a]=x.useState([]),o=x.useRef(1),r=x.useCallback(c=>{a(i=>i.filter(v=>v.id!==c))},[]),d=x.useCallback((c,i="ok")=>{const v=o.current++;a(m=>[...m,{id:v,message:c,tone:i}]),window.setTimeout(()=>r(v),Ts)},[r]),t=x.useCallback(async(c,i)=>{try{const v=await c();return i&&d(i,"ok"),v}catch(v){d(v instanceof Error?v.message:String(v),"bad");return}},[d]),l=x.useMemo(()=>({show:d,wrap:t}),[d,t]);return e.jsxs(es.Provider,{value:l,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(Z,{name:c.tone==="bad"?"alert":"check"}),e.jsx("span",{children:c.message}),e.jsx("button",{type:"button",onClick:()=>r(c.id),"aria-label":"Dismiss",children:e.jsx(Z,{name:"close"})})]},c.id))})]})}function Q(){const s=x.useContext(es);if(!s)throw new Error("useToast used outside ToastProvider");return s}function D({title:s,intro:n,actions:a,crumbs:o}){return e.jsxs("header",{className:"page-head",children:[o?e.jsx("nav",{className:"crumbs",children:o}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]}),a?e.jsx("div",{className:"page-head-actions",children:a}):null]})]})}function R({title:s,intro:n,icon:a,tone:o,actions:r,footer:d,children:t}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[a?e.jsx(je,{name:a,tone:o}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),r?e.jsx("div",{className:"card-head-actions",children:r}):null]}):null,t,d?e.jsx("div",{className:"card-foot",children:d}):null]})}function ne({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function ee({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(je,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function S({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function z({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function V({children:s}){return e.jsx("p",{className:"empty",children:s})}function G({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 ke({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function A({children:s,onClick:n,variant:a,size:o,disabled:r,busy:d,icon:t,type:l="button",title:c}){return e.jsxs("button",{type:l,className:"","data-variant":a,"data-size":o,disabled:r||d,onClick:n,title:c,children:[d?e.jsx("span",{className:"spinner"}):t?e.jsx(Z,{name:t}):null,s]})}function I({label:s,hint:n,children:a,grow:o}){return e.jsxs("label",{className:o?"field grow":"field",children:[e.jsx("span",{children:s}),a,n?e.jsx("small",{children:n}):null]})}function X({label:s,hint:n,checked:a,onChange:o,disabled:r}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:a,disabled:r,onChange:d=>o(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 me({value:s,options:n,onChange:a}){return e.jsx("div",{className:"segments",role:"group",children:n.map(o=>e.jsx("button",{type:"button","aria-pressed":o.value===s,onClick:()=>a(o.value),children:o.label},String(o.value)))})}function W({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function ss({data:s,labelOf:n,valueOf:a,toneOf:o,title:r}){if(s.length===0)return e.jsx(V,{children:"Nothing in this window."});const d=s.map(l=>a(l)),t=Math.max(1,...d);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((l,c)=>{const i=a(l);return e.jsx("div",{className:"bar","data-tone":o==null?void 0:o(l),"data-empty":i===0||void 0,style:{height:`${Math.max(2,i/t*100)}%`},title:r?r(l):`${n(l,c)}: ${i}`},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 Ps({value:s,total:n,tone:a}){const o=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":a,children:e.jsx("div",{style:{width:`${o*100}%`}})})}function F({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(Z,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(Z,{name:"close"})}):null]}):null}function q({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,a)=>e.jsx("span",{className:"skeleton"},a))}),Array.from({length:s},(n,a)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},a))]})}function te({title:s,body:n,confirmLabel:a="Confirm",destructive:o,busy:r,onConfirm:d,onCancel:t}){const l=x.useId(),c=x.useRef(null);return x.useEffect(()=>{var v;(v=c.current)==null||v.focus();const i=m=>{m.key==="Escape"&&t()};return document.addEventListener("keydown",i),()=>document.removeEventListener("keydown",i)},[t]),e.jsx("div",{className:"scrim",onPointerDown:i=>i.target===i.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":l,ref:c,tabIndex:-1,children:[e.jsx("h2",{id:l,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{onClick:t,variant:"quiet",children:"Cancel"}),e.jsx(A,{onClick:d,variant:o?"danger":"primary",busy:r,children:a})]})]})})}function $e({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 ns({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 J(s,n={}){const{pollMs:a,enabled:o=!0}=n,[r,d]=x.useState(),[t,l]=x.useState(""),[c,i]=x.useState(o),[v,m]=x.useState(!1),h=x.useRef(0),p=x.useRef(!1),f=x.useCallback(async()=>{if(!o)return;const b=++h.current;p.current&&m(!0);try{const g=await T.get(s);if(b!==h.current)return;d(g),l(""),p.current=!0}catch(g){if(b!==h.current)return;l(g instanceof Error?g.message:String(g))}finally{b===h.current&&(i(!1),m(!1))}},[s,o]);return x.useEffect(()=>(p.current=!1,i(!0),f(),()=>{h.current+=1}),[f]),x.useEffect(()=>{if(!a||!o)return;let b;const g=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void f(),a)},j=()=>{g(),document.hidden||f()};return g(),document.addEventListener("visibilitychange",j),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",j)}},[a,o,f]),{data:r,error:t,loading:c,refreshing:v,reload:f,set:d}}function se(){const[s,n]=x.useState(null),a=x.useRef(!0);x.useEffect(()=>()=>{a.current=!1},[]);const o=x.useCallback(async(r,d)=>{n(r);try{return await d(),!0}finally{a.current&&n(null)}},[]);return{busy:s,run:o}}function Fs(){var g,j,k,u,M;const{status:s,error:n,loading:a}=Y(),o=J("/admin/api/runtime",{pollMs:3e4}),r=J("/admin/api/views",{pollMs:6e4});if(a||!s)return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(F,{message:n}),e.jsx(q,{})]});const d=s.features??{features:[],revision:0,safeMode:!1},t=d.features??[],l=s.clients??[],c=l.filter(w=>de(w.lastSeen)).length,i=s.updatePolicy??{},v=!!i.minimumVersion&&i.minimumVersion===i.latestVersion,m=s.playbackPolicy,h=s.mdblist,p=s.forYou,f=(s.runs??[]).slice(0,5),b=o.data;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(F,{message:n}),e.jsx(ee,{tiles:[{label:"items in the library",value:y(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:y((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${c} active now`,value:y(l.length),icon:"tv",tone:"info"},{label:`visits today · ${((g=r.data)==null?void 0:g.lastWeek.visits)??0} this time last week`,value:y((j=r.data)==null?void 0:j.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((k=r.data)==null?void 0:k.lastWeek.viewers)??0} this time last week`,value:y((u=r.data)==null?void 0:u.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${t.filter(w=>w.enabled).length} / ${t.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:L(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(ne,{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($e,{rows:[{label:"Availability",value:(M=s.maintenance)!=null&&M.enabled?e.jsx(S,{tone:"bad",children:"offline for maintenance"}):e.jsx(S,{tone:"ok",children:"online"})},{label:"Feature control plane",value:d.safeMode?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["revision r",y(d.revision)]})},{label:"App update prompt",value:i.enabled?e.jsxs(S,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",i.latestVersion]}):e.jsx(S,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(S,{tone:"warn",children:"running"}):e.jsxs(S,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(m==null?void 0:m.prerollEnabled)===!1?e.jsx(S,{children:"off"}):e.jsxs(S,{tone:"ok",children:[((m==null?void 0:m.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($e,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"MDBList ratings",value:h!=null&&h.enabled?e.jsxs(S,{tone:"ok",children:[y(h.cachedTitles)," titles stored"]}):e.jsx(S,{children:h!=null&&h.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(S,{tone:"warn",children:"rebuilding"}):e.jsxs(S,{children:[y((p==null?void 0:p.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:y((p==null?void 0:p.profiles)??0)})}]})})]}),e.jsxs(ne,{cols:"2",children:[e.jsx(R,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(K,{to:"/admin/imports",children:"All imports"}),children:e.jsx(W,{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(G,{columns:4,children:"No imports have run yet."}):f.map(w=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(w.startedAt)}),e.jsx("td",{children:w.kind}),e.jsx("td",{children:e.jsx(S,{tone:w.status==="success"?"ok":w.status==="running"?"warn":"bad",children:w.status})}),e.jsx("td",{className:"num",children:y(w.itemsUpserted)})]},w.id||w.startedAt))})]})})}),e.jsx(R,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:b?e.jsxs(e.Fragment,{children:[e.jsx(ns,{tiles:[{label:"goroutines",value:y(b.goroutines)},{label:"heap in use",value:oe(b.heapInuse)},{label:"reserved",value:oe(b.sys)},{label:"collections",value:y(b.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",oe(b.nextGc)," · memory limit"," ",b.memoryLimit>0&&b.memoryLimit`/admin/api/notifications${he({days:r,type:t,severity:c,unread:v,limit:f,offset:h*f})}`,[r,t,c,v,h]),{data:g,error:j,loading:k,reload:u}=J(b),M=async()=>{await a(),await u()};return e.jsxs(e.Fragment,{children:[e.jsx(D,{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 M(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(F,{message:j}),e.jsx(ee,{tiles:[{label:"Events in window",value:y((g==null?void 0:g.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:y(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:y((g==null?void 0:g.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(I,{label:"Window",children:e.jsx(me,{value:r,options:Os.map(w=>({value:w.value,label:w.label})),onChange:w=>{d(w),p(0)}})}),e.jsx(I,{label:"Kind",children:e.jsxs("select",{value:t,onChange:w=>{l(w.target.value),p(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((g==null?void 0:g.types)??[]).map(w=>e.jsxs("option",{value:w.type,children:[Ee(w.type)," (",w.count,")"]},w.type))]})}),e.jsx(I,{label:"Severity",children:e.jsxs("select",{value:c,onChange:w=>{i(w.target.value),p(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(I,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:w=>{m(w.target.value==="unread"),p(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:()=>{u(),o()},children:"Refresh"})})]}),k?e.jsx(q,{}):e.jsx(R,{title:"Events",icon:"bell",tone:"info",footer:((g==null?void 0:g.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:h===0,onClick:()=>p(h-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(h+1)*f>=((g==null?void 0:g.total)??0),onClick:()=>p(h+1),children:"Older"})]}):void 0,children:e.jsx(W,{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:((g==null?void 0:g.events.length)??0)===0?e.jsx(G,{columns:6,children:"Nothing has happened in this window."}):g==null?void 0:g.events.map(w=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(w.occurredAt),children:le(w.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(je,{name:Qe(w.type),tone:Xe(w)}),Ee(w.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:w.title}),w.summary?e.jsx("div",{className:"muted",children:w.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:w.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:w.target||"—"}),e.jsxs("td",{className:"nowrap",children:[w.readAt?null:e.jsx(S,{tone:"ok",children:"new"}),w.link?e.jsx(K,{className:"table-row-link",to:w.link,children:"Open"}):null]})]},w.id))})]})})})]})}function Us(){const{data:s,error:n,loading:a}=J("/admin/api/accounts",{pollMs:6e4}),o=(s==null?void 0:s.accounts)??[],r=o.flatMap(l=>l.devices??[]),d=o.filter(l=>{var c;return(c=l.recommendations)==null?void 0:c.completed}).length,t=o.filter(l=>{var c,i;return((c=l.recommendations)==null?void 0:c.prompted)&&!((i=l.recommendations)!=null&&i.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(F,{message:n}),e.jsx(ke,{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."}),a?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"Memby users",value:y(o.length),icon:"people",tone:"note"},{label:"signed-in devices",value:y(r.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:y(r.filter(l=>de(l.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:y(d),icon:"check",tone:"ok"},{label:"setup prompts queued",value:y(t),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:o.length===0?e.jsx(V,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):o.map(l=>{var h,p;const c=l.devices??[],i=c.filter(f=>de(f.lastSeen)).length,v=(h=l.recommendations)!=null&&h.completed?{label:"personalised",tone:"ok"}:(p=l.recommendations)!=null&&p.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},m=ye(l.lastSeen);return e.jsxs(K,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(l.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:Ze(l.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[l.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":m.tone,title:m.label})]}),e.jsxs("span",{className:"list-meta",children:[y(c.length)," device",c.length===1?"":"s",i?` · ${i} active now`:""," · last seen ",L(l.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(S,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},l.id)})})]})]})}function ve(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function Bs(){const{userId:s=""}=pe(),n=be(),{wrap:a}=Q(),{busy:o,run:r}=se(),d=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:t,error:l,loading:c,reload:i}=J("/admin/api/accounts",{pollMs:3e4}),[v,m]=x.useState(null),[h,p]=x.useState(null),[f,b]=x.useState(null),[g,j]=x.useState(null),k=((t==null?void 0:t.accounts)??[]).find(E=>E.id===s),u=(t==null?void 0:t.catalogue)??[],M=(t==null?void 0:t.themes)??[];x.useEffect(()=>{var E;v===null&&k&&m({...((E=k.settings)==null?void 0:E.preferences)??{}})},[k,v]),x.useEffect(()=>{if(h!==null||!k)return;const E=k.themes??[];p(E.length===0?M.map(U=>U.id):E)},[k,h,M]);const w=x.useMemo(()=>{const E=[];for(const U of u){let H=E.find(B=>B.name===U.area);H||E.push(H={name:U.area,definitions:[]}),H.definitions.push(U)}return E},[u]),$=(E,U,H,B)=>r(E,async()=>{const ie=await a(U,H);b(null),ie!==void 0&&(B==null||B()),await i()});if(c)return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"User",crumbs:e.jsx(K,{to:"/admin/accounts",children:"← All users"})}),e.jsx(q,{})]});if(!k)return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"User",crumbs:e.jsx(K,{to:"/admin/accounts",children:"← All users"})}),e.jsx(F,{message:l}),e.jsx(R,{children:e.jsx(V,{children:"This user is no longer signed in to Memby."})})]});const O=k.devices??[],N=O.filter(E=>de(E.lastSeen)).length,C=k.settings??{},_=k.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:k.username||"Unnamed user",intro:`Memby user · ${y(O.length)} device${O.length===1?"":"s"} · last seen ${L(k.lastSeen)}`,crumbs:e.jsx(K,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:Ze(k.username)}),N?e.jsxs(S,{tone:"ok",children:[N," active now"]}):e.jsx(S,{children:"idle"}),e.jsx(z,{children:k.id})]})}),e.jsx(F,{message:l}),e.jsxs(ne,{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:O.length===0?e.jsx(V,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:O.map(E=>{const U=ye(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":U.tone,title:U.label})," ",e.jsx(K,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(E.id)}`,children:E.name||"Memby TV"})]}),e.jsxs("p",{children:[E.version?`Memby ${E.version}`:"Legacy Memby client"," · ",U.label," · last seen ",L(E.lastSeen)," · signed in ",L(E.signedInAt)]}),(E.versions??[]).length>0?e.jsx("div",{className:"chips",children:(E.versions??[]).map(H=>e.jsxs(z,{tone:H.version===E.version?"ok":void 0,children:[H.version,H.version===E.version?" · now":""]},H.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(A,{size:"sm",disabled:!E.id,onClick:()=>j({id:E.id,name:E.name}),children:"Rename"}),e.jsx(A,{size:"sm",variant:"danger",disabled:!E.id,onClick:()=>b({kind:"remove-device",deviceId:E.id,name:E.name}),children:"Sign out"})]})]},E.id||E.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:_.completed?e.jsx(A,{busy:o==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):_.prompted?e.jsx(A,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(A,{variant:"primary",busy:o==="prompt",onClick:()=>void $("prompt",()=>T.put(`${d}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:_.completed?e.jsx(S,{tone:"ok",children:"completed"}):_.prompted?e.jsx(S,{tone:"warn",children:"prompt queued"}):e.jsx(S,{children:"not invited"})}),e.jsx(Vs,{prompt:_})]})]}),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:C.saved?e.jsxs(S,{tone:C.source==="admin"?"warn":"ok",children:["r",y(C.revision)," · ",C.source||"device"," · ",L(C.updatedAt)]}):e.jsx(S,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:o==="push",onClick:()=>void $("push",()=>T.put(`${d}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>m(null)),children:"Push to their televisions"}),e.jsx(A,{onClick:()=>{m(null),i()},children:"Discard changes"}),e.jsx(A,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(K,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:w.map(E=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:E.name}),E.definitions.map(U=>e.jsx(Ws,{definition:U,value:v==null?void 0:v[U.key],onChange:H=>m(B=>({...B??{},[U.key]:H}))},U.key))]},E.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:(k.themes??[]).length===0?e.jsx(S,{children:"all schemes"}):e.jsxs(S,{tone:"note",children:[y((k.themes??[]).length)," of ",y(M.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:o==="themes",onClick:()=>(h??[]).length===0?b({kind:"no-themes"}):void $("themes",()=>T.put(`${d}/themes`,{themes:h??[]}),"Colour schemes saved.",()=>p(null)),children:"Save colour schemes"}),e.jsx(A,{onClick:()=>p(M.map(E=>E.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:M.map(E=>{var U,H,B;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(h??[]).includes(E.id),onChange:ie=>p(Ne=>ie.target.checked?[...Ne??[],E.id]:(Ne??[]).filter(as=>as!==E.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":ve((U=E.palette)==null?void 0:U.surface),"--swatch-accent":ve((H=E.palette)==null?void 0:H.accent),"--swatch-hairline":ve((B=E.palette)==null?void 0:B.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:E.name}),e.jsx("p",{children:E.description})]})]},E.id)})})}),e.jsx(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"})}),g?e.jsx(_s,{initial:g.name,busy:o==="rename",onCancel:()=>j(null),onConfirm:E=>void $("rename",()=>T.put(`${d}/devices/${encodeURIComponent(g.id)}`,{deviceName:E}),"Device renamed.",()=>j(null))}):null,f?e.jsx(zs,{pending:f,busy:o,username:k.username,onCancel:()=>b(null),onConfirm:()=>{switch(f.kind){case"remove-device":return void $("remove-device",()=>T.del(`${d}/devices/${encodeURIComponent(f.deviceId)}`),"Device signed out.");case"remove-account":return void $("remove-account",()=>T.del(`${d}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void $("reset-rec",()=>T.del(`${d}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void $("reset-prefs",()=>T.del(`${d}/preferences`),"Defaults restored.",()=>m(null));case"no-themes":return void $("themes",()=>T.put(`${d}/themes`,{themes:[]}),"Colour schemes saved.",()=>p(null))}}}):null]})}function Vs({prompt:s}){const n=s.ratings??[],a=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],o=[...n.map(r=>e.jsxs(z,{tone:"warn",children:[r.title," · ",y(r.rating)," ★"]},`r:${r.title}`)),...a.flatMap(([r,d])=>(d??[]).map(t=>e.jsxs(z,{children:[r,": ",t]},`${r}:${t}`)))];return o.length===0?e.jsx(V,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:o})}function Ws({definition:s,value:n,onChange:a}){if(s.kind==="toggle")return e.jsx(X,{label:s.name,hint:s.description,checked:!!n,onChange:a});if(s.kind==="choice"||s.kind==="number"){const r=s.unit??"",d=s.kind==="number"?(s.numbers??[]).map(t=>({value:String(t),label:t===0?"No limit":r?`${t} ${r}`:String(t)})):s.options??[];return e.jsx(I,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:t=>a(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 r=Array.isArray(n)?n:[],d=[...r,...(s.options??[]).map(t=>t.value).filter(t=>!r.includes(t))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:d.map(t=>{const l=(s.options??[]).find(c=>c.value===t);return l?e.jsx(X,{label:l.label,checked:r.includes(t),onChange:c=>a(c?[...r,t]:r.filter(i=>i!==t))},t):null})})]})}const o=Array.isArray(n)?n:[];return e.jsx(I,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:o.join(` -`),onChange:r=>a(r.target.value.split(` -`).map(d=>d.trim()).filter(Boolean))})})}function _s({initial:s,busy:n,onConfirm:a,onCancel:o}){const[r,d]=x.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:t=>t.target===t.currentTarget&&o(),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(I,{label:"Device name",children:e.jsx("input",{type:"text",value:r,autoFocus:!0,maxLength:80,onChange:t=>d(t.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:o,children:"Cancel"}),e.jsx(A,{variant:"primary",busy:n,disabled:!r.trim(),onClick:()=>a(r.trim()),children:"Rename"})]})]})})}function zs({pending:s,busy:n,username:a,onConfirm:o,onCancel:r}){const t={"remove-device":{title:"Sign this device out of Memby?",body:"Its Emby account will not be changed. The set can sign in again at any time.",label:"Sign out",destructive:!0},"remove-account":{title:`Remove Memby access for ${a||"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(te,{title:t.title,body:t.body,confirmLabel:t.label,destructive:t.destructive,busy:!!n,onConfirm:o,onCancel:r})}function Ks(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const o=(s.options??[]).find(r=>r.value===n);return o?o.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const a=Array.isArray(n)?n:[];return a.length===0?"None":a.map(o=>{var r;return((r=(s.options??[]).find(d=>d.value===o))==null?void 0:r.label)??o}).join(", ")}function Hs(){const{userId:s=""}=pe(),{wrap:n}=Q(),{busy:a,run:o}=se(),r=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:d,error:t,loading:l,reload:c}=J(`${r}/preferences/history`,{pollMs:3e4}),[i,v]=x.useState(new Set),[m,h]=x.useState(null),p=(d==null?void 0:d.username)||"this account",f=(d==null?void 0:d.devices)??[],b=(d==null?void 0:d.revisions)??[],g=(d==null?void 0:d.catalogue)??[],j=u=>v(M=>{const w=new Set(M);return w.has(u)?w.delete(u):w.add(u),w}),k=u=>o("restore",async()=>{await n(()=>T.post(`${r}/preferences/revisions/${u}/restore`),`Restored r${u}.`),h(null),await c()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Settings history",intro:`Every change to ${p}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(K,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",p]})}),e.jsx(F,{message:t}),l?e.jsx(q,{}):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(S,{tone:d.currentSource==="admin"?"warn":"ok",children:["now on r",y(d.currentRevision)," · ",d.currentSource||"device"]}):e.jsx(S,{children:"defaults · never synced"}),children:f.length===0?e.jsx(V,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(u=>{const M=u.never?void 0:u.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":M})," ",u.name||"Memby TV"," ",u.signedOut?e.jsx(S,{children:"signed out"}):null]}),e.jsxs("p",{children:[u.never?"Has not fetched these settings yet":`Holding r${y(u.revision)} · taken ${L(u.ackedAt)}`,u.clientVersion?` · Memby ${u.clientVersion}`:"",u.signedOut?"":` · last seen ${L(u.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:u.never?e.jsx(S,{children:"never taken one"}):u.behind?e.jsxs(S,{tone:"bad",children:[y(u.behind)," behind"]}):e.jsx(S,{tone:"ok",children:"up to date"})})]},u.deviceId||u.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(W,{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:b.length===0?e.jsx(G,{columns:6,children:"Nothing has been changed on this account yet."}):b.flatMap(u=>{const M=i.has(u.revision),w=u.acks??[],$=u.changes??[],O=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(u.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",y(u.revision)," ",u.current?e.jsx(S,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(S,{tone:u.source==="admin"?"warn":"ok",children:u.author}),u.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",y(u.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:u.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):$.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:$.map((N,C)=>e.jsxs(z,{children:[N.name,": ",N.before," → ",N.after]},`${N.name}:${C}`))})}),e.jsx("td",{className:"num",children:w.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:w.map(N=>N.deviceName||N.deviceId).join(", "),children:y(w.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(A,{size:"sm",onClick:()=>j(u.revision),children:M?"Hide":"Show"}),u.current?null:e.jsx(A,{size:"sm",onClick:()=>h(u.revision),children:"Restore"})]})})]},u.revision)];return M&&O.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:g.map(N=>{var C;return e.jsxs(z,{children:[N.name,":"," ",Ks(N,(C=u.preferences)==null?void 0:C[N.key])]},N.key)})})})},`${u.revision}:detail`)),O})})]})})})]}),m!==null?e.jsx(te,{title:`Restore revision ${m}?`,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:a==="restore",onConfirm:()=>void k(m),onCancel:()=>h(null)}):null]})}function Gs({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(a=>e.jsx(z,{tone:a.version===s.version?"ok":void 0,children:a.version},a.version))})}function Zs(){const{status:s,error:n,loading:a}=Y(),o=(s==null?void 0:s.clients)??[],r=o.filter(t=>(t.capabilities??[]).includes("server_features_v1")),d=new Set(o.map(t=>t.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(F,{message:n}),a?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"devices known",value:y(o.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:y(o.filter(t=>de(t.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:y(r.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:y(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(W,{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:o.length===0?e.jsx(G,{columns:6,children:"No devices have signed in yet."}):o.map(t=>{const l=(t.capabilities??[]).includes("server_features_v1"),c=ye(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(K,{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(Gs,{client:t})}),e.jsx("td",{children:e.jsx(S,{tone:l?"ok":"warn",children:l?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:L(t.lastSeen)})]},`${t.deviceId}:${t.username}`)})})]})})})]})]})}const Ie={user:"",q:"",ip:"",outcome:"",from:"",to:""},Js=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function Ys(){var u,M,w,$,O,N;const[s,n]=x.useState("log"),[a,o]=x.useState(7),[r,d]=x.useState(Ie),[t,l]=x.useState(0),c=100,i=x.useMemo(()=>he({...r,days:r.from?void 0:a||void 0,limit:c,offset:t*c}),[r,a,t]),v=J(`/admin/api/logins${i}`,{enabled:s==="log"}),m=J(`/admin/api/logins/devices${i}`,{enabled:s==="devices"}),h=((u=v.data)==null?void 0:u.users)??((M=m.data)==null?void 0:M.users)??[],p=((w=v.data)==null?void 0:w.totals)??(($=m.data)==null?void 0:$.totals),f=((O=v.data)==null?void 0:O.retentionDays)??((N=m.data)==null?void 0:N.retentionDays)??90,b=s==="log"?v.loading:m.loading,g=s==="log"?v.error:m.error,j=C=>{d(_=>({..._,...C})),l(0)},k=Object.entries(r).some(([,C])=>C!=="")||!!r.from;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Sign-in history",intro:"Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected.",actions:e.jsx(me,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(F,{message:g}),p?e.jsx(ee,{tiles:[{label:"Successful sign-ins",value:y(p.logins),icon:"key",tone:"ok"},{label:"Refused",value:y(p.failures),icon:"shield",tone:p.failures>0?"warn":void 0},{label:"Televisions",value:y(p.devices),icon:"tv",tone:"info"},{label:"People",value:y(p.users),icon:"people",tone:"note"},{label:"Addresses",value:y(p.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(I,{label:"Window",children:e.jsx(me,{value:r.from?-1:a,options:Js.map(C=>({value:C.value,label:C.label})),onChange:C=>{o(C),j({from:"",to:""})}})}),e.jsx(I,{label:"Person",children:e.jsxs("select",{value:r.user,onChange:C=>j({user:C.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),h.map(C=>e.jsx("option",{value:C.id,children:C.username||C.id},C.id))]})}),e.jsx(I,{label:"Outcome",children:e.jsxs("select",{value:r.outcome,onChange:C=>j({outcome:C.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(I,{label:"Address",children:e.jsx("input",{type:"text",value:r.ip,placeholder:"10.0.0.4",onChange:C=>j({ip:C.target.value})})}),e.jsx(I,{label:"From",children:e.jsx("input",{type:"date",value:r.from,onChange:C=>j({from:C.target.value})})}),e.jsx(I,{label:"To",children:e.jsx("input",{type:"date",value:r.to,onChange:C=>j({to:C.target.value})})}),e.jsx(I,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:r.q,placeholder:"Name, device or address",onChange:C=>j({q:C.target.value})})}),e.jsx("div",{className:"filter-actions",children:k?e.jsx(A,{variant:"quiet",size:"sm",onClick:()=>{d(Ie),l(0)},children:"Clear"}):null})]}),b?e.jsx(q,{}):s==="log"?e.jsx(Xs,{data:v.data,page:t,limit:c,onPage:l}):e.jsx(Qs,{data:m.data})]})}function Xs({data:s,page:n,limit:a,onPage:o}){if(!s)return null;const r=s.events.length,d=s.total===0?0:n*a+1;return e.jsxs(e.Fragment,{children:[e.jsxs(ne,{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(ss,{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(V,{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:[y(t.logins)," in",t.failures>0?` · ${y(t.failures)} refused`:""]})]}),t.failures>0&&t.logins===0?e.jsx(S,{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":`${y(d)}–${y(d+r-1)} of ${y(s.total)}`}),footer:s.total>a?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:n===0,onClick:()=>o(n-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(n+1)*a>=s.total,onClick:()=>o(n+1),children:"Older"})]}):void 0,children:e.jsx(W,{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(G,{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:L(t.occurredAt)}),e.jsx("td",{children:t.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:t.deviceId?e.jsx(K,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(t.deviceId)}`,children:t.deviceName||t.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:t.ipAddress||"—"}),e.jsx("td",{className:"mono",children:t.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:t.success?t.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:t.failureReason||"refused"})})]},t.id))})]})})})]})}function Qs({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(W,{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(G,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(K,{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?y(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:y(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:y(n.failures)}):"—"}),e.jsx("td",{className:"num",children:y(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?L(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const en=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function sn(){const{deviceId:s=""}=pe(),[n,a]=x.useState(7),o=x.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${he({days:n||void 0,limit:200})}`,[s,n]),{data:r,error:d,loading:t}=J(o,{enabled:!!s}),l=r==null?void 0:r.summary,c=(l==null?void 0:l.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:c,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(K,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(K,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:c})]}),actions:e.jsx(me,{value:n,options:en.map(i=>({value:i.value,label:i.label})),onChange:a})}),e.jsx(F,{message:d}),t?e.jsx(q,{}):r?e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"Sign-ins today",value:y((l==null?void 0:l.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:y((l==null?void 0:l.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:y((l==null?void 0:l.failures)??0),icon:"shield",tone:((l==null?void 0:l.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:y((l==null?void 0:l.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:l!=null&&l.firstLogin?L(l.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:l!=null&&l.lastLogin?L(l.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(ne,{cols:"wide",children:[e.jsx(R,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(ss,{data:r.days,labelOf:i=>i.day,valueOf:i=>i.logins+i.failures,toneOf:i=>i.failures>i.logins?"bad":void 0,title:i=>`${i.day}: ${i.logins} in${i.failures?`, ${i.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:(l==null?void 0:l.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:r.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(l==null?void 0:l.clientVersion)||"unknown"})]})})]})}),e.jsx(R,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:r.versions.length===0?e.jsx(V,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:r.versions.map(i=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:i.version}),e.jsxs("p",{children:[L(i.firstSeen)," → ",L(i.lastSeen)]})]})},i.version))})})]})]}),e.jsx(R,{title:"Addresses",icon:"globe",tone:"data",children:r.addresses.length===0?e.jsx(V,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:r.addresses.map(i=>e.jsxs(z,{tone:i.failures>0?"warn":"data",children:[i.ipAddress," · ",y(i.logins),i.failures>0?` (+${y(i.failures)} refused)`:""]},i.ipAddress))})}),e.jsx(R,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[y(r.total)," in this window"]}),children:e.jsx(W,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:r.events.length===0?e.jsx(G,{columns:6,children:"This television has not connected in the selected window."}):r.events.map(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(i.occurredAt)}),e.jsx("td",{children:i.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:i.ipAddress||"—"}),e.jsx("td",{className:"mono",children:i.clientVersion||"—"}),e.jsx("td",{className:"muted",children:i.method}),e.jsx("td",{className:"nowrap",children:i.success?i.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:i.failureReason||"refused"})})]},i.id))})]})})})]}):null]})}function nn(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(!1),i=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),m=h=>t(h,async()=>{await r(()=>T.post("/admin/api/sync",{kind:h}),h==="full"?"Full re-import started.":"Import started."),c(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(F,{message:n}),a||!s?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"items",value:y(s.library.total),icon:"library",tone:"data"},...Object.keys(i).sort().map(h=>({label:h,value:y(i[h]),icon:"list"})),{label:"last import",value:L(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:v?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"sync",disabled:v,busy:d==="incremental",onClick:()=>void m("incremental"),children:"Sync new items"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full re-import"})]})})]}),l?e.jsx(te,{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 m("full"),onCancel:()=>c(!1)}):null]})}const tn={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:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(!1),[i,v]=x.useState(""),[m,h]=x.useState(!1),[p,f]=x.useState([]),[b,g]=x.useState(!1),j=s==null?void 0:s.mdblist;x.useEffect(()=>{b||!j||(c(j.enabled),f(j.sources??[]))},[j,b]);const k=()=>t("save",async()=>{await r(()=>T.post("/admin/api/mdblist-settings",{enabled:l,apiKey:i.trim(),clearApiKey:m,sources:p}),"Ratings settings saved."),v(""),h(!1),g(!1),await o()}),u=(j==null?void 0:j.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(F,{message:n}),a||!j?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"titles stored",value:y(u),icon:"database",tone:"data"},{label:"due to be re-checked",value:y(j.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:y((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(ne,{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:l?e.jsxs(S,{tone:"ok",children:["on · ",p.length," sources"]}):e.jsx(S,{children:j.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(X,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:l,onChange:M=>{c(M),g(!0)}}),e.jsx(I,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:i,placeholder:j.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:M=>v(M.target.value)})}),e.jsx(X,{label:"Remove the saved key",checked:m,onChange:h})]}),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:(j.availableSources??[]).length===0?e.jsx(V,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(j.availableSources??[]).map(M=>e.jsx(X,{label:tn[M]??M,checked:p.includes(M),onChange:w=>{g(!0),f($=>w?[...$,M]:$.filter(O=>O!==M))}},M))})})]}),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:u?"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 m;const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),l=(s==null?void 0:s.requestUsers)??[],c=((m=s==null?void 0:s.requestPolicy)==null?void 0:m.allowedUserIds)??[],i=x.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(h=>[h.userId,h])),[s==null?void 0:s.requestUsage]),v=h=>t(`access-${h}`,async()=>{const p=c.includes(h)?c.filter(f=>f!==h):[...c,h];await r(()=>T.post("/admin/api/request-policy",{allowedUserIds:p}),p.includes(h)?"Request access granted.":"Request access removed."),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(F,{message:n}),a?e.jsx(q,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(R,{title:"Where a request goes",intro:"A movie or series is monitored and searched for immediately. The configured download service handles it from there.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(S,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(S,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(V,{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:l.length===0?e.jsx(V,{children:"No one has signed in yet."}):e.jsx(W,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"User"}),e.jsx("th",{children:"Last seen"}),e.jsx("th",{className:"num",children:"Sent to services"}),e.jsx("th",{children:"Last request"}),e.jsx("th",{children:"Access"})]})}),e.jsx("tbody",{children:l.map(h=>{const p=i.get(h.id),f=c.includes(h.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:h.username})}),e.jsx("td",{className:"muted nowrap",children:L(h.lastSeen)}),e.jsx("td",{className:"num",children:(p==null?void 0:p.requests)??0}),e.jsx("td",{className:"muted nowrap",children:p!=null&&p.lastRequest?L(p.lastRequest):"—"}),e.jsx("td",{children:e.jsx(A,{size:"sm",variant:f?"quiet":"primary",busy:d===`access-${h.id}`,onClick:()=>void v(h.id),children:f?"Remove access":"Give access"})})]},h.id)})})]})})})]})]})}function ln(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(!1),i=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),m=(h,p,f)=>t(p,async()=>{await r(()=>T.post("/admin/api/for-you",{action:h}),f),c(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(F,{message:n}),a?e.jsx(q,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"Tracearr sessions",value:y((i==null?void 0:i.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:y((i==null?void 0:i.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:y((i==null?void 0:i.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:L(i==null?void 0:i.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:v?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"download",disabled:v,busy:d==="import",onClick:()=>void m("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full Tracearr backfill"}),e.jsx(A,{icon:"sync",disabled:v,busy:d==="rebuild",onClick:()=>void m("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(K,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),l?e.jsx(te,{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 m("full-import","full","Backfill started."),onCancel:()=>c(!1)}):null]})}const on=[["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 cn(s){return s?on.flatMap(([n,a])=>Object.entries(s[a]??{}).map(([o,r])=>({dimension:n,name:o,weight:r.weight??0,evidence:r.evidence??0}))).sort((n,a)=>Math.abs(a.weight)-Math.abs(n.weight)):[]}const Le=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function dn(){const{status:s}=Y(),{wrap:n}=Q(),{busy:a,run:o}=se(),[r,d]=x.useState(""),[t,l]=x.useState("default"),[c,i]=x.useState("0"),[v,m]=x.useState(""),[h,p]=x.useState(null),[f,b]=x.useState("Choose a person to inspect their recommendations."),[g,j]=x.useState(""),k=(s==null?void 0:s.requestUsers)??[],u=()=>o("run",async()=>{if(!r){j("Choose a person to pressure-test.");return}j(""),b("Running the permission check and the scorer…");const N=new URLSearchParams({userId:r,context:t,minutes:c||"0",limit:"100"});v&&N.set("at",new Date(v).toISOString());const C=await n(()=>T.get(`/admin/api/recommendations?${N.toString()}`));C?(p(C),b(`Scored at ${new Date().toLocaleTimeString()}.`)):b("Pressure test failed.")}),M=cn((h==null?void 0:h.profile)??null).slice(0,24),w=(h==null?void 0:h.actions)??[],$=(h==null?void 0:h.items)??[],O=(h==null?void 0:h.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(F,{message:g}),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:a==="run",onClick:()=>void u(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(I,{label:"Person",children:e.jsxs("select",{value:r,onChange:N=>d(N.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),k.map(N=>e.jsx("option",{value:N.id,children:N.username},N.id))]})}),e.jsx(I,{label:"Context",children:e.jsxs("select",{value:t,onChange:N=>l(N.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(I,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:c,onChange:N=>i(N.target.value)})}),e.jsx(I,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:N=>m(N.target.value)})})]})}),h?e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"prepared pool",value:y(h.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:y(h.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:y($.length),icon:"sparkle",tone:"note"},{label:"source events",value:y(O.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:O.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:L(O.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:[M.length===0?e.jsx(V,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:M.map(N=>e.jsxs(z,{tone:N.weight<0?"bad":void 0,children:[N.dimension,": ",N.name," ",Le(N.weight)," · n=",y(N.evidence)]},`${N.dimension}:${N.name}`))}),w.length===0?e.jsx(V,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:w.map((N,C)=>e.jsxs(z,{tone:"ok",children:[N.action,": ",N.title||N.itemId]},`${N.action}:${C}`))})]}),$.length===0?e.jsx(R,{children:e.jsx(V,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(ne,{children:$.map((N,C)=>{const _=N.explanation??{},E=Object.entries(_.components??{}).sort((B,ie)=>Math.abs(ie[1])-Math.abs(B[1])),U=N.exposure??{},H=[N.type,N.year,N.runtimeMinutes?`${N.runtimeMinutes} min`:null,...N.genres??[]].filter(Boolean).join(" · ");return e.jsxs(R,{title:`#${C+1} · ${N.title}`,intro:H,actions:e.jsx(z,{tone:"ok",children:Number(_.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[N.preparedReason||"No legacy prepared explanation",N.compatibilityLabel?` · ${N.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(_.reasonCodes??[]).map(B=>e.jsx(z,{tone:"ok",children:B},B)),E.map(([B,ie])=>e.jsxs(z,{tone:ie<0?"bad":void 0,children:[B,"=",Le(ie)]},B))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",y(N.baseRank??0)," · base ",Number(N.baseScore??0).toFixed(3)," · affinity ",Number(N.affinityScore??0).toFixed(3)," · compatibility"," ",Number(N.compatibilityScore??0).toFixed(3)," · impressions"," ",y(U.impressions??0)," · focuses ",y(U.focuses??0)," · selects"," ",y(U.selects??0)]}),e.jsx("div",{className:"chips",children:(N.eligibleRows??[]).map(B=>e.jsx(z,{tone:"ok",children:B},B))}),N.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",N.preparedEvidenceTitle]}):null]})]},`${C}:${N.title}`)})})]}):null]})}const Te=4;function hn(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r,show:d}=Q(),{busy:t,run:l}=se(),[c,i]=x.useState([]),[v,m]=x.useState(""),[h,p]=x.useState(!1),[f,b]=x.useState(""),[g,j]=x.useState(null),[k,u]=x.useState([]),M=s==null?void 0:s.heroPolicy;x.useEffect(()=>{h||!M||(i((M.pinnedItems??[]).slice(0,Te)),m(M.primeSubtitle??""),u(M.schedules??[]))},[M,h]);const w=()=>l("search",async()=>{const N=f.trim();if(!N)return;const C=await r(()=>T.get(`/admin/api/hero/search?q=${encodeURIComponent(N)}`));C&&j(C.items??[])}),$=N=>{if(!c.some(C=>C.id===N.id)){if(c.length>=Te){d("Remove a pinned title before adding another.","bad");return}i(C=>[...C,N]),p(!0)}},O=()=>l("save",async()=>{await r(()=>T.post("/admin/api/hero-policy",{pinnedItemIds:c.map(N=>N.id),primeSubtitle:v.trim(),schedules:k}),"Hero saved."),p(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."}),e.jsx(F,{message:n}),a?e.jsx(q,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsxs(R,{title:"Pinned titles",intro:"Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural.",icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void O(),children:"Save hero"}),e.jsx(A,{onClick:()=>{i([]),p(!0)},children:"Clear pins"}),h?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[c.length===0?e.jsx(V,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"chips",children:c.map((N,C)=>e.jsxs(A,{variant:"quiet",size:"sm",icon:"close",onClick:()=>{i(_=>_.filter(E=>E.id!==N.id)),p(!0)},children:[C+1,". ",N.name,N.year?` (${N.year})`:""]},N.id))}),e.jsx(I,{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:v,placeholder:"Leave blank for the automatic reason",onChange:N=>{m(N.target.value),p(!0)}})})]}),e.jsxs(R,{title:"Scheduled heroes",intro:"Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero.",icon:"clock",tone:"info",children:[k.length===0?e.jsx(V,{children:"No scheduled heroes yet."}):e.jsx("div",{className:"stack",children:k.map(N=>{const C=[...c,...g??[]].find(_=>_.id===N.itemId);return e.jsxs("div",{className:"row",children:[e.jsx("b",{children:(C==null?void 0:C.name)??N.itemId}),e.jsxs("span",{className:"muted",children:[new Date(N.startAt).toLocaleString()," → ",new Date(N.endAt).toLocaleString()," · priority ",N.priority]}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{u(_=>_.filter(E=>E.id!==N.id)),p(!0)},children:"Remove"})]},N.id)})}),c.length>0?e.jsx(A,{size:"sm",icon:"plus",onClick:()=>{const N=c[0];if(!N)return;const C=new Date,_=new Date(C.getTime()+2*60*60*1e3);u(E=>[...E,{id:crypto.randomUUID(),itemId:N.id,startAt:C.toISOString(),endAt:_.toISOString(),priority:0,enabled:!0}]),p(!0)},children:"Schedule first pinned title for two hours"}):e.jsx("p",{className:"hint",children:"Pin or search for a title first, then add it to a schedule."})]}),e.jsxs(R,{title:"Find a title",intro:"Search the imported Emby catalogue. Up to four films or series can be pinned.",icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(I,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:N=>b(N.target.value),onKeyDown:N=>{N.key==="Enter"&&w()}})}),e.jsx(A,{busy:t==="search",icon:"search",onClick:()=>void w(),children:"Search"})]}),g===null?null:g.length===0?e.jsx(V,{children:"No playable films or series matched that search."}):e.jsx(ne,{children:g.map(N=>e.jsx(R,{title:N.name,intro:`${N.type||"Title"} · ${N.year||"Year unknown"}`,children:e.jsx(A,{size:"sm",icon:"plus",disabled:c.some(C=>C.id===N.id),onClick:()=>$(N),children:c.some(C=>C.id===N.id)?"Pinned":"Add to hero"})},N.id))})]})]})]})}function un(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState({}),[i,v]=x.useState(null),m=s==null?void 0:s.features,h=(m==null?void 0:m.features)??[],p=(s==null?void 0:s.clients)??[],f=(m==null?void 0:m.revision)??0;x.useEffect(()=>{m&&c(u=>{const M={...u};for(const w of m.features??[])M[w.key]===void 0&&(M[w.key]=w.source==="override"?w.enabled?"on":"off":"default");return M})},[m]);const b=(u,M,w,$)=>t(M,async()=>{await r(()=>T.post("/admin/api/features",{action:u,expectedRevision:f,overrides:$??{}}),w),v(null),u==="save"&&c({}),await o()}),g=()=>{const u={};for(const[M,w]of Object.entries(l))w==="on"&&(u[M]=!0),w==="off"&&(u[M]=!1);return b("save","save","Published.",u)},j=p.filter(u=>(u.capabilities??[]).includes("server_features_v1")).length,k=!!(m!=null&&m.safeMode);return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(F,{message:n}),a||!m?e.jsx(q,{}):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:k?void 0:"danger",busy:d==="safe",onClick:()=>k?void b("leave-safe-mode","safe","Safe mode ended."):v({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:k?"Leave safe mode":"Enable safe mode"}),children:e.jsx(ns,{tiles:[{label:"features active",value:`${h.filter(u=>u.enabled).length} / ${h.length}`},{label:"explicit overrides",value:y(h.filter(u=>u.source==="override").length)},{label:"televisions reporting the control plane",value:`${j} / ${p.length}`},{label:"published revision",value:`r${y(f)}`}]})}),e.jsx(ne,{cols:"2",children:h.length===0?e.jsx(R,{title:"Nothing registered",icon:"sliders",children:e.jsx(V,{children:"No server features are registered."})}):h.map(u=>e.jsxs(R,{title:u.name,intro:u.description,actions:e.jsx(S,{tone:u.enabled?"ok":void 0,children:u.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",u.recovery]}),children:[e.jsx(I,{label:"Mode",children:e.jsxs("select",{value:l[u.key]??"default",onChange:M=>c(w=>({...w,[u.key]:M.target.value})),children:[e.jsx("option",{value:"default",children:"Safe default"}),e.jsx("option",{value:"on",children:"Forced on"}),e.jsx("option",{value:"off",children:"Forced off"})]})}),e.jsxs("div",{className:"chips",children:[e.jsx(z,{children:u.key}),e.jsxs(z,{children:["protocol ",y(u.minimumProtocol),"+"]}),e.jsx(z,{tone:u.compatible?"ok":"warn",children:u.compatible?"server compatible":"compatibility blocked"}),e.jsx(z,{tone:"note",children:u.area})]})]},u.key))}),e.jsx(R,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>void g(),children:"Publish changes"}),e.jsx(A,{disabled:!m.canRollback,onClick:()=>v({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:()=>v({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"}),k?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["live · revision r",y(f)]})]})})]}),i?e.jsx(te,{title:i.title,body:i.body,confirmLabel:i.label,destructive:i.action!=="rollback",busy:d===i.action,onConfirm:()=>void b(i.action,i.action,`${i.label} done.`),onCancel:()=>v(null)}):null]})}function mn(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r,show:d}=Q(),{busy:t,run:l}=se(),[c,i]=x.useState(!0),[v,m]=x.useState("6.5"),[h,p]=x.useState(!1);x.useEffect(()=>{var b,g;h||!s||(i(((b=s.playbackPolicy)==null?void 0:b.prerollEnabled)!==!1),m(String((((g=s.playbackPolicy)==null?void 0:g.prerollDurationMs)??6500)/1e3)))},[s,h]);const f=()=>l("save",async()=>{const b=Number(v);if(!Number.isFinite(b)||b<1||b>30){d("The preroll duration must be between 1 and 30 seconds.","bad");return}await r(()=>T.post("/admin/api/playback-policy",{prerollEnabled:c,prerollDurationMs:Math.round(b*1e3)}),"Playback policy saved."),p(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(F,{message:n}),a?e.jsx(q,{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(S,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(S,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:t==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(X,{label:"Show the preroll before a title starts",checked:c,onChange:b=>{i(b),p(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(I,{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:b=>{m(b.target.value),p(!0)}})})})]})]})}function pn(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(null),[i,v]=x.useState(null),[m,h]=x.useState(!1),p=s==null?void 0:s.subtitles,f=p==null?void 0:p.stored;x.useEffect(()=>{l||!p||c({bazarr:p.bazarrEnabled,openSubtitles:p.openSubtitlesEnabled,key:"",clearKey:!1,username:p.openSubtitlesUsername??"",password:"",clearLogin:!1})},[p,l]);const b=u=>c(M=>M&&{...M,...u}),g=()=>t("save",async()=>{l&&(await r(()=>T.post("/admin/api/subtitle-settings",{bazarrEnabled:l.bazarr,openSubtitlesEnabled:l.openSubtitles,openSubtitlesApiKey:l.key.trim(),clearOpenSubtitlesApiKey:l.clearKey,openSubtitlesUsername:l.username.trim(),openSubtitlesPassword:l.password,clearOpenSubtitlesLogin:l.clearLogin}),"Subtitle settings saved."),c(null),await o())}),j=()=>t("test",async()=>{v(null);const u=await r(()=>T.post("/admin/api/subtitle-test"));v((u==null?void 0:u.results)??[])}),k=()=>t("clear",async()=>{await r(()=>T.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),h(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(F,{message:n}),a||!p||!l?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"offered on televisions",value:p.available?"yes":"no",small:!0,icon:"captions",tone:p.available?"ok":void 0},{label:"providers on",value:y((p.bazarrEnabled&&p.bazarrConfigured?1:0)+(p.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:y((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:L(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(ne,{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:p.bazarrConfigured?l.bazarr?e.jsx(S,{tone:"ok",children:"on"}):e.jsx(S,{children:"off"}):e.jsx(S,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:p.bazarrConfigured?`Configured at ${p.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(X,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:l.bazarr,disabled:!p.bazarrConfigured,onChange:u=>b({bazarr:u})})}),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:p.openSubtitlesEnabled?e.jsx(S,{tone:p.openSubtitlesAccount?"ok":"warn",children:p.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(S,{children:p.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(X,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:l.openSubtitles,onChange:u=>b({openSubtitles:u})}),e.jsx(I,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l.key,placeholder:p.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:u=>b({key:u.target.value})})}),e.jsx(X,{label:"Remove the saved key",checked:l.clearKey,onChange:u=>b({clearKey:u})}),e.jsxs("div",{className:"fields",children:[e.jsx(I,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:l.username,placeholder:"Not signed in",onChange:u=>b({username:u.target.value})})}),e.jsx(I,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l.password,onChange:u=>b({password:u.target.value})})})]}),e.jsx(X,{label:"Sign out and forget the account",checked:l.clearLogin,onChange:u=>b({clearLogin:u})})]})]}),e.jsxs(R,{children:[e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>void g(),children:"Save subtitle settings"}),e.jsx(A,{busy:d==="test",icon:"pulse",onClick:()=>void j(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:p.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."})]}),i===null?null:i.length===0?e.jsx(V,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:i.map(u=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:u.provider}),e.jsx("p",{children:u.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(S,{tone:u.ok?"ok":"bad",children:u.ok?"reachable":"not reachable"})})]},u.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(S,{tone:"data",children:[y(f.count)," files · ",oe(f.bytes)]}):e.jsx(S,{children:"nothing held"}),footer:e.jsx(A,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>h(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),m?e.jsx(te,{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:()=>h(!1)}):null]})}function xn(){const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(null),[i,v]=x.useState(!1),m=s==null?void 0:s.updatePolicy;x.useEffect(()=>{l||!m||c({version:m.latestVersion??"",url:m.downloadUrl??"",notes:m.notes??"",retireBelow:m.retireBelowVersion??"",required:!!m.minimumVersion&&m.minimumVersion===m.latestVersion,destructive:!!m.retireBelowVersion&&m.retireBelowVersion===m.latestVersion})},[m,l]);const h=g=>t(g?"save":"off",async()=>{l&&(await r(()=>T.post("/admin/api/update-policy",{enabled:g,latestVersion:l.version.trim(),downloadUrl:l.url.trim(),notes:l.notes.trim(),required:l.required,destructive:l.destructive,retireBelowVersion:l.retireBelow.trim()}),g?"Update policy saved.":"Update prompts turned off."),v(!1),c(null),await o())}),p=!!(m!=null&&m.minimumVersion)&&(m==null?void 0:m.minimumVersion)===(m==null?void 0:m.latestVersion),f=!!(m!=null&&m.retireBelowVersion)&&(m==null?void 0:m.retireBelowVersion)===(m==null?void 0:m.latestVersion),b=g=>c(j=>j&&{...j,...g});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(F,{message:n}),a||!l?e.jsx(q,{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:m!=null&&m.enabled?e.jsxs(S,{tone:p?"warn":"ok",children:[f?"sign-out · ":p?"required · ":"optional · ",m.latestVersion]}):e.jsx(S,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:d==="save",onClick:()=>l.required?v(!0):void h(!0),children:"Save policy"}),e.jsx(A,{busy:d==="off",onClick:()=>void h(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(I,{label:"Latest version",children:e.jsx("input",{type:"text",value:l.version,placeholder:"0.2.63",onChange:g=>b({version:g.target.value})})}),e.jsx(I,{label:"APK URL",children:e.jsx("input",{type:"text",value:l.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:g=>b({url:g.target.value})})})]}),e.jsx(I,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:l.notes,placeholder:"One line the viewer reads",onChange:g=>b({notes:g.target.value})})}),e.jsx(I,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:l.retireBelow,placeholder:"0.2.44",onChange:g=>b({retireBelow:g.target.value})})}),e.jsx(X,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:l.required,onChange:g=>b({required:g})}),e.jsx(X,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:l.destructive,onChange:g=>b(g?{destructive:!0,required:!0,retireBelow:l.version.trim()}:{destructive:!1,retireBelow:l.retireBelow.trim()===l.version.trim()?"":l.retireBelow})})]}),i&&l?e.jsx(te,{title:l.destructive?"Sign every older television out?":"Require this update?",body:l.destructive?"This deletes sessions on every older television and forces viewers to sign in again after updating.":"Required updates block the home screen on every television below this version until they update.",confirmLabel:"Publish",destructive:l.destructive,busy:d==="save",onConfirm:()=>void h(!0),onCancel:()=>v(!1)}):null]})}const jn=2e4,vn=3e3;function De(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function gn(){const{wrap:s}=Q(),{busy:n,run:a}=se(),[o,r]=x.useState(!1),{data:d,error:t,loading:l,reload:c}=J("/admin/api/tasks?limit=60",{pollMs:o?vn:jn}),i=(d==null?void 0:d.tasks)??[],v=i.some(j=>j.running);v!==o&&r(v);const m=j=>a(j.id,async()=>{await s(()=>T.post(`/admin/api/tasks/${encodeURIComponent(j.id)}/run`),`${j.name} started.`),await c()}),h=(j,k)=>a(`${j.id}:enabled`,async()=>{await s(()=>T.put(`/admin/api/tasks/${encodeURIComponent(j.id)}`,{enabled:k}),k?`${j.name} switched on.`:`${j.name} switched off.`),await c()}),p=i.filter(j=>{var k;return((k=j.lastRun)==null?void 0:k.status)==="failed"}).length,f=i.filter(j=>!j.enabled).length,b=(d==null?void 0:d.groups)??[],g=i.filter(j=>!j.group);return e.jsxs(e.Fragment,{children:[e.jsx(D,{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(F,{message:t}),l?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"Tasks",value:y(i.length),icon:"clock",tone:"info"},{label:"Running now",value:y(i.filter(j=>j.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:y(p),icon:"alert",tone:p>0?"bad":void 0},{label:"Switched off",value:y(f),icon:"power",tone:f>0?"warn":void 0}]}),p>0?e.jsx(ke,{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,[...b,...g.length>0?[""]:[]].map(j=>{const k=i.filter(u=>u.group===j);return k.length===0?null:e.jsx(R,{title:j||"Other",icon:j==="System"?"chip":j==="Analytics"?"chart":"wrench",tone:j==="System"?"info":j==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:k.map(u=>{var M;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[u.name," ",u.running?e.jsx(S,{tone:"info",children:"running"}):null,u.enabled?null:e.jsx(S,{tone:"warn",children:"off"})]}),e.jsx("p",{children:u.description}),e.jsxs("p",{className:"quiet",children:[Cs(u.intervalSeconds),u.enabled&&u.nextRun?` · next ${le(u.nextRun).replace(" ago","")}`:"",u.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:L(u.lastRun.startedAt),children:le(u.lastRun.startedAt)}),` in ${ce(u.lastRun.durationMs)}`,u.lastRun.detail?` — ${u.lastRun.detail}`:""]}):" · never run"]}),(M=u.lastRun)!=null&&M.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(S,{tone:"bad",children:u.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[u.lastRun?e.jsx(S,{tone:De(u.lastRun.status),children:u.lastRun.status}):e.jsx(S,{children:"never run"}),e.jsx(X,{label:"",checked:u.enabled,disabled:n===`${u.id}:enabled`,onChange:w=>void h(u,w)}),e.jsx(A,{size:"sm",icon:"play",busy:n===u.id,disabled:u.running,onClick:()=>void m(u),children:"Run now"})]})]},u.id)})})},j||"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(W,{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(G,{columns:6,children:"No task has run yet."}):d==null?void 0:d.runs.map(j=>{var k;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(j.startedAt),children:le(j.startedAt)}),e.jsx("td",{children:((k=i.find(u=>u.id===j.taskId))==null?void 0:k.name)??j.taskId}),e.jsx("td",{className:"muted",children:j.trigger}),e.jsx("td",{children:e.jsx(S,{tone:De(j.status),children:j.status})}),e.jsx("td",{className:"num muted",children:ce(j.durationMs)}),e.jsx("td",{className:"muted",children:j.error||j.detail||"—"})]},j.id)})})]})})})]})]})}const bn={id:"",name:"Discord",url:"",enabled:!0,events:[]};function fn(){const{wrap:s,show:n}=Q(),{busy:a,run:o}=se(),{data:r,error:d,loading:t,reload:l}=J("/admin/api/integrations",{pollMs:6e4}),[c,i]=x.useState(null),[v,m]=x.useState(null),h=(r==null?void 0:r.catalogue)??[],p=(r==null?void 0:r.integrations)??[],f=k=>i({id:k.id,name:k.name,url:"",enabled:k.enabled,events:k.events??[]}),b=()=>o("save",async()=>{if(!c)return;await s(()=>T.post("/admin/api/integrations",c),c.id?"Integration saved.":"Integration added.")&&(i(null),await l())}),g=k=>o("remove",async()=>{await s(()=>T.del(`/admin/api/integrations/${encodeURIComponent(k.id)}`),`${k.name} removed.`),m(null),await l()}),j=k=>o(`test:${k.id}`,async()=>{const u=await s(()=>T.post(`/admin/api/integrations/${encodeURIComponent(k.id)}/test`));u&&n(u.message,u.ok?"ok":"bad"),await l()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{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:()=>i(bn),children:"Add a webhook"})}),e.jsx(F,{message:d}),((r==null?void 0:r.dropped)??0)>0?e.jsxs(ke,{tone:"warn",children:[y((r==null?void 0:r.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,t?e.jsx(q,{}):p.length===0&&!c?e.jsx(R,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(V,{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."})}):p.map(k=>e.jsx(yn,{integration:k,catalogue:h,busy:a,onEdit:()=>f(k),onTest:()=>void j(k),onRemove:()=>m(k)},k.id)),c?e.jsx(wn,{draft:c,catalogue:h,busy:a==="save",onChange:i,onSave:()=>void b(),onCancel:()=>i(null)}):null,v?e.jsx(te,{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:a==="remove",onConfirm:()=>void g(v),onCancel:()=>m(null)}):null]})}function yn({integration:s,catalogue:n,busy:a,onEdit:o,onTest:r,onRemove:d}){const t=s.health,l=!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(S,{tone:"ok",children:"on"}):e.jsx(S,{tone:"warn",children:"off"}),t.deliveries>0?e.jsx(S,{tone:l?"ok":"bad",children:l?"delivering":"failing"}):e.jsx(S,{children:"never used"}),e.jsx(A,{size:"sm",icon:"pulse",busy:a===`test:${s.id}`,onClick:r,children:"Test"}),e.jsx(A,{size:"sm",onClick:o,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(i=>{var v;return((v=n.find(m=>m.type===i))==null?void 0:v.label)??i}).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?L(t.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:t.deliveries>0?e.jsxs("span",{className:"quiet",children:[y(t.deliveries)," attempts, ",y(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:[L(t.lastFailure),t.lastError?` — ${t.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(W,{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(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(i.attemptedAt),children:le(i.attemptedAt)}),e.jsx("td",{className:"muted",children:i.eventType}),e.jsx("td",{children:i.success?e.jsx(S,{tone:"ok",children:i.statusCode||"ok"}):e.jsx(S,{tone:"bad",children:i.error||i.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ce(i.durationMs)})]},i.id))})]})}):e.jsx(W,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(G,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function wn({draft:s,catalogue:n,busy:a,onChange:o,onSave:r,onCancel:d}){const t=[...new Set(n.map(c=>c.group))],l=(c,i)=>o({...s,events:i?[...s.events,c]:s.events.filter(v=>v!==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:a,onClick:r,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(I,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:c=>o({...s,name:c.target.value})})}),e.jsx(I,{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=>o({...s,url:c.target.value})})})]}),e.jsx(X,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:c=>o({...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(i=>i.group===c).map(i=>e.jsx(X,{label:i.label,hint:i.description,checked:s.events.includes(i.type),onChange:v=>l(i.type,v)},i.type))]},c))]})}function kn(){var b;const{status:s,error:n,loading:a,reload:o}=Y(),{wrap:r}=Q(),{busy:d,run:t}=se(),[l,c]=x.useState(""),[i,v]=x.useState(!1),[m,h]=x.useState(!1),p=!!((b=s==null?void 0:s.maintenance)!=null&&b.enabled);x.useEffect(()=>{var g;!m&&s&&c(((g=s.maintenance)==null?void 0:g.message)??"")},[s,m]);const f=g=>t(g?"on":"off",async()=>{await r(()=>T.post("/admin/api/maintenance",{enabled:g,message:l}),g?"Memby is offline for every television.":"Memby is back online."),v(!1),h(!1),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(F,{message:n}),a?e.jsx(q,{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:p?"bad":"warn",actions:p?e.jsx(S,{tone:"bad",children:"offline"}):e.jsx(S,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"danger",disabled:p,onClick:()=>v(!0),children:"Go offline"}),e.jsx(A,{disabled:!p,busy:d==="off",onClick:()=>void f(!1),children:"Bring back online"})]}),children:e.jsx(I,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:l,placeholder:"Back shortly — upgrading the server",onChange:g=>{c(g.target.value),h(!0)}})})}),i?e.jsx(te,{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 f(!0),onCancel:()=>v(!1)}):null]})}function Nn(){const{status:s,error:n,loading:a}=Y(),o=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(F,{message:n}),a?e.jsx(q,{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(W,{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:o.length===0?e.jsx(G,{columns:8,children:"Nothing has been imported yet."}):o.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(r.startedAt)}),e.jsx("td",{children:r.kind}),e.jsx("td",{className:"muted",children:r.trigger}),e.jsx("td",{children:e.jsx(S,{tone:r.status==="success"?"ok":r.status==="running"?"warn":"bad",children:r.status})}),e.jsx("td",{className:"num",children:y(r.itemsSeen)}),e.jsx("td",{className:"num",children:y(r.itemsUpserted)}),e.jsx("td",{className:"num",children:y(r.itemsRemoved)}),e.jsx("td",{className:"muted",children:r.error||""})]},r.id||r.startedAt))})]})})})]})}const Pe={DEBUG:10,INFO:20,WARN:30,ERROR:40},Fe=2e4,Sn=5e3,Mn=["component","user","device","client","protocol","method","path","status","duration"];function Cn(s){const n=a=>{const o=Mn.indexOf(a);return o>=0?o:a==="error"?1e3:100};return Object.entries(s).filter(([a])=>a!=="version").sort((a,o)=>n(a[0])-n(o[0]))}const Rn=s=>[s.message,...Object.entries(s.attributes??{}).flat()].join(" ").toLowerCase();function En(){const[s,n]=x.useState([]),[a,o]=x.useState(0),[r,d]=x.useState(!1),[t,l]=x.useState("INFO"),[c,i]=x.useState(""),[v,m]=x.useState(""),h=x.useRef(0),p=x.useRef(!1),f=x.useRef(null),b=x.useRef(!0),g=x.useCallback(async()=>{if(!(r||p.current)){p.current=!0;try{let w=0,$;do{$=await T.get(`/admin/api/events?after=${h.current}&limit=1000`),h.current=$.next||h.current,$.dropped&&o(N=>N+$.dropped);const O=$.events??[];O.length>0&&n(N=>{const C=[...N,...O];return C.length>Fe?C.slice(C.length-Fe):C}),w+=1}while($.hasMore&&w<20);m("")}catch(w){m(w instanceof Error?w.message:String(w))}finally{p.current=!1}}},[r]);x.useEffect(()=>{if(g(),r)return;const w=window.setInterval(()=>void g(),Sn);return()=>window.clearInterval(w)},[g,r]);const j=x.useMemo(()=>{const w=Pe[t]??20,$=c.trim().toLowerCase();return s.filter(O=>(Pe[O.level]??0)>=w&&(!$||Rn(O).includes($)))},[s,t,c]),k=j.slice(-2500);x.useLayoutEffect(()=>{const w=f.current;w&&b.current&&(w.scrollTop=w.scrollHeight)},[k.length]);const u=()=>{const w=f.current;w&&(b.current=w.scrollHeight-w.scrollTop-w.clientHeight<50)},M=()=>{const w=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),$=document.createElement("a");$.href=URL.createObjectURL(w),$.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,$.click(),window.setTimeout(()=>URL.revokeObjectURL($.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(F,{message:v}),e.jsxs(R,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(I,{label:"Level",children:e.jsxs("select",{value:t,onChange:w=>l(w.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(I,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:c,placeholder:"Person, television, title, component, path…",onChange:w=>i(w.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(A,{onClick:()=>d(w=>!w),icon:r?"play":"clock",children:r?"Resume":"Pause"}),e.jsx(A,{onClick:()=>{n([]),o(0)},children:"Clear view"}),e.jsx(A,{onClick:M,icon:"download",children:"Export JSON"})]})]}),e.jsx("div",{className:"logview",ref:f,onScroll:u,role:"log","aria-live":"polite",children:k.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match this filter."}):k.map((w,$)=>e.jsxs("div",{className:"logline","data-level":w.level,children:[e.jsx("time",{children:L(w.occurredAt)}),e.jsx("span",{className:"lvl",children:w.level}),e.jsx("span",{className:"msg",children:w.message}),e.jsx("span",{className:"attrs",children:Cn(w.attributes??{}).map(([O,N])=>e.jsxs("span",{children:[" ",e.jsxs("b",{children:[O,"="]}),String(N)]},O))})]},`${w.occurredAt}:${$}`))}),e.jsxs("p",{className:"hint",children:[y(s.length)," retained · ",y(j.length)," matching",k.length`/admin/api/journeys${he({days:s,userId:a})}`,[s,a]),{data:t,error:l,loading:c}=J(d),i=t==null?void 0:t.stats,v=(t==null?void 0:t.users)??[],m=(t==null?void 0:t.actions)??[],h=(t==null?void 0:t.paths)??[],p=h[0],f=x.useMemo(()=>{const b=new Map(((t==null?void 0:t.features)??[]).map(j=>[j.feature,j])),g=new Map(Oe.map((j,k)=>[j,k]));return[...new Set([...Oe,...b.keys()])].map(j=>({name:j,stat:b.get(j)})).sort((j,k)=>{var M,w;const u=(((M=k.stat)==null?void 0:M.uses)??0)-(((w=j.stat)==null?void 0:w.uses)??0);return u||(g.get(j.name)??Number.MAX_SAFE_INTEGER)-(g.get(k.name)??Number.MAX_SAFE_INTEGER)})},[t==null?void 0:t.features]);return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(F,{message:l}),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(I,{label:"Window",children:e.jsxs("select",{value:s,onChange:b=>n(Number(b.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(I,{label:"User",children:e.jsxs("select",{value:a,onChange:b=>{const g=b.target.value;o(g),g&&r(`/admin/journeys/${encodeURIComponent(g)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(b=>e.jsx("option",{value:b.userId,children:b.username||b.userId},b.userId))]})})]}),children:c?e.jsx(q,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:"journeys",value:y(i==null?void 0:i.journeys),icon:"list",tone:"data"},{label:"viewers",value:y(i==null?void 0:i.viewers),icon:"people",tone:"info"},{label:"completion",value:ge(i==null?void 0:i.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:y(i==null?void 0:i.abandoned),icon:"alert",tone:"note"},{label:"active now",value:y(i==null?void 0:i.active),icon:"pulse",tone:"info"},{label:"average steps",value:((i==null?void 0:i.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ce(i==null?void 0:i.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:ge(i==null?void 0:i.completionRate)})]}),e.jsx(Ps,{value:(i==null?void 0:i.completed)??0,total:(i==null?void 0:i.journeys)??0}),e.jsxs("p",{children:[y(i==null?void 0:i.completed)," completed · ",y(i==null?void 0:i.abandoned)," abandoned ·"," ",y(i==null?void 0:i.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:p?`${ae(p.from)} → ${ae(p.to)}`:"Not enough data"}),e.jsx("p",{children:p?`${y(p.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(ne,{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(W,{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:m.length===0?e.jsx(G,{columns:3,children:"No significant actions in this window."}):m.map(b=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ae(b.action)}),e.jsx("span",{className:"table-sub",children:ae(b.category)})]}),e.jsx("td",{className:"num",children:y(b.events)}),e.jsx("td",{className:"num",children:y(b.journeys)})]},`${b.category}:${b.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(W,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:h.length===0?e.jsx(G,{columns:2,children:"No repeated paths in this window."}):h.map((b,g)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[ae(b.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",ae(b.to)]}),e.jsx("td",{className:"num",children:y(b.count)})]},`${b.from}:${b.to}:${g}`))})]})})})]}),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(W,{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:b,stat:g})=>{const j=(g==null?void 0:g.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:ae(b)}),e.jsx("td",{className:"num",children:y(j)}),e.jsx("td",{className:"muted nowrap",children:g?L(g.lastUsedAt):"—"}),e.jsx("td",{children:j===0?e.jsx(S,{tone:"warn",children:"not used"}):j<3?e.jsx(S,{tone:"note",children:"rare"}):e.jsx(S,{tone:"ok",children:"used"})})]},b)})})]})})}),a?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 re=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},ts=s=>re((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),qe=s=>s.itemName?`${re(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${re(s.source)} → ${re(s.target)}`:ts(s),$n=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]??re(s.action);function In(s){var a;const n=(a=[...s].reverse().find(o=>o.outcome))==null?void 0:a.outcome;return n==="success"||n==="completed"?{label:re(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:re(n),tone:"note"}:s.some(o=>o.action==="stop"&&o.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function Ln(s){const n=s.reduce((a,o,r)=>(o.category==="playback"&&o.action==="request"&&a.push(r),a),[]);return n.length===0?[s]:n.map((a,o)=>s.slice(o===0?0:a,n[o+1]??s.length))}function Tn(){var c,i;const{userId:s=""}=pe(),n=x.useMemo(()=>`/admin/api/journeys${he({days:90,userId:s})}`,[s]),{data:a,error:o,loading:r}=J(n),d=((i=(c=a==null?void 0:a.users)==null?void 0:c.find(v=>v.userId===s))==null?void 0:i.username)||s,t=x.useMemo(()=>{const v=new Map;for(const m of(a==null?void 0:a.events)??[])v.set(m.journeyId,[...v.get(m.journeyId)??[],m]);return[...v.values()].map(m=>m.sort((h,p)=>h.sequence-p.sequence)).sort((m,h)=>{var p,f;return(((p=h[0])==null?void 0:p.occurredAt)??"").localeCompare(((f=m[0])==null?void 0:f.occurredAt)??"")})},[a==null?void 0:a.events]),l=t.flatMap(v=>Ln(v).map((m,h)=>{var p;return{events:m,key:`${(p=v[0])==null?void 0:p.journeyId}:${h}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:`${d}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",crumbs:e.jsx(K,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(F,{message:o}),r?e.jsx(q,{}):e.jsx(R,{title:"Viewing journeys",intro:`${t.length} app session${t.length===1?"":"s"} · ${l.length} viewing journey${l.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:l.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):l.map((v,m)=>{const h=v.events,p=h[0],f=[...h].reverse().find(g=>g.itemName||g.action==="select"||g.category==="playback"&&g.action==="request"),b=In(h);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:L(p==null?void 0:p.occurredAt)}),e.jsxs("span",{children:["Journey ",m+1," · ",h.length," recorded steps"]})]}),e.jsx(S,{tone:b.tone,children:b.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(Z,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:ts(p)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(Z,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?qe(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(Z,{name:b.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:b.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:h.map(g=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":g.action}),e.jsxs("div",{children:[e.jsx("b",{children:$n(g)}),e.jsx("span",{children:qe(g)})]}),e.jsx("time",{children:L(g.occurredAt)})]},`${g.journeyId}:${g.sequence}`))})]},v.key)})})})]})}function Ue(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function Dn(){const[s,n]=x.useState(30),{data:a,error:o,loading:r}=J(`/admin/api/analytics?days=${s}`),d=(a==null?void 0:a.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(F,{message:o}),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(I,{label:"Window",children:e.jsxs("select",{value:s,onChange:t=>n(Number(t.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:r?e.jsx(q,{rows:1}):e.jsx(W,{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(G,{columns:8,children:"No events in this window."}):d.map(t=>e.jsxs("tr",{children:[e.jsx("td",{children:Ue(t.rowId)}),e.jsx("td",{className:"muted",children:Ue(t.rowKind)}),e.jsx("td",{className:"num",children:ce(t.dwellMs)}),e.jsx("td",{className:"num",children:y(t.impressions)}),e.jsx("td",{className:"num",children:y(t.focuses)}),e.jsx("td",{className:"num",children:y(t.selects)}),e.jsx("td",{className:"num",children:ge(t.selectRate)}),e.jsx("td",{className:"num",children:y(t.viewers)})]},`${t.rowId}:${t.rowKind}`))})]})})})]})}function Pn(){const[s,n]=x.useState(7),{data:a,error:o,loading:r}=J(`/admin/api/searches?days=${s}`),d=(a==null?void 0:a.terms)??[],t=(a==null?void 0:a.recent)??[],l=a==null?void 0:a.totals;return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(F,{message:o}),e.jsx(ee,{tiles:[{label:"searches",value:y((l==null?void 0:l.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:y((l==null?void 0:l.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:y((l==null?void 0:l.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(a==null?void 0:a.retentionDays)??30} days`,small:!0,icon:"clock"}]}),r?e.jsx(q,{}):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(I,{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(W,{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(G,{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:y(c.searches)}),e.jsx("td",{className:"num",children:y(c.viewers)}),e.jsx("td",{className:"muted nowrap",children:L(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(W,{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(G,{columns:3,children:"No searches in this window."}):t.map((c,i)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:L(c.occurredAt)}),e.jsx("td",{children:c.username||e.jsx(S,{tone:"warn",children:c.userId||"unknown"})}),e.jsx("td",{children:c.query})]},`${c.occurredAt}:${i}`))})]})})})]})]})}function Be(s,n){if(n===0)return s>0?"new this week":"no change";const a=Math.round((s-n)/n*100);return`${a>0?"+":""}${a}% vs last week`}function Fn(){const{data:s,error:n,loading:a}=J("/admin/api/views",{pollMs:6e4}),o=(s==null?void 0:s.daily)??[],r=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(D,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(F,{message:n}),a?e.jsx(q,{}):e.jsxs(e.Fragment,{children:[e.jsx(ee,{tiles:[{label:Be((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:y(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:Be((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:y(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(W,{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:o.length===0?e.jsx(G,{columns:3,children:"No home-screen visits yet."}):o.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.label}),e.jsx("td",{className:"num",children:y(d.visits)}),e.jsx("td",{className:"num",children:y(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(W,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:r.length===0?e.jsx(G,{columns:3,children:"No home-screen visits yet today."}):r.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.label}),e.jsx("td",{className:"num",children:y(d.visits)}),e.jsx("td",{className:"num",children:y(d.viewers)})]},d.label))})]})})})]})]})}function On(){return e.jsx(D,{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 qn(){return e.jsx(cs,{children:e.jsx(ks,{children:e.jsx(As,{children:e.jsx(Ds,{children:e.jsx(ds,{children:e.jsxs(P,{path:"/admin",element:e.jsx(Ls,{}),children:[e.jsx(P,{index:!0,element:e.jsx(Fs,{})}),e.jsx(P,{path:"activity",element:e.jsx(qs,{})}),e.jsx(P,{path:"accounts",element:e.jsx(Us,{})}),e.jsx(P,{path:"accounts/:userId",element:e.jsx(Bs,{})}),e.jsx(P,{path:"accounts/:userId/settings",element:e.jsx(Hs,{})}),e.jsx(P,{path:"clients",element:e.jsx(Zs,{})}),e.jsx(P,{path:"logins",element:e.jsx(Ys,{})}),e.jsx(P,{path:"devices/:deviceId",element:e.jsx(sn,{})}),e.jsx(P,{path:"library",element:e.jsx(nn,{})}),e.jsx(P,{path:"ratings",element:e.jsx(an,{})}),e.jsx(P,{path:"requests",element:e.jsx(rn,{})}),e.jsx(P,{path:"recommendations",element:e.jsx(ln,{})}),e.jsx(P,{path:"inspector",element:e.jsx(dn,{})}),e.jsx(P,{path:"hero",element:e.jsx(hn,{})}),e.jsx(P,{path:"features",element:e.jsx(un,{})}),e.jsx(P,{path:"playback",element:e.jsx(mn,{})}),e.jsx(P,{path:"subtitles",element:e.jsx(pn,{})}),e.jsx(P,{path:"updates",element:e.jsx(xn,{})}),e.jsx(P,{path:"tasks",element:e.jsx(gn,{})}),e.jsx(P,{path:"integrations",element:e.jsx(fn,{})}),e.jsx(P,{path:"maintenance",element:e.jsx(kn,{})}),e.jsx(P,{path:"imports",element:e.jsx(Nn,{})}),e.jsx(P,{path:"logs",element:e.jsx(En,{})}),e.jsx(P,{path:"journeys",element:e.jsx(An,{})}),e.jsx(P,{path:"journeys/:userId",element:e.jsx(Tn,{})}),e.jsx(P,{path:"views",element:e.jsx(Fn,{})}),e.jsx(P,{path:"engagement",element:e.jsx(Dn,{})}),e.jsx(P,{path:"searches",element:e.jsx(Pn,{})}),e.jsx(P,{path:"overview",element:e.jsx(hs,{to:"/admin",replace:!0})}),e.jsx(P,{path:"*",element:e.jsx(On,{})})]})})})})})})}const is=document.getElementById("root");if(!is)throw new Error("the console has no root element to render into");ze(is).render(e.jsx(x.StrictMode,{children:e.jsx(qn,{})})); diff --git a/admin-ui/dist/assets/index-BpHOHb2g.js b/admin-ui/dist/assets/index-BpHOHb2g.js new file mode 100644 index 0000000..43c47f2 --- /dev/null +++ b/admin-ui/dist/assets/index-BpHOHb2g.js @@ -0,0 +1,11 @@ +import{r as p,a as ls,u as fe,L as G,b as We,O as os,N as cs,c as xe,B as ds,R as hs,d as q,e as us}from"./router-BwjLFE7Y.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))t(o);new MutationObserver(o=>{for(const d of o)if(d.type==="childList")for(const i of d.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&t(i)}).observe(document,{childList:!0,subtree:!0});function l(o){const d={};return o.integrity&&(d.integrity=o.integrity),o.referrerPolicy&&(d.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?d.credentials="include":o.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function t(o){if(o.ep)return;o.ep=!0;const d=l(o);fetch(o.href,d)}})();var _e={exports:{}},je={};/** + * @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 ms=p,ps=Symbol.for("react.element"),xs=Symbol.for("react.fragment"),js=Object.prototype.hasOwnProperty,vs=ms.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,gs={key:!0,ref:!0,__self:!0,__source:!0};function ze(s,n,l){var t,o={},d=null,i=null;l!==void 0&&(d=""+l),n.key!==void 0&&(d=""+n.key),n.ref!==void 0&&(i=n.ref);for(t in n)js.call(n,t)&&!gs.hasOwnProperty(t)&&(o[t]=n[t]);if(s&&s.defaultProps)for(t in n=s.defaultProps,n)o[t]===void 0&&(o[t]=n[t]);return{$$typeof:ps,type:s,key:d,ref:i,props:o,_owner:vs.current}}je.Fragment=xs;je.jsx=ze;je.jsxs=ze;_e.exports=je;var e=_e.exports,Ke,Se=ls;Ke=Se.createRoot,Se.hydrateRoot;const He={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 Q({name:s,className:n}){const l=He[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 ve({name:s,tone:n}){return He[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(Q,{name:s})}):null}const ye=[{id:"overview",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:"people",label:"People",items:[{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:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one 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.",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.",hidden:!0}]},{id:"content",label:"Content",items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{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:"personalisation",label:"Personalisation",items:[{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{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:"Experience",items:[{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:"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:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"}]},{id:"operations",label:"Operations",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:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"reporting",label:"Reporting",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:"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"},{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"}]}];ye.flatMap(s=>s.items);const bs=ye.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Me extends Error{constructor(n,l){super(n),this.status=l,this.name="ApiError"}}const fs=5*60*1e3;let Ge=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{Ge=Date.now()},{passive:!0});const ys=()=>Date.now()-Ge({}));throw new Me(t.error??`Request failed (${l.status})`,l.status)}if(l.status!==204)return await l.json()}function he(s){const n=new URLSearchParams;for(const[t,o]of Object.entries(s))o==null||o===""||o===!1||n.set(t,String(o));const l=n.toString();return l?`?${l}`:""}const T={get:s=>me(s),post:(s,n)=>me(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>me(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>me(s,{method:"DELETE"})},ks=3e4,Ze=p.createContext(null);function Ns({children:s}){var g;const[n,l]=p.useState(),[t,o]=p.useState(!0),[d,i]=p.useState(""),[r,c]=p.useState(""),[a,v]=p.useState(!0),x=p.useRef(0),h=p.useCallback(async()=>{const b=++x.current;try{const m=await T.get("/admin/api/status");if(b!==x.current)return;l(m),o(!0),c("")}catch(m){if(b!==x.current)return;o(!1),c(m instanceof Error?m.message:String(m))}finally{b===x.current&&(i(new Date().toISOString()),v(!1))}},[]),u=p.useCallback(async b=>{var m;await T.post("/admin/api/maintenance",{enabled:b,message:((m=n==null?void 0:n.maintenance)==null?void 0:m.message)??""}),await h()},[h,(g=n==null?void 0:n.maintenance)==null?void 0:g.message]);p.useEffect(()=>{h();let b;const m=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void h(),ks)},y=()=>{m(),document.hidden||h()};return m(),document.addEventListener("visibilitychange",y),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",y)}},[h]);const f=p.useMemo(()=>({status:n,version:(n==null?void 0:n.serverVersion)??"",online:t,checkedAt:d,error:r,loading:a,reload:h,setMaintenance:u}),[n,t,d,r,a,h,u]);return e.jsx(Ze.Provider,{value:f,children:s})}function ee(){const s=p.useContext(Ze);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Ss(s,n){const l=s.label.toLowerCase(),t=s.group.toLowerCase();return l.startsWith(n)?4:l.includes(n)?3:t.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function Ms(){const s=fe(),{status:n}=ee(),[l,t]=p.useState(!1),[o,d]=p.useState(""),[i,r]=p.useState(0),c=p.useRef(null),a=p.useRef(null),v=p.useMemo(()=>{const h=o.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,g)=>({item:f,rank:h?Ss(f,h):1,index:g})).filter(f=>f.rank>0).sort((f,g)=>g.rank-f.rank||f.index-g.index).map(({item:f,rank:g})=>({item:f,rank:g}))},[o]);p.useEffect(()=>r(0),[o]),p.useEffect(()=>{const h=u=>{var f;(f=c.current)!=null&&f.contains(u.target)||t(!1)};return document.addEventListener("pointerdown",h),()=>document.removeEventListener("pointerdown",h)},[]),p.useEffect(()=>{const h=u=>{var g,b;if(u.key!=="S"||!u.shiftKey||u.ctrlKey||u.metaKey||u.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(u.preventDefault(),(g=a.current)==null||g.focus(),(b=a.current)==null||b.select())};return window.addEventListener("keydown",h),()=>window.removeEventListener("keydown",h)},[]);const x=h=>{var u;t(!1),d(""),(u=a.current)==null||u.blur(),s(h)};return e.jsxs("div",{className:"omni",ref:c,"data-open":l||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(Q,{name:"search"}),e.jsx("input",{ref:a,type:"search",value:o,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":l,onFocus:()=>t(!0),onChange:h=>{d(h.target.value),t(!0)},onKeyDown:h=>{var u;if(h.key==="Escape")d(""),t(!1),(u=a.current)==null||u.blur();else if(h.key==="ArrowDown")h.preventDefault(),r(f=>Math.min(f+1,v.length-1));else if(h.key==="ArrowUp")h.preventDefault(),r(f=>Math.max(f-1,0));else if(h.key==="Enter"){const f=v[i];if(!f)return;h.preventDefault(),x(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((h,u)=>e.jsxs("a",{className:u===i?"omni-item on":"omni-item",href:h.item.path,role:"option","aria-selected":u===i,onPointerEnter:()=>r(u),onClick:f=>{f.preventDefault(),x(h.item.path)},children:[h.item.icon?e.jsx(Q,{name:h.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:h.item.label}),e.jsx("small",{children:h.item.intro})]}),e.jsx("span",{className:"omni-group",children:h.item.group})]},h.item.id))}):null]})}const w=s=>(s??0).toLocaleString(),L=s=>s?new Date(s).toLocaleString():"—",Cs=s=>s?new Date(s).toLocaleTimeString():"—";function ce(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 Rs(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 oe(s){const n=["B","KB","MB","GB"];let l=Number(s??0),t=0;for(;l>=1024&&tString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),be=s=>`${Math.round((s??0)*100)}%`;function le(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 t=Math.floor(l/60);if(t<60)return`${t} min ago`;const o=Math.floor(t/60);if(o<24)return`${o}h ago`;const d=Math.floor(o/24);return d<30?`${d}d ago`:new Date(s).toLocaleDateString()}const Ye=15*60*1e3,Es=3*60*60*1e3,de=s=>!!s&&Date.now()-new Date(s).getTime(){var m;try{const y=await T.get(`/admin/api/notifications?limit=${Re}`);l(y.events),o(y.unread),i(y.types),x.current=Math.max(x.current,((m=y.events[0])==null?void 0:m.id)??0),v("")}catch(y){v(y instanceof Error?y.message:String(y))}},[]),u=p.useCallback(m=>{x.current=Math.max(x.current,m.id),l(y=>y.some(j=>j.id===m.id)?y:[m,...y].sort((j,R)=>R.id-j.id).slice(0,Re)),m.readAt||o(y=>y+1),i(y=>y.some(j=>j.type===m.type)?y.map(j=>j.type===m.type?{...j,count:j.count+1}:j):[...y,{type:m.type,count:1}])},[]);p.useEffect(()=>{h()},[h]),p.useEffect(()=>{let m=null,y,j=!1;return(()=>{j||(m=new EventSource(`/admin/api/notifications/stream?after=${x.current}`),m.addEventListener("open",()=>{c(!0),window.clearInterval(y),y=void 0}),m.addEventListener("admin",N=>{try{u(JSON.parse(N.data))}catch{}}),m.addEventListener("error",()=>{c(!1),y===void 0&&(y=window.setInterval(()=>void h(),Is))}))})(),()=>{j=!0,m==null||m.close(),window.clearInterval(y)}},[u,h]);const f=p.useCallback(async m=>{const y=m.filter(j=>j>0);if(y.length!==0){l(j=>j.map(R=>y.includes(R.id)&&!R.readAt?{...R,readAt:new Date().toISOString()}:R));try{const j=await T.post("/admin/api/notifications/read",{ids:y});o(j.unread)}catch{h()}}},[h]),g=p.useCallback(async()=>{o(0),l(m=>m.map(y=>y.readAt?y:{...y,readAt:new Date().toISOString()}));try{const m=await T.post("/admin/api/notifications/read",{all:!0});o(m.unread)}catch{h()}},[h]),b=p.useMemo(()=>({events:n,unread:t,types:d,connected:r,error:a,markRead:f,markAllRead:g,reload:h}),[n,t,d,r,a,f,g,h]);return e.jsx(Xe.Provider,{value:b,children:s})}function ke(){const s=p.useContext(Xe);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function Qe(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function es(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 Ee(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 Ie=99;function As(){const{events:s,unread:n,connected:l,markRead:t,markAllRead:o}=ke(),[d,i]=p.useState(!1),r=p.useRef(null);return p.useEffect(()=>{const c=v=>{var x;(x=r.current)!=null&&x.contains(v.target)||i(!1)},a=v=>{v.key==="Escape"&&i(!1)};return document.addEventListener("pointerdown",c),document.addEventListener("keydown",a),()=>{document.removeEventListener("pointerdown",c),document.removeEventListener("keydown",a)}},[]),p.useEffect(()=>{if(!d)return;const c=s.filter(a=>!a.readAt).map(a=>a.id);c.length>0&&t(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:()=>i(c=>!c),children:[e.jsx(Q,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>Ie?`${Ie}+`: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:[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 o(),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 a=e.jsxs(e.Fragment,{children:[e.jsx(ve,{name:es(c.type),tone:Qe(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:le(c.occurredAt)})]})]});return c.link?e.jsx(G,{className:"bell-item","data-unread":!c.readAt||void 0,to:c.link,onClick:()=>i(!1),children:a},c.id):e.jsx("div",{className:"bell-item","data-unread":!c.readAt||void 0,children:a},c.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(G,{to:"/admin/activity",onClick:()=>i(!1),children:"All activity"})})]}):null]})}function P({title:s,intro:n,actions:l,crumbs:t}){return e.jsxs("header",{className:"page-head",children:[t?e.jsx("nav",{className:"crumbs",children:t}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{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 M({title:s,intro:n,icon:l,tone:t,actions:o,footer:d,children:i}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[l?e.jsx(ve,{name:l,tone:t}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),o?e.jsx("div",{className:"card-head-actions",children:o}):null]}):null,i,d?e.jsx("div",{className:"card-foot",children:d}):null]})}function ne({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function se({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(ve,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function S({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function H({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function V({children:s}){return e.jsx("p",{className:"empty",children:s})}function X({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 E({children:s,onClick:n,variant:l,size:t,disabled:o,busy:d,icon:i,type:r="button",title:c}){return e.jsxs("button",{type:r,className:"","data-variant":l,"data-size":t,disabled:o||d,onClick:n,title:c,children:[d?e.jsx("span",{className:"spinner"}):i?e.jsx(Q,{name:i}):null,s]})}function A({label:s,hint:n,children:l,grow:t}){return e.jsxs("label",{className:t?"field grow":"field",children:[e.jsx("span",{children:s}),l,n?e.jsx("small",{children:n}):null]})}function W({label:s,hint:n,checked:l,onChange:t,disabled:o}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:l,disabled:o,onChange:d=>t(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 pe({value:s,options:n,onChange:l}){return e.jsx("div",{className:"segments",role:"group",children:n.map(t=>e.jsx("button",{type:"button","aria-pressed":t.value===s,onClick:()=>l(t.value),children:t.label},String(t.value)))})}function _({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function ss({data:s,labelOf:n,valueOf:l,toneOf:t,title:o}){if(s.length===0)return e.jsx(V,{children:"Nothing in this window."});const d=s.map(r=>l(r)),i=Math.max(1,...d);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((r,c)=>{const a=l(r);return e.jsx("div",{className:"bar","data-tone":t==null?void 0:t(r),"data-empty":a===0||void 0,style:{height:`${Math.max(2,a/i*100)}%`},title:o?o(r):`${n(r,c)}: ${a}`},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 Ts({value:s,total:n,tone:l}){const t=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":l,children:e.jsx("div",{style:{width:`${t*100}%`}})})}function D({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(Q,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(Q,{name:"close"})}):null]}):null}function O({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 te({title:s,body:n,confirmLabel:l="Confirm",destructive:t,busy:o,onConfirm:d,onCancel:i}){const r=p.useId(),c=p.useRef(null);return p.useEffect(()=>{var v;(v=c.current)==null||v.focus();const a=x=>{x.key==="Escape"&&i()};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[i]),e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":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(E,{onClick:i,variant:"quiet",children:"Cancel"}),e.jsx(E,{onClick:d,variant:t?"danger":"primary",busy:o,children:l})]})]})})}function $e({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 ns({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 Ls({open:s,onNavigate:n}){const{unread:l}=ke(),t=We(),[o,d]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),i=r=>{d(c=>{const a={...c,[r]:!c[r]};try{localStorage.setItem("memby-admin-nav",JSON.stringify(a))}catch{}return a})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:ye.map(r=>{const c=r.items.filter(x=>!x.hidden);if(c.length===0)return null;const v=c.some(x=>t.pathname===x.path)||!o[r.id];return e.jsxs("div",{className:"rail-group",children:[r.label?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":v,onClick:()=>i(r.id),children:[r.label,e.jsx(Q,{name:"caret",className:"ico caret"})]}):null,v?c.map(x=>e.jsx(cs,{to:x.path,end:x.path==="/admin",onClick:n,className:({isActive:h})=>h?"on":"","aria-current":void 0,children:({isActive:h})=>e.jsxs("span",{style:{display:"contents"},ref:u=>{const f=u==null?void 0:u.parentElement;f&&(h?f.setAttribute("aria-current","page"):f.removeAttribute("aria-current"))},children:[x.icon?e.jsx(Q,{name:x.icon}):null,x.label,x.badge==="notifications"&&l>0?e.jsx("span",{className:"rail-badge",children:l>99?"99+":l}):null]})},x.id)):null]},r.id)})})}function Ds(){var f;const{version:s,online:n,checkedAt:l,status:t,setMaintenance:o}=ee(),[d,i]=p.useState(!1),[r,c]=p.useState(!1),[a,v]=p.useState(!1),x=We(),h=!!((f=t==null?void 0:t.maintenance)!=null&&f.enabled),u=async()=>{if(!(r||!t)){c(!0);try{await o(!h)}finally{c(!1)}}};return p.useEffect(()=>i(!1),[x.pathname]),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":d,"aria-controls":"rail",onClick:()=>i(g=>!g),children:e.jsx(Q,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Ms,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":n&&!h?"ok":"bad","aria-pressed":h,disabled:!t||r,title:h?"Bring Memby back online":"Take Memby offline",onClick:()=>h?void u():v(!0),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:h?"offline":n?"online":"not responding"}),e.jsx("span",{children:h?"click to bring back online":l?`updated ${Cs(l)}`:""})]}),e.jsx(As,{}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsx("button",{type:"submit","data-variant":"quiet","data-size":"sm",title:"Sign out",children:e.jsx(Q,{name:"logout"})})})]})]}),e.jsx(Ls,{open:d,onNavigate:()=>i(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(os,{})}),a?e.jsx(te,{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:r,onConfirm:()=>{v(!1),u()},onCancel:()=>v(!1)}):null]})}const ts=p.createContext(null),Ps=5e3;function Fs({children:s}){const[n,l]=p.useState([]),t=p.useRef(1),o=p.useCallback(c=>{l(a=>a.filter(v=>v.id!==c))},[]),d=p.useCallback((c,a="ok")=>{const v=t.current++;l(x=>[...x,{id:v,message:c,tone:a}]),window.setTimeout(()=>o(v),Ps)},[o]),i=p.useCallback(async(c,a)=>{try{const v=await c();return a&&d(a,"ok"),v}catch(v){d(v instanceof Error?v.message:String(v),"bad");return}},[d]),r=p.useMemo(()=>({show:d,wrap:i}),[d,i]);return e.jsxs(ts.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(Q,{name:c.tone==="bad"?"alert":"check"}),e.jsx("span",{children:c.message}),e.jsx("button",{type:"button",onClick:()=>o(c.id),"aria-label":"Dismiss",children:e.jsx(Q,{name:"close"})})]},c.id))})]})}function Z(){const s=p.useContext(ts);if(!s)throw new Error("useToast used outside ToastProvider");return s}function K(s,n={}){const{pollMs:l,enabled:t=!0}=n,[o,d]=p.useState(),[i,r]=p.useState(""),[c,a]=p.useState(t),[v,x]=p.useState(!1),h=p.useRef(0),u=p.useRef(!1),f=p.useCallback(async()=>{if(!t)return;const g=++h.current;u.current&&x(!0);try{const b=await T.get(s);if(g!==h.current)return;d(b),r(""),u.current=!0}catch(b){if(g!==h.current)return;r(b instanceof Error?b.message:String(b))}finally{g===h.current&&(a(!1),x(!1))}},[s,t]);return p.useEffect(()=>(u.current=!1,a(!0),f(),()=>{h.current+=1}),[f]),p.useEffect(()=>{if(!l||!t)return;let g;const b=()=>{window.clearInterval(g),g=document.hidden?void 0:window.setInterval(()=>void f(),l)},m=()=>{b(),document.hidden||f()};return b(),document.addEventListener("visibilitychange",m),()=>{window.clearInterval(g),document.removeEventListener("visibilitychange",m)}},[l,t,f]),{data:o,error:i,loading:c,refreshing:v,reload:f,set:d}}function J(){const[s,n]=p.useState(null),l=p.useRef(!0);p.useEffect(()=>()=>{l.current=!1},[]);const t=p.useCallback(async(o,d)=>{n(o);try{return await d(),!0}finally{l.current&&n(null)}},[]);return{busy:s,run:t}}function qs(){var b,m,y,j,R;const{status:s,error:n,loading:l}=ee(),t=K("/admin/api/runtime",{pollMs:3e4}),o=K("/admin/api/views",{pollMs:6e4});if(l||!s)return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(D,{message:n}),e.jsx(O,{})]});const d=s.features??{features:[],revision:0,safeMode:!1},i=d.features??[],r=s.clients??[],c=r.filter(N=>de(N.lastSeen)).length,a=s.updatePolicy??{},v=!!a.minimumVersion&&a.minimumVersion===a.latestVersion,x=s.playbackPolicy,h=s.mdblist,u=s.forYou,f=(s.runs??[]).slice(0,5),g=t.data;return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(D,{message:n}),e.jsx(se,{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 · ${((b=o.data)==null?void 0:b.lastWeek.visits)??0} this time last week`,value:w((m=o.data)==null?void 0:m.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((y=o.data)==null?void 0:y.lastWeek.viewers)??0} this time last week`,value:w((j=o.data)==null?void 0:j.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${i.filter(N=>N.enabled).length} / ${i.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:L(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(ne,{cols:"2",children:[e.jsx(M,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx($e,{rows:[{label:"Availability",value:(R=s.maintenance)!=null&&R.enabled?e.jsx(S,{tone:"bad",children:"offline for maintenance"}):e.jsx(S,{tone:"ok",children:"online"})},{label:"Feature control plane",value:d.safeMode?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["revision r",w(d.revision)]})},{label:"App update prompt",value:a.enabled?e.jsxs(S,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",a.latestVersion]}):e.jsx(S,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(S,{tone:"warn",children:"running"}):e.jsxs(S,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(x==null?void 0:x.prerollEnabled)===!1?e.jsx(S,{children:"off"}):e.jsxs(S,{tone:"ok",children:[((x==null?void 0:x.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(M,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx($e,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(S,{tone:"ok",children:"ready"}):e.jsx(S,{children:"not configured"})},{label:"MDBList ratings",value:h!=null&&h.enabled?e.jsxs(S,{tone:"ok",children:[w(h.cachedTitles)," titles stored"]}):e.jsx(S,{children:h!=null&&h.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(S,{tone:"warn",children:"rebuilding"}):e.jsxs(S,{children:[w((u==null?void 0:u.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:w((u==null?void 0:u.profiles)??0)})}]})})]}),e.jsxs(ne,{cols:"2",children:[e.jsx(M,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(G,{to:"/admin/imports",children:"All imports"}),children:e.jsx(_,{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(X,{columns:4,children:"No imports have run yet."}):f.map(N=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(N.startedAt)}),e.jsx("td",{children:N.kind}),e.jsx("td",{children:e.jsx(S,{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(M,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:g?e.jsxs(e.Fragment,{children:[e.jsx(ns,{tiles:[{label:"goroutines",value:w(g.goroutines)},{label:"heap in use",value:oe(g.heapInuse)},{label:"reserved",value:oe(g.sys)},{label:"collections",value:w(g.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",oe(g.nextGc)," · memory limit"," ",g.memoryLimit>0&&g.memoryLimit`/admin/api/notifications${he({days:o,type:i,severity:c,unread:v,limit:f,offset:h*f})}`,[o,i,c,v,h]),{data:b,error:m,loading:y,reload:j}=K(g),R=async()=>{await l(),await j()};return e.jsxs(e.Fragment,{children:[e.jsx(P,{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(E,{onClick:()=>void R(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(D,{message:m}),e.jsx(se,{tiles:[{label:"Events in window",value:w((b==null?void 0:b.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:w(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:w((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(A,{label:"Window",children:e.jsx(pe,{value:o,options:Os.map(N=>({value:N.value,label:N.label})),onChange:N=>{d(N),u(0)}})}),e.jsx(A,{label:"Kind",children:e.jsxs("select",{value:i,onChange:N=>{r(N.target.value),u(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((b==null?void 0:b.types)??[]).map(N=>e.jsxs("option",{value:N.type,children:[Ee(N.type)," (",N.count,")"]},N.type))]})}),e.jsx(A,{label:"Severity",children:e.jsxs("select",{value:c,onChange:N=>{a(N.target.value),u(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(A,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:N=>{x(N.target.value==="unread"),u(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(E,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{j(),t()},children:"Refresh"})})]}),y?e.jsx(O,{}):e.jsx(M,{title:"Events",icon:"bell",tone:"info",footer:((b==null?void 0:b.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx(E,{size:"sm",disabled:h===0,onClick:()=>u(h-1),children:"Newer"}),e.jsx(E,{size:"sm",disabled:(h+1)*f>=((b==null?void 0:b.total)??0),onClick:()=>u(h+1),children:"Older"})]}):void 0,children:e.jsx(_,{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(X,{columns:6,children:"Nothing has happened in this window."}):b==null?void 0:b.events.map(N=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(N.occurredAt),children:le(N.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(ve,{name:es(N.type),tone:Qe(N)}),Ee(N.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:N.title}),N.summary?e.jsx("div",{className:"muted",children:N.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:N.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:N.target||"—"}),e.jsxs("td",{className:"nowrap",children:[N.readAt?null:e.jsx(S,{tone:"ok",children:"new"}),N.link?e.jsx(G,{className:"table-row-link",to:N.link,children:"Open"}):null]})]},N.id))})]})})})]})}function Bs(){const{data:s,error:n,loading:l}=K("/admin/api/accounts",{pollMs:6e4}),t=(s==null?void 0:s.accounts)??[],o=t.flatMap(r=>r.devices??[]),d=t.filter(r=>{var c;return(c=r.recommendations)==null?void 0:c.completed}).length,i=t.filter(r=>{var c,a;return((c=r.recommendations)==null?void 0:c.prompted)&&!((a=r.recommendations)!=null&&a.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(D,{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(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"Memby users",value:w(t.length),icon:"people",tone:"note"},{label:"signed-in devices",value:w(o.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(o.filter(r=>de(r.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:w(d),icon:"check",tone:"ok"},{label:"setup prompts queued",value:w(i),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:t.length===0?e.jsx(V,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):t.map(r=>{var h,u;const c=r.devices??[],a=c.filter(f=>de(f.lastSeen)).length,v=(h=r.recommendations)!=null&&h.completed?{label:"personalised",tone:"ok"}:(u=r.recommendations)!=null&&u.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},x=we(r.lastSeen);return e.jsxs(G,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(r.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:Je(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":x.tone,title:x.label})]}),e.jsxs("span",{className:"list-meta",children:[w(c.length)," device",c.length===1?"":"s",a?` · ${a} active now`:""," · last seen ",L(r.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(S,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},r.id)})})]})]})}function ge(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function Vs(){const{userId:s=""}=xe(),n=fe(),{wrap:l}=Z(),{busy:t,run:o}=J(),d=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:i,error:r,loading:c,reload:a}=K("/admin/api/accounts",{pollMs:3e4}),[v,x]=p.useState(null),[h,u]=p.useState(null),[f,g]=p.useState(null),[b,m]=p.useState(null),y=((i==null?void 0:i.accounts)??[]).find(I=>I.id===s),j=(i==null?void 0:i.catalogue)??[],R=(i==null?void 0:i.themes)??[];p.useEffect(()=>{var I;v===null&&y&&x({...((I=y.settings)==null?void 0:I.preferences)??{}})},[y,v]),p.useEffect(()=>{if(h!==null||!y)return;const I=y.themes??[];u(I.length===0?R.map(U=>U.id):I)},[y,h,R]);const N=p.useMemo(()=>{const I=[];for(const U of j){let Y=I.find(B=>B.name===U.area);Y||I.push(Y={name:U.area,definitions:[]}),Y.definitions.push(U)}return I},[j]),$=(I,U,Y,B)=>o(I,async()=>{const ie=await l(U,Y);g(null),ie!==void 0&&(B==null||B()),await a()});if(c)return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"User",crumbs:e.jsx(G,{to:"/admin/accounts",children:"← All users"})}),e.jsx(O,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"User",crumbs:e.jsx(G,{to:"/admin/accounts",children:"← All users"})}),e.jsx(D,{message:r}),e.jsx(M,{children:e.jsx(V,{children:"This user is no longer signed in to Memby."})})]});const F=y.devices??[],k=F.filter(I=>de(I.lastSeen)).length,C=y.settings??{},z=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:y.username||"Unnamed user",intro:`Memby user · ${w(F.length)} device${F.length===1?"":"s"} · last seen ${L(y.lastSeen)}`,crumbs:e.jsx(G,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:Je(y.username)}),k?e.jsxs(S,{tone:"ok",children:[k," active now"]}):e.jsx(S,{children:"idle"}),e.jsx(H,{children:y.id})]})}),e.jsx(D,{message:r}),e.jsxs(ne,{cols:"wide",children:[e.jsx(M,{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:F.length===0?e.jsx(V,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:F.map(I=>{const U=we(I.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":U.tone,title:U.label})," ",e.jsx(G,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(I.id)}`,children:I.name||"Memby TV"})]}),e.jsxs("p",{children:[I.version?`Memby ${I.version}`:"Legacy Memby client"," · ",U.label," · last seen ",L(I.lastSeen)," · signed in ",L(I.signedInAt)]}),(I.versions??[]).length>0?e.jsx("div",{className:"chips",children:(I.versions??[]).map(Y=>e.jsxs(H,{tone:Y.version===I.version?"ok":void 0,children:[Y.version,Y.version===I.version?" · now":""]},Y.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(E,{size:"sm",disabled:!I.id,onClick:()=>m({id:I.id,name:I.name}),children:"Rename"}),e.jsx(E,{size:"sm",variant:"danger",disabled:!I.id,onClick:()=>g({kind:"remove-device",deviceId:I.id,name:I.name}),children:"Sign out"})]})]},I.id||I.name)})})}),e.jsxs(M,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:z.completed?e.jsx(E,{busy:t==="reset-rec",onClick:()=>g({kind:"reset-recommendations"}),children:"Clear stored choices"}):z.prompted?e.jsx(E,{onClick:()=>g({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(E,{variant:"primary",busy:t==="prompt",onClick:()=>void $("prompt",()=>T.put(`${d}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:z.completed?e.jsx(S,{tone:"ok",children:"completed"}):z.prompted?e.jsx(S,{tone:"warn",children:"prompt queued"}):e.jsx(S,{children:"not invited"})}),e.jsx(Ws,{prompt:z})]})]}),e.jsx(M,{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:C.saved?e.jsxs(S,{tone:C.source==="admin"?"warn":"ok",children:["r",w(C.revision)," · ",C.source||"device"," · ",L(C.updatedAt)]}):e.jsx(S,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"primary",busy:t==="push",onClick:()=>void $("push",()=>T.put(`${d}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>x(null)),children:"Push to their televisions"}),e.jsx(E,{onClick:()=>{x(null),a()},children:"Discard changes"}),e.jsx(E,{onClick:()=>g({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(G,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:N.map(I=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:I.name}),I.definitions.map(U=>e.jsx(_s,{definition:U,value:v==null?void 0:v[U.key],onChange:Y=>x(B=>({...B??{},[U.key]:Y}))},U.key))]},I.name))}),e.jsx(M,{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(S,{children:"all schemes"}):e.jsxs(S,{tone:"note",children:[w((y.themes??[]).length)," of ",w(R.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"primary",busy:t==="themes",onClick:()=>(h??[]).length===0?g({kind:"no-themes"}):void $("themes",()=>T.put(`${d}/themes`,{themes:h??[]}),"Colour schemes saved.",()=>u(null)),children:"Save colour schemes"}),e.jsx(E,{onClick:()=>u(R.map(I=>I.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:R.map(I=>{var U,Y,B;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(h??[]).includes(I.id),onChange:ie=>u(Ne=>ie.target.checked?[...Ne??[],I.id]:(Ne??[]).filter(rs=>rs!==I.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":ge((U=I.palette)==null?void 0:U.surface),"--swatch-accent":ge((Y=I.palette)==null?void 0:Y.accent),"--swatch-hairline":ge((B=I.palette)==null?void 0:B.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:I.name}),e.jsx("p",{children:I.description})]})]},I.id)})})}),e.jsx(M,{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(E,{variant:"danger",onClick:()=>g({kind:"remove-account"}),children:"Remove Memby access"})}),b?e.jsx(zs,{initial:b.name,busy:t==="rename",onCancel:()=>m(null),onConfirm:I=>void $("rename",()=>T.put(`${d}/devices/${encodeURIComponent(b.id)}`,{deviceName:I}),"Device renamed.",()=>m(null))}):null,f?e.jsx(Ks,{pending:f,busy:t,username:y.username,onCancel:()=>g(null),onConfirm:()=>{switch(f.kind){case"remove-device":return void $("remove-device",()=>T.del(`${d}/devices/${encodeURIComponent(f.deviceId)}`),"Device signed out.");case"remove-account":return void $("remove-account",()=>T.del(`${d}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void $("reset-rec",()=>T.del(`${d}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void $("reset-prefs",()=>T.del(`${d}/preferences`),"Defaults restored.",()=>x(null));case"no-themes":return void $("themes",()=>T.put(`${d}/themes`,{themes:[]}),"Colour schemes saved.",()=>u(null))}}}):null]})}function Ws({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]],t=[...n.map(o=>e.jsxs(H,{tone:"warn",children:[o.title," · ",w(o.rating)," ★"]},`r:${o.title}`)),...l.flatMap(([o,d])=>(d??[]).map(i=>e.jsxs(H,{children:[o,": ",i]},`${o}:${i}`)))];return t.length===0?e.jsx(V,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:t})}function _s({definition:s,value:n,onChange:l}){if(s.kind==="toggle")return e.jsx(W,{label:s.name,hint:s.description,checked:!!n,onChange:l});if(s.kind==="choice"||s.kind==="number"){const o=s.unit??"",d=s.kind==="number"?(s.numbers??[]).map(i=>({value:String(i),label:i===0?"No limit":o?`${i} ${o}`:String(i)})):s.options??[];return e.jsx(A,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:i=>l(s.kind==="number"?Number(i.target.value):i.target.value),children:d.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})})}if(s.kind==="multi"){const o=Array.isArray(n)?n:[],d=[...o,...(s.options??[]).map(i=>i.value).filter(i=>!o.includes(i))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:d.map(i=>{const r=(s.options??[]).find(c=>c.value===i);return r?e.jsx(W,{label:r.label,checked:o.includes(i),onChange:c=>l(c?[...o,i]:o.filter(a=>a!==i))},i):null})})]})}const t=Array.isArray(n)?n:[];return e.jsx(A,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:t.join(` +`),onChange:o=>l(o.target.value.split(` +`).map(d=>d.trim()).filter(Boolean))})})}function zs({initial:s,busy:n,onConfirm:l,onCancel:t}){const[o,d]=p.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:i=>i.target===i.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(A,{label:"Device name",children:e.jsx("input",{type:"text",value:o,autoFocus:!0,maxLength:80,onChange:i=>d(i.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(E,{variant:"quiet",onClick:t,children:"Cancel"}),e.jsx(E,{variant:"primary",busy:n,disabled:!o.trim(),onClick:()=>l(o.trim()),children:"Rename"})]})]})})}function Ks({pending:s,busy:n,username:l,onConfirm:t,onCancel:o}){const i={"remove-device":{title:"Sign this device out of Memby?",body:"Its Emby account will not be changed. The set can sign in again at any time.",label:"Sign out",destructive:!0},"remove-account":{title:`Remove Memby access for ${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(te,{title:i.title,body:i.body,confirmLabel:i.label,destructive:i.destructive,busy:!!n,onConfirm:t,onCancel:o})}function Hs(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const t=(s.options??[]).find(o=>o.value===n);return t?t.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const l=Array.isArray(n)?n:[];return l.length===0?"None":l.map(t=>{var o;return((o=(s.options??[]).find(d=>d.value===t))==null?void 0:o.label)??t}).join(", ")}function Gs(){const{userId:s=""}=xe(),{wrap:n}=Z(),{busy:l,run:t}=J(),o=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:d,error:i,loading:r,reload:c}=K(`${o}/preferences/history`,{pollMs:3e4}),[a,v]=p.useState(new Set),[x,h]=p.useState(null),u=(d==null?void 0:d.username)||"this account",f=(d==null?void 0:d.devices)??[],g=(d==null?void 0:d.revisions)??[],b=(d==null?void 0:d.catalogue)??[],m=j=>v(R=>{const N=new Set(R);return N.has(j)?N.delete(j):N.add(j),N}),y=j=>t("restore",async()=>{await n(()=>T.post(`${o}/preferences/revisions/${j}/restore`),`Restored r${j}.`),h(null),await c()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Settings history",intro:`Every change to ${u}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(G,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",u]})}),e.jsx(D,{message:i}),r?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(M,{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(S,{tone:d.currentSource==="admin"?"warn":"ok",children:["now on r",w(d.currentRevision)," · ",d.currentSource||"device"]}):e.jsx(S,{children:"defaults · never synced"}),children:f.length===0?e.jsx(V,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(j=>{const R=j.never?void 0:j.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":R})," ",j.name||"Memby TV"," ",j.signedOut?e.jsx(S,{children:"signed out"}):null]}),e.jsxs("p",{children:[j.never?"Has not fetched these settings yet":`Holding r${w(j.revision)} · taken ${L(j.ackedAt)}`,j.clientVersion?` · Memby ${j.clientVersion}`:"",j.signedOut?"":` · last seen ${L(j.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:j.never?e.jsx(S,{children:"never taken one"}):j.behind?e.jsxs(S,{tone:"bad",children:[w(j.behind)," behind"]}):e.jsx(S,{tone:"ok",children:"up to date"})})]},j.deviceId||j.name)})})}),e.jsx(M,{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(_,{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:g.length===0?e.jsx(X,{columns:6,children:"Nothing has been changed on this account yet."}):g.flatMap(j=>{const R=a.has(j.revision),N=j.acks??[],$=j.changes??[],F=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(j.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",w(j.revision)," ",j.current?e.jsx(S,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(S,{tone:j.source==="admin"?"warn":"ok",children:j.author}),j.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",w(j.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:j.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):$.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:$.map((k,C)=>e.jsxs(H,{children:[k.name,": ",k.before," → ",k.after]},`${k.name}:${C}`))})}),e.jsx("td",{className:"num",children:N.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:N.map(k=>k.deviceName||k.deviceId).join(", "),children:w(N.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(E,{size:"sm",onClick:()=>m(j.revision),children:R?"Hide":"Show"}),j.current?null:e.jsx(E,{size:"sm",onClick:()=>h(j.revision),children:"Restore"})]})})]},j.revision)];return R&&F.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:b.map(k=>{var C;return e.jsxs(H,{children:[k.name,":"," ",Hs(k,(C=j.preferences)==null?void 0:C[k.key])]},k.key)})})})},`${j.revision}:detail`)),F})})]})})})]}),x!==null?e.jsx(te,{title:`Restore revision ${x}?`,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 y(x),onCancel:()=>h(null)}):null]})}function Zs({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(H,{tone:l.version===s.version?"ok":void 0,children:l.version},l.version))})}function Js(){const{status:s,error:n,loading:l}=ee(),t=(s==null?void 0:s.clients)??[],o=t.filter(i=>(i.capabilities??[]).includes("server_features_v1")),d=new Set(t.map(i=>i.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(D,{message:n}),l?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"devices known",value:w(t.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(t.filter(i=>de(i.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:w(o.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:w(d.size),icon:"download",tone:"note"}]}),e.jsx(M,{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(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(X,{columns:6,children:"No devices have signed in yet."}):t.map(i=>{const r=(i.capabilities??[]).includes("server_features_v1"),c=we(i.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":c.tone,title:c.label}),e.jsx(G,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:i.username}),e.jsx("td",{className:"mono",children:i.version||"legacy"}),e.jsx("td",{children:e.jsx(Zs,{client:i})}),e.jsx("td",{children:e.jsx(S,{tone:r?"ok":"warn",children:r?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:L(i.lastSeen)})]},`${i.deviceId}:${i.username}`)})})]})})})]})]})}const Ae={user:"",q:"",ip:"",outcome:"",from:"",to:""},Ys=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function Xs(){var j,R,N,$,F,k;const[s,n]=p.useState("log"),[l,t]=p.useState(7),[o,d]=p.useState(Ae),[i,r]=p.useState(0),c=100,a=p.useMemo(()=>he({...o,days:o.from?void 0:l||void 0,limit:c,offset:i*c}),[o,l,i]),v=K(`/admin/api/logins${a}`,{enabled:s==="log"}),x=K(`/admin/api/logins/devices${a}`,{enabled:s==="devices"}),h=((j=v.data)==null?void 0:j.users)??((R=x.data)==null?void 0:R.users)??[],u=((N=v.data)==null?void 0:N.totals)??(($=x.data)==null?void 0:$.totals),f=((F=v.data)==null?void 0:F.retentionDays)??((k=x.data)==null?void 0:k.retentionDays)??90,g=s==="log"?v.loading:x.loading,b=s==="log"?v.error:x.error,m=C=>{d(z=>({...z,...C})),r(0)},y=Object.entries(o).some(([,C])=>C!=="")||!!o.from;return e.jsxs(e.Fragment,{children:[e.jsx(P,{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(pe,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(D,{message:b}),u?e.jsx(se,{tiles:[{label:"Successful sign-ins",value:w(u.logins),icon:"key",tone:"ok"},{label:"Refused",value:w(u.failures),icon:"shield",tone:u.failures>0?"warn":void 0},{label:"Televisions",value:w(u.devices),icon:"tv",tone:"info"},{label:"People",value:w(u.users),icon:"people",tone:"note"},{label:"Addresses",value:w(u.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(A,{label:"Window",children:e.jsx(pe,{value:o.from?-1:l,options:Ys.map(C=>({value:C.value,label:C.label})),onChange:C=>{t(C),m({from:"",to:""})}})}),e.jsx(A,{label:"Person",children:e.jsxs("select",{value:o.user,onChange:C=>m({user:C.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),h.map(C=>e.jsx("option",{value:C.id,children:C.username||C.id},C.id))]})}),e.jsx(A,{label:"Outcome",children:e.jsxs("select",{value:o.outcome,onChange:C=>m({outcome:C.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(A,{label:"Address",children:e.jsx("input",{type:"text",value:o.ip,placeholder:"10.0.0.4",onChange:C=>m({ip:C.target.value})})}),e.jsx(A,{label:"From",children:e.jsx("input",{type:"date",value:o.from,onChange:C=>m({from:C.target.value})})}),e.jsx(A,{label:"To",children:e.jsx("input",{type:"date",value:o.to,onChange:C=>m({to:C.target.value})})}),e.jsx(A,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:o.q,placeholder:"Name, device or address",onChange:C=>m({q:C.target.value})})}),e.jsx("div",{className:"filter-actions",children:y?e.jsx(E,{variant:"quiet",size:"sm",onClick:()=>{d(Ae),r(0)},children:"Clear"}):null})]}),g?e.jsx(O,{}):s==="log"?e.jsx(Qs,{data:v.data,page:i,limit:c,onPage:r}):e.jsx(en,{data:x.data})]})}function Qs({data:s,page:n,limit:l,onPage:t}){if(!s)return null;const o=s.events.length,d=s.total===0?0:n*l+1;return e.jsxs(e.Fragment,{children:[e.jsxs(ne,{cols:"wide",children:[e.jsx(M,{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(ss,{data:s.days,labelOf:i=>i.day,valueOf:i=>i.logins+i.failures,toneOf:i=>i.failures>i.logins?"bad":void 0,title:i=>`${i.day}: ${i.logins} in, ${i.failures} refused, ${i.devices} televisions`})}),e.jsx(M,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(V,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(i=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:i.ipAddress}),e.jsxs("p",{children:[w(i.logins)," in",i.failures>0?` · ${w(i.failures)} refused`:""]})]}),i.failures>0&&i.logins===0?e.jsx(S,{tone:"bad",children:"only refused"}):null]},i.ipAddress))})})]}),e.jsx(M,{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+o-1)} of ${w(s.total)}`}),footer:s.total>l?e.jsxs(e.Fragment,{children:[e.jsx(E,{size:"sm",disabled:n===0,onClick:()=>t(n-1),children:"Newer"}),e.jsx(E,{size:"sm",disabled:(n+1)*l>=s.total,onClick:()=>t(n+1),children:"Older"})]}):void 0,children:e.jsx(_,{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(X,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(i.occurredAt)}),e.jsx("td",{children:i.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:i.deviceId?e.jsx(G,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||i.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:i.ipAddress||"—"}),e.jsx("td",{className:"mono",children:i.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:i.success?i.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:i.failureReason||"refused"})})]},i.id))})]})})})]})}function en({data:s}){return s?e.jsx(M,{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(_,{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(X,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(G,{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?L(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 nn(){const{deviceId:s=""}=xe(),[n,l]=p.useState(7),t=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${he({days:n||void 0,limit:200})}`,[s,n]),{data:o,error:d,loading:i}=K(t,{enabled:!!s}),r=o==null?void 0:o.summary,c=(r==null?void 0:r.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:c,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(G,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(G,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:c})]}),actions:e.jsx(pe,{value:n,options:sn.map(a=>({value:a.value,label:a.label})),onChange:l})}),e.jsx(D,{message:d}),i?e.jsx(O,{}):o?e.jsxs(e.Fragment,{children:[e.jsx(se,{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?L(r.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:r!=null&&r.lastLogin?L(r.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(ne,{cols:"wide",children:[e.jsx(M,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(ss,{data:o.days,labelOf:a=>a.day,valueOf:a=>a.logins+a.failures,toneOf:a=>a.failures>a.logins?"bad":void 0,title:a=>`${a.day}: ${a.logins} in${a.failures?`, ${a.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(M,{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:o.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(M,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:o.versions.length===0?e.jsx(V,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:o.versions.map(a=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.version}),e.jsxs("p",{children:[L(a.firstSeen)," → ",L(a.lastSeen)]})]})},a.version))})})]})]}),e.jsx(M,{title:"Addresses",icon:"globe",tone:"data",children:o.addresses.length===0?e.jsx(V,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:o.addresses.map(a=>e.jsxs(H,{tone:a.failures>0?"warn":"data",children:[a.ipAddress," · ",w(a.logins),a.failures>0?` (+${w(a.failures)} refused)`:""]},a.ipAddress))})}),e.jsx(M,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[w(o.total)," in this window"]}),children:e.jsx(_,{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:o.events.length===0?e.jsx(X,{columns:6,children:"This television has not connected in the selected window."}):o.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"muted",children:a.method}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(S,{tone:"info",children:"first sign-in"}):e.jsx(S,{tone:"ok",children:"got in"}):e.jsx(S,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]}):null]})}function tn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(!1),a=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),x=h=>i(h,async()=>{await o(()=>T.post("/admin/api/sync",{kind:h}),h==="full"?"Full re-import started.":"Import started."),c(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(D,{message:n}),l||!s?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"items",value:w(s.library.total),icon:"library",tone:"data"},...Object.keys(a).sort().map(h=>({label:h,value:w(a[h]),icon:"list"})),{label:"last import",value:L(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(M,{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(E,{variant:"primary",icon:"sync",disabled:v,busy:d==="incremental",onClick:()=>void x("incremental"),children:"Sync new items"}),e.jsx(E,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full re-import"})]})})]}),r?e.jsx(te,{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 x("full"),onCancel:()=>c(!1)}):null]})}const an={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 rn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(!1),[a,v]=p.useState(""),[x,h]=p.useState(!1),[u,f]=p.useState([]),[g,b]=p.useState(!1),m=s==null?void 0:s.mdblist;p.useEffect(()=>{g||!m||(c(m.enabled),f(m.sources??[]))},[m,g]);const y=()=>i("save",async()=>{await o(()=>T.post("/admin/api/mdblist-settings",{enabled:r,apiKey:a.trim(),clearApiKey:x,sources:u}),"Ratings settings saved."),v(""),h(!1),b(!1),await t()}),j=(m==null?void 0:m.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(D,{message:n}),l||!m?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"titles stored",value:w(j),icon:"database",tone:"data"},{label:"due to be re-checked",value:w(m.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:w((m.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:m.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:m.apiKeyConfigured?"ok":void 0}]}),e.jsxs(ne,{cols:"2",children:[e.jsxs(M,{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(S,{tone:"ok",children:["on · ",u.length," sources"]}):e.jsx(S,{children:m.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:r,onChange:R=>{c(R),b(!0)}}),e.jsx(A,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:a,placeholder:m.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:R=>v(R.target.value)})}),e.jsx(W,{label:"Remove the saved key",checked:x,onChange:h})]}),e.jsx(M,{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:(m.availableSources??[]).length===0?e.jsx(V,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(m.availableSources??[]).map(R=>e.jsx(W,{label:an[R]??R,checked:u.includes(R),onChange:N=>{b(!0),f($=>N?[...$,R]:$.filter(F=>F!==R))}},R))})})]}),e.jsx(M,{children:e.jsxs("div",{className:"row",children:[e.jsx(E,{variant:"primary",busy:d==="save",onClick:()=>void y(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:j?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function ln(){var x;const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),r=(s==null?void 0:s.requestUsers)??[],c=((x=s==null?void 0:s.requestPolicy)==null?void 0:x.allowedUserIds)??[],a=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(h=>[h.userId,h])),[s==null?void 0:s.requestUsage]),v=h=>i(`access-${h}`,async()=>{const u=c.includes(h)?c.filter(f=>f!==h):[...c,h];await o(()=>T.post("/admin/api/request-policy",{allowedUserIds:u}),u.includes(h)?"Request access granted.":"Request access removed."),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(M,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(S,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(S,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(V,{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(M,{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(V,{children:"No one has signed in yet."}):e.jsx(_,{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(h=>{const u=a.get(h.id),f=c.includes(h.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:h.username})}),e.jsx("td",{className:"muted nowrap",children:L(h.lastSeen)}),e.jsx("td",{className:"num",children:(u==null?void 0:u.requests)??0}),e.jsx("td",{className:"muted nowrap",children:u!=null&&u.lastRequest?L(u.lastRequest):"—"}),e.jsx("td",{children:e.jsx(E,{size:"sm",variant:f?"quiet":"primary",busy:d===`access-${h.id}`,onClick:()=>void v(h.id),children:f?"Remove access":"Give access"})})]},h.id)})})]})})})]})]})}function on(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(!1),a=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),x=(h,u,f)=>i(u,async()=>{await o(()=>T.post("/admin/api/for-you",{action:h}),f),c(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"Tracearr sessions",value:w((a==null?void 0:a.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:w((a==null?void 0:a.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:w((a==null?void 0:a.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:L(a==null?void 0:a.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(M,{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(E,{variant:"primary",icon:"download",disabled:v,busy:d==="import",onClick:()=>void x("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(E,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full Tracearr backfill"}),e.jsx(E,{icon:"sync",disabled:v,busy:d==="rebuild",onClick:()=>void x("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(M,{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(G,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),r?e.jsx(te,{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 x("full-import","full","Backfill started."),onCancel:()=>c(!1)}):null]})}const cn=[["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 dn(s){return s?cn.flatMap(([n,l])=>Object.entries(s[l]??{}).map(([t,o])=>({dimension:n,name:t,weight:o.weight??0,evidence:o.evidence??0}))).sort((n,l)=>Math.abs(l.weight)-Math.abs(n.weight)):[]}const Te=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function hn(){const{status:s}=ee(),{wrap:n}=Z(),{busy:l,run:t}=J(),[o,d]=p.useState(""),[i,r]=p.useState("default"),[c,a]=p.useState("0"),[v,x]=p.useState(""),[h,u]=p.useState(null),[f,g]=p.useState("Choose a person to inspect their recommendations."),[b,m]=p.useState(""),y=(s==null?void 0:s.requestUsers)??[],j=()=>t("run",async()=>{if(!o){m("Choose a person to pressure-test.");return}m(""),g("Running the permission check and the scorer…");const k=new URLSearchParams({userId:o,context:i,minutes:c||"0",limit:"100"});v&&k.set("at",new Date(v).toISOString());const C=await n(()=>T.get(`/admin/api/recommendations?${k.toString()}`));C?(u(C),g(`Scored at ${new Date().toLocaleTimeString()}.`)):g("Pressure test failed.")}),R=dn((h==null?void 0:h.profile)??null).slice(0,24),N=(h==null?void 0:h.actions)??[],$=(h==null?void 0:h.items)??[],F=(h==null?void 0:h.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(D,{message:b}),e.jsx(M,{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(E,{variant:"primary",busy:l==="run",onClick:()=>void j(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(A,{label:"Person",children:e.jsxs("select",{value:o,onChange:k=>d(k.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),y.map(k=>e.jsx("option",{value:k.id,children:k.username},k.id))]})}),e.jsx(A,{label:"Context",children:e.jsxs("select",{value:i,onChange:k=>r(k.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(A,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:c,onChange:k=>a(k.target.value)})}),e.jsx(A,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:k=>x(k.target.value)})})]})}),h?e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"prepared pool",value:w(h.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:w(h.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:w($.length),icon:"sparkle",tone:"note"},{label:"source events",value:w(F.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:F.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:L(F.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(M,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[R.length===0?e.jsx(V,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:R.map(k=>e.jsxs(H,{tone:k.weight<0?"bad":void 0,children:[k.dimension,": ",k.name," ",Te(k.weight)," · n=",w(k.evidence)]},`${k.dimension}:${k.name}`))}),N.length===0?e.jsx(V,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:N.map((k,C)=>e.jsxs(H,{tone:"ok",children:[k.action,": ",k.title||k.itemId]},`${k.action}:${C}`))})]}),$.length===0?e.jsx(M,{children:e.jsx(V,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(ne,{children:$.map((k,C)=>{const z=k.explanation??{},I=Object.entries(z.components??{}).sort((B,ie)=>Math.abs(ie[1])-Math.abs(B[1])),U=k.exposure??{},Y=[k.type,k.year,k.runtimeMinutes?`${k.runtimeMinutes} min`:null,...k.genres??[]].filter(Boolean).join(" · ");return e.jsxs(M,{title:`#${C+1} · ${k.title}`,intro:Y,actions:e.jsx(H,{tone:"ok",children:Number(z.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[k.preparedReason||"No legacy prepared explanation",k.compatibilityLabel?` · ${k.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(z.reasonCodes??[]).map(B=>e.jsx(H,{tone:"ok",children:B},B)),I.map(([B,ie])=>e.jsxs(H,{tone:ie<0?"bad":void 0,children:[B,"=",Te(ie)]},B))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",w(k.baseRank??0)," · base ",Number(k.baseScore??0).toFixed(3)," · affinity ",Number(k.affinityScore??0).toFixed(3)," · compatibility"," ",Number(k.compatibilityScore??0).toFixed(3)," · impressions"," ",w(U.impressions??0)," · focuses ",w(U.focuses??0)," · selects"," ",w(U.selects??0)]}),e.jsx("div",{className:"chips",children:(k.eligibleRows??[]).map(B=>e.jsx(H,{tone:"ok",children:B},B))}),k.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",k.preparedEvidenceTitle]}):null]})]},`${C}:${k.title}`)})})]}):null]})}const Le=4;function un(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o,show:d}=Z(),{busy:i,run:r}=J(),[c,a]=p.useState([]),[v,x]=p.useState(""),[h,u]=p.useState(!1),[f,g]=p.useState(""),[b,m]=p.useState(null),[y,j]=p.useState([]),R=s==null?void 0:s.heroPolicy;p.useEffect(()=>{h||!R||(a((R.pinnedItems??[]).slice(0,Le)),x(R.primeSubtitle??""),j(R.schedules??[]))},[R,h]);const N=()=>r("search",async()=>{const k=f.trim();if(!k)return;const C=await o(()=>T.get(`/admin/api/hero/search?q=${encodeURIComponent(k)}`));C&&m(C.items??[])}),$=k=>{if(!c.some(C=>C.id===k.id)){if(c.length>=Le){d("Remove a pinned title before adding another.","bad");return}a(C=>[...C,k]),u(!0)}},F=()=>r("save",async()=>{await o(()=>T.post("/admin/api/hero-policy",{pinnedItemIds:c.map(k=>k.id),primeSubtitle:v.trim(),schedules:y}),"Hero saved."),u(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsxs(M,{title:"Pinned titles",intro:"Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural.",icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"primary",busy:i==="save",onClick:()=>void F(),children:"Save hero"}),e.jsx(E,{onClick:()=>{a([]),u(!0)},children:"Clear pins"}),h?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[c.length===0?e.jsx(V,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"chips",children:c.map((k,C)=>e.jsxs(E,{variant:"quiet",size:"sm",icon:"close",onClick:()=>{a(z=>z.filter(I=>I.id!==k.id)),u(!0)},children:[C+1,". ",k.name,k.year?` (${k.year})`:""]},k.id))}),e.jsx(A,{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:v,placeholder:"Leave blank for the automatic reason",onChange:k=>{x(k.target.value),u(!0)}})})]}),e.jsxs(M,{title:"Scheduled heroes",intro:"Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero.",icon:"clock",tone:"info",children:[y.length===0?e.jsx(V,{children:"No scheduled heroes yet."}):e.jsx("div",{className:"stack",children:y.map(k=>{const C=[...c,...b??[]].find(z=>z.id===k.itemId);return e.jsxs("div",{className:"row",children:[e.jsx("b",{children:(C==null?void 0:C.name)??k.itemId}),e.jsxs("span",{className:"muted",children:[new Date(k.startAt).toLocaleString()," → ",new Date(k.endAt).toLocaleString()," · priority ",k.priority]}),e.jsx(E,{size:"sm",variant:"quiet",onClick:()=>{j(z=>z.filter(I=>I.id!==k.id)),u(!0)},children:"Remove"})]},k.id)})}),c.length>0?e.jsx(E,{size:"sm",icon:"plus",onClick:()=>{const k=c[0];if(!k)return;const C=new Date,z=new Date(C.getTime()+2*60*60*1e3);j(I=>[...I,{id:crypto.randomUUID(),itemId:k.id,startAt:C.toISOString(),endAt:z.toISOString(),priority:0,enabled:!0}]),u(!0)},children:"Schedule first pinned title for two hours"}):e.jsx("p",{className:"hint",children:"Pin or search for a title first, then add it to a schedule."})]}),e.jsxs(M,{title:"Find a title",intro:"Search the imported Emby catalogue. Up to four films or series can be pinned.",icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(A,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:k=>g(k.target.value),onKeyDown:k=>{k.key==="Enter"&&N()}})}),e.jsx(E,{busy:i==="search",icon:"search",onClick:()=>void N(),children:"Search"})]}),b===null?null:b.length===0?e.jsx(V,{children:"No playable films or series matched that search."}):e.jsx(ne,{children:b.map(k=>e.jsx(M,{title:k.name,intro:`${k.type||"Title"} · ${k.year||"Year unknown"}`,children:e.jsx(E,{size:"sm",icon:"plus",disabled:c.some(C=>C.id===k.id),onClick:()=>$(k),children:c.some(C=>C.id===k.id)?"Pinned":"Add to hero"})},k.id))})]})]})]})}function mn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(null),a=s==null?void 0:s.features,v=(a==null?void 0:a.features)??[],x=(s==null?void 0:s.clients)??[],h=(a==null?void 0:a.revision)??0,u=(m,y,j,R)=>i(y,async()=>{await o(()=>T.post("/admin/api/features",{action:m,expectedRevision:h,overrides:R??{}}),j),c(null),await t()}),f=x.filter(m=>(m.capabilities??[]).includes("server_features_v1")).length,g=!!(a!=null&&a.safeMode),b=(m,y)=>({...Object.fromEntries(v.filter(j=>j.source==="override").map(j=>[j.key,j.enabled])),[m]:y});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(D,{message:n}),l||!a?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(M,{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(E,{variant:g?void 0:"danger",busy:d==="safe",onClick:()=>g?void u("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:g?"Leave safe mode":"Enable safe mode"}),children:e.jsx(ns,{tiles:[{label:"features active",value:`${v.filter(m=>m.enabled).length} / ${v.length}`},{label:"explicit overrides",value:w(v.filter(m=>m.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${x.length}`},{label:"published revision",value:`r${w(h)}`}]})}),e.jsx(ne,{cols:"2",children:v.length===0?e.jsx(M,{title:"Nothing registered",icon:"sliders",children:e.jsx(V,{children:"No server features are registered."})}):v.map(m=>e.jsxs(M,{title:m.name,intro:m.description,actions:e.jsx(S,{tone:m.enabled?"ok":void 0,children:m.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",m.recovery]}),children:[e.jsx(W,{label:m.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:m.enabled,disabled:d===m.key,onChange:y=>c({action:"feature",key:m.key,enabled:y,title:`${y?"Turn on":"Turn off"} ${m.name}?`,body:`${y?"Enable":"Disable"} this feature for every compatible television. ${m.recovery}`,label:y?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(H,{children:m.key}),e.jsxs(H,{children:["protocol ",w(m.minimumProtocol),"+"]}),e.jsx(H,{tone:m.compatible?"ok":"warn",children:m.compatible?"server compatible":"compatibility blocked"}),e.jsx(H,{tone:"note",children:m.area})]})]},m.key))}),e.jsx(M,{children:e.jsxs("div",{className:"row",children:[e.jsx(E,{disabled:!a.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(E,{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"}),g?e.jsx(S,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(S,{tone:"ok",children:["live · revision r",w(h)]})]})})]}),r?e.jsx(te,{title:r.title,body:r.body,confirmLabel:r.label,destructive:r.action!=="rollback",busy:d===r.action,onConfirm:()=>void u(r.action==="feature"?"save":r.action,r.action==="feature"?r.key??"feature":r.action,`${r.label} done.`,r.action==="feature"&&r.key?b(r.key,!!r.enabled):void 0),onCancel:()=>c(null)}):null]})}function pn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o,show:d}=Z(),{busy:i,run:r}=J(),[c,a]=p.useState(!0),[v,x]=p.useState("6.5"),[h,u]=p.useState(!1);p.useEffect(()=>{var g,b;h||!s||(a(((g=s.playbackPolicy)==null?void 0:g.prerollEnabled)!==!1),x(String((((b=s.playbackPolicy)==null?void 0:b.prerollDurationMs)??6500)/1e3)))},[s,h]);const f=()=>r("save",async()=>{const g=Number(v);if(!Number.isFinite(g)||g<1||g>30){d("The preroll duration must be between 1 and 30 seconds.","bad");return}await o(()=>T.post("/admin/api/playback-policy",{prerollEnabled:c,prerollDurationMs:Math.round(g*1e3)}),"Playback policy saved."),u(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:1}):e.jsxs(M,{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(S,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(S,{children:"off"}),footer:e.jsx(E,{variant:"primary",busy:i==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(W,{label:"Show the preroll before a title starts",checked:c,onChange:g=>{a(g),u(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(A,{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:g=>{x(g.target.value),u(!0)}})})})]})]})}function xn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(null),[a,v]=p.useState(null),[x,h]=p.useState(!1),u=s==null?void 0:s.subtitles,f=u==null?void 0:u.stored;p.useEffect(()=>{r||!u||c({bazarr:u.bazarrEnabled,openSubtitles:u.openSubtitlesEnabled,key:"",clearKey:!1,username:u.openSubtitlesUsername??"",password:"",clearLogin:!1})},[u,r]);const g=j=>c(R=>R&&{...R,...j}),b=()=>i("save",async()=>{r&&(await o(()=>T.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 t())}),m=()=>i("test",async()=>{v(null);const j=await o(()=>T.post("/admin/api/subtitle-test"));v((j==null?void 0:j.results)??[])}),y=()=>i("clear",async()=>{await o(()=>T.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(D,{message:n}),l||!u||!r?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"offered on televisions",value:u.available?"yes":"no",small:!0,icon:"captions",tone:u.available?"ok":void 0},{label:"providers on",value:w((u.bazarrEnabled&&u.bazarrConfigured?1:0)+(u.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:L(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(ne,{cols:"2",children:[e.jsx(M,{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:u.bazarrConfigured?r.bazarr?e.jsx(S,{tone:"ok",children:"on"}):e.jsx(S,{children:"off"}):e.jsx(S,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:u.bazarrConfigured?`Configured at ${u.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(W,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:r.bazarr,disabled:!u.bazarrConfigured,onChange:j=>g({bazarr:j})})}),e.jsxs(M,{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:u.openSubtitlesEnabled?e.jsx(S,{tone:u.openSubtitlesAccount?"ok":"warn",children:u.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(S,{children:u.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:r.openSubtitles,onChange:j=>g({openSubtitles:j})}),e.jsx(A,{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:u.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:j=>g({key:j.target.value})})}),e.jsx(W,{label:"Remove the saved key",checked:r.clearKey,onChange:j=>g({clearKey:j})}),e.jsxs("div",{className:"fields",children:[e.jsx(A,{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:j=>g({username:j.target.value})})}),e.jsx(A,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:r.password,onChange:j=>g({password:j.target.value})})})]}),e.jsx(W,{label:"Sign out and forget the account",checked:r.clearLogin,onChange:j=>g({clearLogin:j})})]})]}),e.jsxs(M,{children:[e.jsxs("div",{className:"row",children:[e.jsx(E,{variant:"primary",busy:d==="save",onClick:()=>void b(),children:"Save subtitle settings"}),e.jsx(E,{busy:d==="test",icon:"pulse",onClick:()=>void m(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:u.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),a===null?null:a.length===0?e.jsx(V,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:a.map(j=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:j.provider}),e.jsx("p",{children:j.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(S,{tone:j.ok?"ok":"bad",children:j.ok?"reachable":"not reachable"})})]},j.provider))})]}),e.jsx(M,{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(S,{tone:"data",children:[w(f.count)," files · ",oe(f.bytes)]}):e.jsx(S,{children:"nothing held"}),footer:e.jsx(E,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>h(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),x?e.jsx(te,{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 y(),onCancel:()=>h(!1)}):null]})}function jn(){const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(null),[a,v]=p.useState(!1),x=s==null?void 0:s.updatePolicy;p.useEffect(()=>{r||!x||c({version:x.latestVersion??"",url:x.downloadUrl??"",notes:x.notes??"",retireBelow:x.retireBelowVersion??"",required:!!x.minimumVersion&&x.minimumVersion===x.latestVersion,destructive:!!x.retireBelowVersion&&x.retireBelowVersion===x.latestVersion})},[x,r]);const h=b=>i(b?"save":"off",async()=>{r&&(await o(()=>T.post("/admin/api/update-policy",{enabled:b,latestVersion:r.version.trim(),downloadUrl:r.url.trim(),notes:r.notes.trim(),required:r.required,destructive:r.destructive,retireBelowVersion:r.retireBelow.trim()}),b?"Update policy saved.":"Update prompts turned off."),v(!1),c(null),await t())}),u=!!(x!=null&&x.minimumVersion)&&(x==null?void 0:x.minimumVersion)===(x==null?void 0:x.latestVersion),f=!!(x!=null&&x.retireBelowVersion)&&(x==null?void 0:x.retireBelowVersion)===(x==null?void 0:x.latestVersion),g=b=>c(m=>m&&{...m,...b});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(D,{message:n}),l||!r?e.jsx(O,{rows:1}):e.jsxs(M,{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:x!=null&&x.enabled?e.jsxs(S,{tone:u?"warn":"ok",children:[f?"sign-out · ":u?"required · ":"optional · ",x.latestVersion]}):e.jsx(S,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"primary",busy:d==="save",onClick:()=>r.required?v(!0):void h(!0),children:"Save policy"}),e.jsx(E,{busy:d==="off",onClick:()=>void h(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(A,{label:"Latest version",children:e.jsx("input",{type:"text",value:r.version,placeholder:"0.2.63",onChange:b=>g({version:b.target.value})})}),e.jsx(A,{label:"APK URL",children:e.jsx("input",{type:"text",value:r.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:b=>g({url:b.target.value})})})]}),e.jsx(A,{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:b=>g({notes:b.target.value})})}),e.jsx(A,{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:b=>g({retireBelow:b.target.value})})}),e.jsx(W,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:r.required,onChange:b=>g({required:b})}),e.jsx(W,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:r.destructive,onChange:b=>g(b?{destructive:!0,required:!0,retireBelow:r.version.trim()}:{destructive:!1,retireBelow:r.retireBelow.trim()===r.version.trim()?"":r.retireBelow})})]}),a&&r?e.jsx(te,{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 h(!0),onCancel:()=>v(!1)}):null]})}const vn=2e4,gn=3e3;function De(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function bn(){const{wrap:s}=Z(),{busy:n,run:l}=J(),[t,o]=p.useState(!1),{data:d,error:i,loading:r,reload:c}=K("/admin/api/tasks?limit=60",{pollMs:t?gn:vn}),a=(d==null?void 0:d.tasks)??[],v=a.some(m=>m.running);v!==t&&o(v);const x=m=>l(m.id,async()=>{await s(()=>T.post(`/admin/api/tasks/${encodeURIComponent(m.id)}/run`),`${m.name} started.`),await c()}),h=(m,y)=>l(`${m.id}:enabled`,async()=>{await s(()=>T.put(`/admin/api/tasks/${encodeURIComponent(m.id)}`,{enabled:y}),y?`${m.name} switched on.`:`${m.name} switched off.`),await c()}),u=a.filter(m=>{var y;return((y=m.lastRun)==null?void 0:y.status)==="failed"}).length,f=a.filter(m=>!m.enabled).length,g=(d==null?void 0:d.groups)??[],b=a.filter(m=>!m.group);return e.jsxs(e.Fragment,{children:[e.jsx(P,{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(D,{message:i}),r?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"Tasks",value:w(a.length),icon:"clock",tone:"info"},{label:"Running now",value:w(a.filter(m=>m.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:w(u),icon:"alert",tone:u>0?"bad":void 0},{label:"Switched off",value:w(f),icon:"power",tone:f>0?"warn":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,[...g,...b.length>0?[""]:[]].map(m=>{const y=a.filter(j=>j.group===m);return y.length===0?null:e.jsx(M,{title:m||"Other",icon:m==="System"?"chip":m==="Analytics"?"chart":"wrench",tone:m==="System"?"info":m==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:y.map(j=>{var R;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[j.name," ",j.running?e.jsx(S,{tone:"info",children:"running"}):null,j.enabled?null:e.jsx(S,{tone:"warn",children:"off"})]}),e.jsx("p",{children:j.description}),e.jsxs("p",{className:"quiet",children:[Rs(j.intervalSeconds),j.enabled&&j.nextRun?` · next ${le(j.nextRun).replace(" ago","")}`:"",j.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:L(j.lastRun.startedAt),children:le(j.lastRun.startedAt)}),` in ${ce(j.lastRun.durationMs)}`,j.lastRun.detail?` — ${j.lastRun.detail}`:""]}):" · never run"]}),(R=j.lastRun)!=null&&R.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(S,{tone:"bad",children:j.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[j.lastRun?e.jsx(S,{tone:De(j.lastRun.status),children:j.lastRun.status}):e.jsx(S,{children:"never run"}),e.jsx(W,{label:"",checked:j.enabled,disabled:n===`${j.id}:enabled`,onChange:N=>void h(j,N)}),e.jsx(E,{size:"sm",icon:"play",busy:n===j.id,disabled:j.running,onClick:()=>void x(j),children:"Run now"})]})]},j.id)})})},m||"other")}),e.jsx(M,{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(_,{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(X,{columns:6,children:"No task has run yet."}):d==null?void 0:d.runs.map(m=>{var y;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(m.startedAt),children:le(m.startedAt)}),e.jsx("td",{children:((y=a.find(j=>j.id===m.taskId))==null?void 0:y.name)??m.taskId}),e.jsx("td",{className:"muted",children:m.trigger}),e.jsx("td",{children:e.jsx(S,{tone:De(m.status),children:m.status})}),e.jsx("td",{className:"num muted",children:ce(m.durationMs)}),e.jsx("td",{className:"muted",children:m.error||m.detail||"—"})]},m.id)})})]})})})]})]})}const fn={id:"",name:"Discord",url:"",enabled:!0,events:[]};function yn(){const{wrap:s,show:n}=Z(),{busy:l,run:t}=J(),{data:o,error:d,loading:i,reload:r}=K("/admin/api/integrations",{pollMs:6e4}),[c,a]=p.useState(null),[v,x]=p.useState(null),h=(o==null?void 0:o.catalogue)??[],u=(o==null?void 0:o.integrations)??[],f=y=>a({id:y.id,name:y.name,url:"",enabled:y.enabled,events:y.events??[]}),g=()=>t("save",async()=>{if(!c)return;await s(()=>T.post("/admin/api/integrations",c),c.id?"Integration saved.":"Integration added.")&&(a(null),await r())}),b=y=>t("remove",async()=>{await s(()=>T.del(`/admin/api/integrations/${encodeURIComponent(y.id)}`),`${y.name} removed.`),x(null),await r()}),m=y=>t(`test:${y.id}`,async()=>{const j=await s(()=>T.post(`/admin/api/integrations/${encodeURIComponent(y.id)}/test`));j&&n(j.message,j.ok?"ok":"bad"),await r()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{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(E,{variant:"primary",icon:"plus",onClick:()=>a(fn),children:"Add a webhook"})}),e.jsx(D,{message:d}),e.jsx(wn,{}),e.jsx(kn,{}),e.jsx(Nn,{}),((o==null?void 0:o.dropped)??0)>0?e.jsxs(ue,{tone:"warn",children:[w((o==null?void 0:o.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,i?e.jsx(O,{}):u.length===0&&!c?e.jsx(M,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(V,{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."})}):u.map(y=>e.jsx(Sn,{integration:y,catalogue:h,busy:l,onEdit:()=>f(y),onTest:()=>void m(y),onRemove:()=>x(y)},y.id)),c?e.jsx(Mn,{draft:c,catalogue:h,busy:l==="save",onChange:a,onSave:()=>void g(),onCancel:()=>a(null)}):null,v?e.jsx(te,{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:()=>x(null)}):null]})}function wn(){const{wrap:s}=Z(),{busy:n,run:l}=J(),{data:t,error:o,loading:d,reload:i}=K("/admin/api/arr-integrations"),r=c=>l("arr-integrations",async()=>{t&&(await s(()=>T.post("/admin/api/arr-integrations",{sonarrEnabled:c.sonarrEnabled??t.sonarrEnabled,radarrEnabled:c.radarrEnabled??t.radarrEnabled}),"Integration settings saved."),await i())});return e.jsxs(M,{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(D,{message:o??""}),d?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(W,{label:"Sonarr enabled",hint:t!=null&&t.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(t!=null&&t.sonarrEnabled),disabled:!(t!=null&&t.sonarrConfigured)||n==="arr-integrations",onChange:c=>void r({sonarrEnabled:c})}),e.jsx(W,{label:"Radarr enabled",hint:t!=null&&t.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(t!=null&&t.radarrEnabled),disabled:!(t!=null&&t.radarrConfigured)||n==="arr-integrations",onChange:c=>void r({radarrEnabled:c})})]})]})}function kn(){const{wrap:s}=Z(),{busy:n,run:l}=J(),{data:t,error:o,loading:d,reload:i}=K("/admin/api/sonarr-request-policy"),[r,c]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(c(t.qualityProfileId),v(t.searchImmediately))},[t]);const x=()=>l("sonarr-request-policy",async()=>{await s(()=>T.post("/admin/api/sonarr-request-policy",{qualityProfileId:r,searchImmediately:a}),"Sonarr TV request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(u=>u.id===r);return e.jsxs(M,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(S,{tone:"ok",children:"configured"}):e.jsx(S,{tone:"warn",children:"needs attention"}),footer:e.jsx(E,{variant:"primary",busy:n==="sonarr-request-policy",disabled:d||r<=0,onClick:()=>void x(),children:"Save Sonarr policy"}),children:[e.jsx(D,{message:o??(t==null?void 0:t.error)??""}),d?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(A,{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:u=>c(Number(u.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(u=>e.jsxs("option",{value:u.id,children:[u.name,u.recommended?" — recommended (720p)":""]},u.id))]})})}),e.jsx(W,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:a,onChange:v}),h?e.jsxs(ue,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), be monitored using Memby’s existing all-episodes strategy, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Nn(){const{wrap:s}=Z(),{busy:n,run:l}=J(),{data:t,error:o,loading:d,reload:i}=K("/admin/api/radarr-request-policy"),[r,c]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(c(t.qualityProfileId),v(t.searchImmediately))},[t]);const x=()=>l("radarr-request-policy",async()=>{await s(()=>T.post("/admin/api/radarr-request-policy",{qualityProfileId:r,searchImmediately:a}),"Radarr movie request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(u=>u.id===r);return e.jsxs(M,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(S,{tone:"ok",children:"configured"}):e.jsx(S,{tone:"warn",children:"needs attention"}),footer:e.jsx(E,{variant:"primary",busy:n==="radarr-request-policy",disabled:d||r<=0,onClick:()=>void x(),children:"Save Radarr policy"}),children:[e.jsx(D,{message:o??(t==null?void 0:t.error)??""}),d?e.jsx(O,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(A,{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:u=>c(Number(u.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(u=>e.jsxs("option",{value:u.id,children:[u.name,u.recommended?" — recommended (720p)":""]},u.id))]})})}),e.jsx(W,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:a,onChange:v}),h?e.jsxs(ue,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), remain monitored, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Sn({integration:s,catalogue:n,busy:l,onEdit:t,onTest:o,onRemove:d}){const i=s.health,r=!i.lastFailure||i.lastSuccess&&i.lastSuccess>i.lastFailure,c=s.events??[];return e.jsxs(M,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(S,{tone:"ok",children:"on"}):e.jsx(S,{tone:"warn",children:"off"}),i.deliveries>0?e.jsx(S,{tone:r?"ok":"bad",children:r?"delivering":"failing"}):e.jsx(S,{children:"never used"}),e.jsx(E,{size:"sm",icon:"pulse",busy:l===`test:${s.id}`,onClick:o,children:"Test"}),e.jsx(E,{size:"sm",onClick:t,children:"Edit"}),e.jsx(E,{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(a=>{var v;return((v=n.find(x=>x.type===a))==null?void 0:v.label)??a}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:i.lastSuccess?L(i.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:i.deliveries>0?e.jsxs("span",{className:"quiet",children:[w(i.deliveries)," attempts, ",w(i.failures)," failed"]}):null})]}),i.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[L(i.lastFailure),i.lastError?` — ${i.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Attempted"}),e.jsx("th",{children:"Event"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:s.deliveries.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:L(a.attemptedAt),children:le(a.attemptedAt)}),e.jsx("td",{className:"muted",children:a.eventType}),e.jsx("td",{children:a.success?e.jsx(S,{tone:"ok",children:a.statusCode||"ok"}):e.jsx(S,{tone:"bad",children:a.error||a.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ce(a.durationMs)})]},a.id))})]})}):e.jsx(_,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(X,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Mn({draft:s,catalogue:n,busy:l,onChange:t,onSave:o,onCancel:d}){const i=[...new Set(n.map(c=>c.group))],r=(c,a)=>t({...s,events:a?[...s.events,c]:s.events.filter(v=>v!==c)});return e.jsxs(M,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"primary",busy:l,onClick:o,children:s.id?"Save":"Add"}),e.jsx(E,{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(A,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:c=>t({...s,name:c.target.value})})}),e.jsx(A,{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=>t({...s,url:c.target.value})})})]}),e.jsx(W,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:c=>t({...s,enabled:c})}),i.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(a=>a.group===c).map(a=>e.jsx(W,{label:a.label,hint:a.description,checked:s.events.includes(a.type),onChange:v=>r(a.type,v)},a.type))]},c))]})}function Cn(){var g;const{status:s,error:n,loading:l,reload:t}=ee(),{wrap:o}=Z(),{busy:d,run:i}=J(),[r,c]=p.useState(""),[a,v]=p.useState(!1),[x,h]=p.useState(!1),u=!!((g=s==null?void 0:s.maintenance)!=null&&g.enabled);p.useEffect(()=>{var b;!x&&s&&c(((b=s.maintenance)==null?void 0:b.message)??"")},[s,x]);const f=b=>i(b?"on":"off",async()=>{await o(()=>T.post("/admin/api/maintenance",{enabled:b,message:r}),b?"Memby is offline for every television.":"Memby is back online."),v(!1),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:1}):e.jsx(M,{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:u?"bad":"warn",actions:u?e.jsx(S,{tone:"bad",children:"offline"}):e.jsx(S,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(E,{variant:"danger",disabled:u,onClick:()=>v(!0),children:"Go offline"}),e.jsx(E,{disabled:!u,busy:d==="off",onClick:()=>void f(!1),children:"Bring back online"})]}),children:e.jsx(A,{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:b=>{c(b.target.value),h(!0)}})})}),a?e.jsx(te,{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 f(!0),onCancel:()=>v(!1)}):null]})}function Rn(){const{status:s,error:n,loading:l}=ee(),t=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(D,{message:n}),l?e.jsx(O,{rows:1}):e.jsx(M,{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(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(X,{columns:8,children:"Nothing has been imported yet."}):t.map(o=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:L(o.startedAt)}),e.jsx("td",{children:o.kind}),e.jsx("td",{className:"muted",children:o.trigger}),e.jsx("td",{children:e.jsx(S,{tone:o.status==="success"?"ok":o.status==="running"?"warn":"bad",children:o.status})}),e.jsx("td",{className:"num",children:w(o.itemsSeen)}),e.jsx("td",{className:"num",children:w(o.itemsUpserted)}),e.jsx("td",{className:"num",children:w(o.itemsRemoved)}),e.jsx("td",{className:"muted",children:o.error||""})]},o.id||o.startedAt))})]})})})]})}const Pe={DEBUG:10,INFO:20,WARN:30,ERROR:40},Fe=2e4,En=5e3,In=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"];function qe(s){const n=l=>{const t=In.indexOf(l);return t>=0?t:l==="error"?1e3:100};return Object.entries(s).sort((l,t)=>n(l[0])-n(t[0]))}const $n=s=>s.replace(/_/g," "),An=s=>[s.message,...Object.entries(s.attributes??{}).flat()].join(" ").toLowerCase();function Tn(){const[s,n]=p.useState([]),[l,t]=p.useState(0),[o,d]=p.useState(!1),[i,r]=p.useState("INFO"),[c,a]=p.useState(""),[v,x]=p.useState(""),h=p.useRef(0),u=p.useRef(!1),f=p.useRef(null),g=p.useRef(!0),b=p.useCallback(async()=>{if(!(o||u.current)){u.current=!0;try{let N=0,$;do{$=await T.get(`/admin/api/events?after=${h.current}&limit=1000`),h.current=$.next||h.current,$.dropped&&t(k=>k+$.dropped);const F=$.events??[];F.length>0&&n(k=>{const C=[...k,...F];return C.length>Fe?C.slice(C.length-Fe):C}),N+=1}while($.hasMore&&N<20);x("")}catch(N){x(N instanceof Error?N.message:String(N))}finally{u.current=!1}}},[o]);p.useEffect(()=>{if(b(),o)return;const N=window.setInterval(()=>void b(),En);return()=>window.clearInterval(N)},[b,o]);const m=p.useMemo(()=>{const N=Pe[i]??20,$=c.trim().toLowerCase();return s.filter(F=>(Pe[F.level]??0)>=N&&(!$||An(F).includes($)))},[s,i,c]),y=m.slice(-2500);p.useLayoutEffect(()=>{const N=f.current;N&&g.current&&(N.scrollTop=N.scrollHeight)},[y.length]);const j=()=>{const N=f.current;N&&(g.current=N.scrollHeight-N.scrollTop-N.clientHeight<50)},R=()=>{const N=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),$=document.createElement("a");$.href=URL.createObjectURL(N),$.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,$.click(),window.setTimeout(()=>URL.revokeObjectURL($.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(D,{message:v}),e.jsxs(M,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(A,{label:"Level",children:e.jsxs("select",{value:i,onChange:N=>r(N.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(A,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:c,placeholder:"Person, television, title, component, path…",onChange:N=>a(N.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(E,{onClick:()=>d(N=>!N),icon:o?"play":"clock",children:o?"Resume":"Pause"}),e.jsx(E,{onClick:()=>{n([]),t(0)},children:"Clear view"}),e.jsx(E,{onClick:R,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:f,onScroll:j,role:"log","aria-live":"polite",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."}):y.map((N,$)=>e.jsxs("div",{className:"logline","data-level":N.level,children:[e.jsxs("time",{title:`Log record ${N.sequence}`,children:[L(N.occurredAt),e.jsxs("small",{children:["#",N.sequence]})]}),e.jsx("span",{className:"lvl",children:N.level}),e.jsx("span",{className:"msg",children:N.message}),e.jsx("span",{className:"attrs",children:e.jsxs("details",{className:"logdetails",children:[e.jsx("summary",{children:qe(N.attributes??{}).map(([F,k])=>e.jsxs("span",{children:[e.jsxs("b",{children:[F,"="]}),String(k)," "]},F))}),e.jsxs("dl",{children:[e.jsx("dt",{children:"Log record"}),e.jsx("dd",{children:N.sequence}),qe(N.attributes??{}).map(([F,k])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:$n(F)}),e.jsx("dd",{children:String(k)})]},F))]})]})})]},`${N.occurredAt}:${$}`))]}),e.jsxs("p",{className:"hint",children:[w(s.length)," retained · ",w(m.length)," matching",y.length`/admin/api/journeys${he({days:s,userId:l})}`,[s,l]),{data:i,error:r,loading:c}=K(d),a=i==null?void 0:i.stats,v=(i==null?void 0:i.users)??[],x=(i==null?void 0:i.actions)??[],h=(i==null?void 0:i.paths)??[],u=h[0],f=p.useMemo(()=>{const g=new Map(((i==null?void 0:i.features)??[]).map(m=>[m.feature,m])),b=new Map(Oe.map((m,y)=>[m,y]));return[...new Set([...Oe,...g.keys()])].map(m=>({name:m,stat:g.get(m)})).sort((m,y)=>{var R,N;const j=(((R=y.stat)==null?void 0:R.uses)??0)-(((N=m.stat)==null?void 0:N.uses)??0);return j||(b.get(m.name)??Number.MAX_SAFE_INTEGER)-(b.get(y.name)??Number.MAX_SAFE_INTEGER)})},[i==null?void 0:i.features]);return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(D,{message:r}),e.jsx(M,{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(A,{label:"Window",children:e.jsxs("select",{value:s,onChange:g=>n(Number(g.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(A,{label:"User",children:e.jsxs("select",{value:l,onChange:g=>{const b=g.target.value;t(b),b&&o(`/admin/journeys/${encodeURIComponent(b)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(g=>e.jsx("option",{value:g.userId,children:g.username||g.userId},g.userId))]})})]}),children:c?e.jsx(O,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:"journeys",value:w(a==null?void 0:a.journeys),icon:"list",tone:"data"},{label:"viewers",value:w(a==null?void 0:a.viewers),icon:"people",tone:"info"},{label:"completion",value:be(a==null?void 0:a.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:w(a==null?void 0:a.abandoned),icon:"alert",tone:"note"},{label:"active now",value:w(a==null?void 0:a.active),icon:"pulse",tone:"info"},{label:"average steps",value:((a==null?void 0:a.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ce(a==null?void 0:a.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(i==null?void 0:i.retentionDays)??90} days`,small:!0,icon:"clock"}]}),e.jsxs("div",{className:"summary-grid",children:[e.jsxs("div",{className:"summary",children:[e.jsxs("div",{className:"summary-head",children:[e.jsx("b",{children:"Visit completion"}),e.jsx("strong",{children:be(a==null?void 0:a.completionRate)})]}),e.jsx(Ts,{value:(a==null?void 0:a.completed)??0,total:(a==null?void 0:a.journeys)??0}),e.jsxs("p",{children:[w(a==null?void 0:a.completed)," completed · ",w(a==null?void 0:a.abandoned)," abandoned ·"," ",w(a==null?void 0:a.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:u?`${ae(u.from)} → ${ae(u.to)}`:"Not enough data"}),e.jsx("p",{children:u?`${w(u.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(ne,{cols:"2",children:[e.jsx(M,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(_,{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:x.length===0?e.jsx(X,{columns:3,children:"No significant actions in this window."}):x.map(g=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ae(g.action)}),e.jsx("span",{className:"table-sub",children:ae(g.category)})]}),e.jsx("td",{className:"num",children:w(g.events)}),e.jsx("td",{className:"num",children:w(g.journeys)})]},`${g.category}:${g.action}`))})]})})}),e.jsx(M,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:h.length===0?e.jsx(X,{columns:2,children:"No repeated paths in this window."}):h.map((g,b)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[ae(g.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",ae(g.to)]}),e.jsx("td",{className:"num",children:w(g.count)})]},`${g.from}:${g.to}:${b}`))})]})})})]}),e.jsx(M,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(_,{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:g,stat:b})=>{const m=(b==null?void 0:b.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:ae(g)}),e.jsx("td",{className:"num",children:w(m)}),e.jsx("td",{className:"muted nowrap",children:b?L(b.lastUsedAt):"—"}),e.jsx("td",{children:m===0?e.jsx(S,{tone:"warn",children:"not used"}):m<3?e.jsx(S,{tone:"note",children:"rare"}):e.jsx(S,{tone:"ok",children:"used"})})]},g)})})]})})}),l?null:e.jsx(M,{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 re=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},is=s=>re((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),Ue=s=>s.itemName?`${re(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${re(s.source)} → ${re(s.target)}`:is(s),Dn=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]??re(s.action);function Pn(s){var l;const n=(l=[...s].reverse().find(t=>t.outcome))==null?void 0:l.outcome;return n==="success"||n==="completed"?{label:re(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:re(n),tone:"note"}:s.some(t=>t.action==="stop"&&t.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function Fn(s){const n=s.reduce((l,t,o)=>(t.category==="playback"&&t.action==="request"&&l.push(o),l),[]);return n.length===0?[s]:n.map((l,t)=>s.slice(t===0?0:l,n[t+1]??s.length))}function qn(){var c,a;const{userId:s=""}=xe(),n=p.useMemo(()=>`/admin/api/journeys${he({days:90,userId:s})}`,[s]),{data:l,error:t,loading:o}=K(n),d=((a=(c=l==null?void 0:l.users)==null?void 0:c.find(v=>v.userId===s))==null?void 0:a.username)||s,i=p.useMemo(()=>{const v=new Map;for(const x of(l==null?void 0:l.events)??[])v.set(x.journeyId,[...v.get(x.journeyId)??[],x]);return[...v.values()].map(x=>x.sort((h,u)=>h.sequence-u.sequence)).sort((x,h)=>{var u,f;return(((u=h[0])==null?void 0:u.occurredAt)??"").localeCompare(((f=x[0])==null?void 0:f.occurredAt)??"")})},[l==null?void 0:l.events]),r=i.flatMap(v=>Fn(v).map((x,h)=>{var u;return{events:x,key:`${(u=v[0])==null?void 0:u.journeyId}:${h}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:`${d}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",crumbs:e.jsx(G,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(D,{message:t}),o?e.jsx(O,{}):e.jsx(M,{title:"Viewing journeys",intro:`${i.length} app session${i.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((v,x)=>{const h=v.events,u=h[0],f=[...h].reverse().find(b=>b.itemName||b.action==="select"||b.category==="playback"&&b.action==="request"),g=Pn(h);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:L(u==null?void 0:u.occurredAt)}),e.jsxs("span",{children:["Journey ",x+1," · ",h.length," recorded steps"]})]}),e.jsx(S,{tone:g.tone,children:g.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(Q,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:is(u)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(Q,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?Ue(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(Q,{name:g.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:g.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:h.map(b=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":b.action}),e.jsxs("div",{children:[e.jsx("b",{children:Dn(b)}),e.jsx("span",{children:Ue(b)})]}),e.jsx("time",{children:L(b.occurredAt)})]},`${b.journeyId}:${b.sequence}`))})]},v.key)})})})]})}function Be(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function On(){const[s,n]=p.useState(30),{data:l,error:t,loading:o}=K(`/admin/api/analytics?days=${s}`),d=(l==null?void 0:l.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(D,{message:t}),e.jsx(M,{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(A,{label:"Window",children:e.jsxs("select",{value:s,onChange:i=>n(Number(i.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:o?e.jsx(O,{rows:1}):e.jsx(_,{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(X,{columns:8,children:"No events in this window."}):d.map(i=>e.jsxs("tr",{children:[e.jsx("td",{children:Be(i.rowId)}),e.jsx("td",{className:"muted",children:Be(i.rowKind)}),e.jsx("td",{className:"num",children:ce(i.dwellMs)}),e.jsx("td",{className:"num",children:w(i.impressions)}),e.jsx("td",{className:"num",children:w(i.focuses)}),e.jsx("td",{className:"num",children:w(i.selects)}),e.jsx("td",{className:"num",children:be(i.selectRate)}),e.jsx("td",{className:"num",children:w(i.viewers)})]},`${i.rowId}:${i.rowKind}`))})]})})})]})}function Un(){const[s,n]=p.useState(7),{data:l,error:t,loading:o}=K(`/admin/api/searches?days=${s}`),d=(l==null?void 0:l.terms)??[],i=(l==null?void 0:l.recent)??[],r=l==null?void 0:l.totals;return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(D,{message:t}),e.jsx(se,{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:`${(l==null?void 0:l.retentionDays)??30} days`,small:!0,icon:"clock"}]}),o?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(M,{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(A,{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(_,{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(X,{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:L(c.lastAt)})]},c.query))})]})})}),e.jsx(M,{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(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(X,{columns:3,children:"No searches in this window."}):i.map((c,a)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:L(c.occurredAt)}),e.jsx("td",{children:c.username||e.jsx(S,{tone:"warn",children:c.userId||"unknown"})}),e.jsx("td",{children:c.query})]},`${c.occurredAt}:${a}`))})]})})})]})]})}function Ve(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 Bn(){const{data:s,error:n,loading:l}=K("/admin/api/views",{pollMs:6e4}),t=(s==null?void 0:s.daily)??[],o=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(P,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(D,{message:n}),l?e.jsx(O,{}):e.jsxs(e.Fragment,{children:[e.jsx(se,{tiles:[{label:Ve((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:Ve((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(M,{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(_,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(X,{columns:3,children:"No home-screen visits yet."}):t.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(M,{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(_,{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:o.length===0?e.jsx(X,{columns:3,children:"No home-screen visits yet today."}):o.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))})]})})})]})]})}function Vn(){return e.jsx(P,{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 Wn(){return e.jsx(ds,{children:e.jsx(Ns,{children:e.jsx($s,{children:e.jsx(Fs,{children:e.jsx(hs,{children:e.jsxs(q,{path:"/admin",element:e.jsx(Ds,{}),children:[e.jsx(q,{index:!0,element:e.jsx(qs,{})}),e.jsx(q,{path:"activity",element:e.jsx(Us,{})}),e.jsx(q,{path:"accounts",element:e.jsx(Bs,{})}),e.jsx(q,{path:"accounts/:userId",element:e.jsx(Vs,{})}),e.jsx(q,{path:"accounts/:userId/settings",element:e.jsx(Gs,{})}),e.jsx(q,{path:"clients",element:e.jsx(Js,{})}),e.jsx(q,{path:"logins",element:e.jsx(Xs,{})}),e.jsx(q,{path:"devices/:deviceId",element:e.jsx(nn,{})}),e.jsx(q,{path:"library",element:e.jsx(tn,{})}),e.jsx(q,{path:"ratings",element:e.jsx(rn,{})}),e.jsx(q,{path:"requests",element:e.jsx(ln,{})}),e.jsx(q,{path:"recommendations",element:e.jsx(on,{})}),e.jsx(q,{path:"inspector",element:e.jsx(hn,{})}),e.jsx(q,{path:"hero",element:e.jsx(un,{})}),e.jsx(q,{path:"features",element:e.jsx(mn,{})}),e.jsx(q,{path:"playback",element:e.jsx(pn,{})}),e.jsx(q,{path:"subtitles",element:e.jsx(xn,{})}),e.jsx(q,{path:"updates",element:e.jsx(jn,{})}),e.jsx(q,{path:"tasks",element:e.jsx(bn,{})}),e.jsx(q,{path:"integrations",element:e.jsx(yn,{})}),e.jsx(q,{path:"maintenance",element:e.jsx(Cn,{})}),e.jsx(q,{path:"imports",element:e.jsx(Rn,{})}),e.jsx(q,{path:"logs",element:e.jsx(Tn,{})}),e.jsx(q,{path:"journeys",element:e.jsx(Ln,{})}),e.jsx(q,{path:"journeys/:userId",element:e.jsx(qn,{})}),e.jsx(q,{path:"views",element:e.jsx(Bn,{})}),e.jsx(q,{path:"engagement",element:e.jsx(On,{})}),e.jsx(q,{path:"searches",element:e.jsx(Un,{})}),e.jsx(q,{path:"overview",element:e.jsx(us,{to:"/admin",replace:!0})}),e.jsx(q,{path:"*",element:e.jsx(Vn,{})})]})})})})})})}const as=document.getElementById("root");if(!as)throw new Error("the console has no root element to render into");Ke(as).render(e.jsx(p.StrictMode,{children:e.jsx(Wn,{})})); diff --git a/admin-ui/dist/assets/index-CXwJRCVF.css b/admin-ui/dist/assets/index-CXwJRCVF.css new file mode 100644 index 0000000..13b7e37 --- /dev/null +++ b/admin-ui/dist/assets/index-CXwJRCVF.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;--bg: #010409;--rail-bg: #010409;--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;--top: 58px;--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:var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased}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:0 clamp(14px,2vw,22px) 0 0;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(960px,calc(100vw - var(--rail) - 300px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:flex;align-items:center;gap:8px;min-width:0;padding:4px 6px;border:0;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;font:inherit;text-align:left}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:wait;opacity:.65}.topbar-status .dot{width:8px;height:8px;border-radius:50%;background:var(--quiet);flex:0 0 8px}.topbar-status[data-tone=ok] .dot{background:var(--accent)}.topbar-status[data-tone=bad] .dot{background:var(--danger)}.topbar-status b{font-size:12.5px;font-weight:600}.topbar-status span{font-size:11.5px;color:var(--quiet);white-space:nowrap}@media (max-width: 900px){.topbar-version,.topbar-status span{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{flex:1 1 auto;min-width:0;border: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:0;width:min(520px,94vw);max-height:min(60vh,520px);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}.bell-button{position:relative;display:grid;place-items:center;width:34px;height:34px;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 20px;overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.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 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);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-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: 820px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .15s ease;width:236px}.rail[data-open=true]{transform:none;box-shadow:0 0 60px #000000b3}.topbar-brand{flex:0 0 auto;padding-left:12px}.page{margin-left: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: 820px){.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: 820px){:root{--top: 58px}.topbar{display:flex;gap:8px;height:var(--top);padding:0 12px}.topbar-brand{padding:0}.brand-word{display:none}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;min-width:0}.omni-input{height:36px}.omni-panel{position:fixed;top:calc(var(--top) + 6px);right:12px;left:12px;width:auto;max-height:calc(100dvh - var(--top) - 18px)}.omni-key,.topbar-version,.topbar-status{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:34px;height:34px}.page{padding:18px 14px 48px}.table-wrap{margin:0 -14px -16px;padding:0 14px 16px}table{font-size:13px}th,td{padding-right:16px}}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=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{max-height:62vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono)}.loghead{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:max-content 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}.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: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}.logline .attrs{color:var(--quiet)}@media (min-width: 900px){.logline{display:grid;grid-template-columns:max-content 46px minmax(180px,1fr) minmax(240px,2fr)}}.logline .attrs b{color:var(--muted);font-weight:400}.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)} diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index 4a2f467..57f817b 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -12,9 +12,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/api/types.ts b/admin-ui/src/api/types.ts index d3de647..2a1c5e1 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -392,9 +392,19 @@ export interface SonarrRequestPolicy { error?: string; } +export interface RadarrRequestPolicy extends SonarrRequestPolicy {} + +export interface ArrIntegrationStatus { + sonarrConfigured: boolean; + radarrConfigured: boolean; + sonarrEnabled: boolean; + radarrEnabled: boolean; +} + /* ---------- logs ---------- */ export interface LogEvent { + sequence: number; occurredAt: string; level: string; message: string; diff --git a/admin-ui/src/components/Layout.tsx b/admin-ui/src/components/Layout.tsx index 3bcaa4e..4390372 100644 --- a/admin-ui/src/components/Layout.tsx +++ b/admin-ui/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { nav } from '../nav'; import { useNotifications } from '../lib/notifications'; import { useGateway } from '../lib/gateway'; import { time } from '../lib/format'; +import { Confirm } from './ui'; /* The shell: a top bar spanning the width, a rail down the left, and the page. * @@ -105,12 +106,12 @@ export function Layout() { const { version, online, checkedAt, status, setMaintenance } = useGateway(); const [railOpen, setRailOpen] = useState(false); const [changingAvailability, setChangingAvailability] = useState(false); + const [confirmingOffline, setConfirmingOffline] = useState(false); const location = useLocation(); const offline = Boolean(status?.maintenance?.enabled); const toggleAvailability = async () => { if (changingAvailability || !status) return; - if (!offline && !window.confirm('Take Memby offline for every television?')) return; setChangingAvailability(true); try { await setMaintenance(!offline); @@ -162,7 +163,7 @@ export function Layout() { aria-pressed={offline} disabled={!status || changingAvailability} title={offline ? 'Bring Memby back online' : 'Take Memby offline'} - onClick={() => void toggleAvailability()} + onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)} > {offline ? 'offline' : online ? 'online' : 'not responding'} @@ -182,6 +183,20 @@ export function Layout() {
+ {confirmingOffline ? ( + { + setConfirmingOffline(false); + void toggleAvailability(); + }} + onCancel={() => setConfirmingOffline(false)} + /> + ) : null} ); } diff --git a/admin-ui/src/pages/Features.tsx b/admin-ui/src/pages/Features.tsx index b4e0512..f2ee4ec 100644 --- a/admin-ui/src/pages/Features.tsx +++ b/admin-ui/src/pages/Features.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { api } from '../api/client'; import { useAction } from '../lib/hooks'; import { useGateway } from '../lib/gateway'; @@ -11,31 +11,27 @@ import { Chip, Confirm, Empty, - Field, Grid, Loading, PageHead, PlainTiles, Tag, + Toggle, } from '../components/ui'; -type Mode = 'default' | 'on' | 'off'; - interface Pending { - action: 'safe-mode' | 'rollback' | 'reset'; + action: 'safe-mode' | 'rollback' | 'reset' | 'feature'; title: string; body: string; label: string; + key?: string; + enabled?: boolean; } export function FeaturesPage() { const { status, error, loading, reload } = useGateway(); const { wrap } = useToast(); const { busy, run } = useAction(); - // The choices the operator has made but not published. Held apart from the server's - // answer so a poll landing mid-edit cannot take a half-made decision away — the rule the - // previous console needed `Admin.settled` for. - const [draft, setDraft] = useState>({}); const [pending, setPending] = useState(null); const policy = status?.features; @@ -43,20 +39,6 @@ export function FeaturesPage() { const clients = status?.clients ?? []; const revision = policy?.revision ?? 0; - useEffect(() => { - // Adopt the server's state only where the operator has not expressed a preference. - if (!policy) return; - setDraft((current) => { - const next = { ...current }; - for (const feature of policy.features ?? []) { - if (next[feature.key] === undefined) { - next[feature.key] = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default'; - } - } - return next; - }); - }, [policy]); - const act = (action: string, key: string, message: string, overrides?: Record) => run(key, async () => { await wrap( @@ -71,24 +53,17 @@ export function FeaturesPage() { message, ); setPending(null); - if (action === 'save') { - // Published: the draft is now the server's state, so stop holding it. - setDraft({}); - } await reload(); }); - const publish = () => { - const overrides: Record = {}; - for (const [key, mode] of Object.entries(draft)) { - if (mode === 'on') overrides[key] = true; - if (mode === 'off') overrides[key] = false; - } - return act('save', 'save', 'Published.', overrides); - }; - const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1')).length; const safeMode = Boolean(policy?.safeMode); + const overridesFor = (key: string, enabled: boolean) => ({ + ...Object.fromEntries( + features.filter((feature) => feature.source === 'override').map((feature) => [feature.key, feature.enabled]), + ), + [key]: enabled, + }); return ( <> @@ -160,18 +135,18 @@ export function FeaturesPage() { actions={{feature.enabled ? 'active' : 'off'}} footer={↳ {feature.recovery}} > - - - + setPending({ + action: 'feature', key: feature.key, enabled, + title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`, + body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`, + label: enabled ? 'Turn on' : 'Turn off', + })} + />
{feature.key} protocol {num(feature.minimumProtocol)}+ @@ -187,9 +162,6 @@ export function FeaturesPage() {
- } + > + + {loading ? : <> +
+ +
+ + {selected ? Requested films will use {selected.name} (profile ID {selected.id}), remain monitored, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'} : null} + } + + ); +} + function IntegrationCard({ integration, catalogue, diff --git a/admin-ui/src/pages/Logs.tsx b/admin-ui/src/pages/Logs.tsx index 6071e37..572d7a1 100644 --- a/admin-ui/src/pages/Logs.tsx +++ b/admin-ui/src/pages/Logs.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api/client'; import { num, when } from '../lib/format'; import { Banner, Button, Card, Field, PageHead } from '../components/ui'; @@ -18,9 +18,10 @@ const DRAW = 2_500; const POLL_MS = 5_000; /* The same order the server's own console lines use — who and where first, the reason for - the line last — so a log read here and a log read over SSH look alike. `version` is - dropped: it is the same on every line and is reported once in the top bar instead. */ -const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration']; + the line last — so a log read here and a log read over SSH look alike. The gateway + version deliberately remains visible: a server-log export is often read away from the + console whose top bar would otherwise supply it. */ +const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version']; function orderedFields(attributes: Record): [string, unknown][] { const rank = (key: string) => { @@ -28,11 +29,11 @@ function orderedFields(attributes: Record): [string, unknown][] if (at >= 0) return at; return key === 'error' ? 1000 : 100; }; - return Object.entries(attributes) - .filter(([key]) => key !== 'version') - .sort((a, b) => rank(a[0]) - rank(b[0])); + return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0])); } +const readableKey = (key: string) => key.replace(/_/g, ' '); + const haystack = (event: LogEvent) => [event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase(); @@ -157,25 +158,31 @@ export function LogsPage() {
+ {visible.length === 0 ? (

{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}

) : ( visible.map((event, index) => (
- + {/* The level is a class on the line as well as its own column: scrolling a log is looking for the one line that is not INFO, and a coloured word four columns in is easy to scroll past. */} {event.level} {event.message} - {orderedFields(event.attributes ?? {}).map(([key, value]) => ( - - {' '} - {key}= - {String(value)} - - ))} +
+ {orderedFields(event.attributes ?? {}).map(([key, value]) => {key}={String(value)} )} +
+
Log record
{event.sequence}
+ {orderedFields(event.attributes ?? {}).map(([key, value]) =>
{readableKey(key)}
{String(value)}
)} +
+
)) diff --git a/admin-ui/src/styles.css b/admin-ui/src/styles.css index 13adec2..38d3cd5 100644 --- a/admin-ui/src/styles.css +++ b/admin-ui/src/styles.css @@ -744,9 +744,9 @@ a { background: var(--surface); } .tile .glyph { - /* Tiles keep their figures left-aligned for quick scanning, but the visual glyph is a - standalone marker and follows the centred treatment used by glyph plates elsewhere. */ - margin: 0 auto 9px; + /* Summary cards share tile padding, so their marker always starts at the same top-left + inset. The SVG itself remains centred by .glyph's grid treatment. */ + margin: 0 0 9px; } .tile b { display: block; @@ -1442,6 +1442,22 @@ select { background: #080a0e; font: 12px/1.6 var(--mono); } +.loghead { + position: sticky; + top: 0; + z-index: 1; + display: grid; + grid-template-columns: max-content 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; +} .logline { display: flex; gap: 12px; @@ -1457,6 +1473,11 @@ select { 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; @@ -1480,10 +1501,44 @@ select { .logline .attrs { color: var(--quiet); } +@media (min-width: 900px) { + .logline { + display: grid; + grid-template-columns: max-content 46px minmax(180px, 1fr) minmax(240px, 2fr); + } +} .logline .attrs b { color: var(--muted); font-weight: 400; } +.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: rgba(255, 255, 255, .025); +} +.logdetails dt { color: var(--muted); } +.logdetails dd { min-width: 0; margin: 0; color: var(--text); overflow-wrap: anywhere; } pre.code { margin: 0; diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f969ec7..02c23f4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,10 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?) // The Memby gateway container. When set, the client talks to it instead of Emby and // becomes a thin renderer; blank keeps the direct-to-Emby path above. val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim() +// Diagnostic verbosity is a build-time switch so a support APK can capture deep network +// and Media3 state without changing call sites. Valid values: INFO, DEBUG, TRACE. +val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) + ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" // Kept in BuildConfig so the TV can show the exact corresponding-source location and // the complete legal documents offline. Deployments can override the public source URL @@ -90,6 +94,7 @@ android { buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"") buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"") + buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"") buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl)) buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText)) buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText)) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt index 316400b..2480975 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt @@ -41,6 +41,7 @@ object EmbyServiceFactory { .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider)) + .addInterceptor(DiagnosticNetworkInterceptor("emby")) .addInterceptor(logging) .build() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index f686a9b..5e2da9f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.gatewayAudioTokens import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens import com.ponzischeme89.memby.update.RequiredUpdateSignal +import com.ponzischeme89.memby.diagnostics.MembyDiagnostics import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -37,6 +38,7 @@ object GatewayServiceFactory { // read timeout here only ever means Emby itself is struggling behind it. .readTimeout(20, TimeUnit.SECONDS) .addInterceptor(GatewayAuthInterceptor(tokenProvider)) + .addInterceptor(DiagnosticNetworkInterceptor("gateway")) .addInterceptor(RequiredUpdateInterceptor) .build() @@ -49,6 +51,28 @@ object GatewayServiceFactory { } } +/** Timings and outcomes only: request URLs are stripped of query parameters and no headers are read. */ +internal class DiagnosticNetworkInterceptor(private val backend: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val started = android.os.SystemClock.elapsedRealtime() + MembyDiagnostics.trace("http_started", "backend" to backend, "method" to request.method, "url" to MembyDiagnostics.safeUrl(request.url)) + return try { + chain.proceed(request).also { response -> + MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method, + "url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code, + "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started), + "correlation" to response.header("X-Memby-Correlation")) + } + } catch (error: Exception) { + MembyDiagnostics.debug("http_failed", "backend" to backend, "method" to request.method, + "url" to MembyDiagnostics.safeUrl(request.url), "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started), + "exception" to error.javaClass.simpleName, "detail" to error.message) + throw error + } + } +} + /** * Sends the gateway token as a bearer header. Image URLs cannot carry headers, so those * are built with a `t=` query parameter instead (see EmbyRepository's URL helpers). diff --git a/app/src/main/java/com/ponzischeme89/memby/diagnostics/MembyDiagnostics.kt b/app/src/main/java/com/ponzischeme89/memby/diagnostics/MembyDiagnostics.kt new file mode 100644 index 0000000..9ef874a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/diagnostics/MembyDiagnostics.kt @@ -0,0 +1,34 @@ +package com.ponzischeme89.memby.diagnostics + +import android.util.Log +import com.ponzischeme89.memby.BuildConfig +import okhttp3.HttpUrl + +/** Small, dependency-free diagnostic log shared by network and playback code. */ +object MembyDiagnostics { + private const val TAG = "MembyDiagnostic" + private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2) + private val configured = rank[BuildConfig.DIAGNOSTIC_LOG_LEVEL] ?: 2 + + fun debug(event: String, vararg fields: Pair) = write("DEBUG", event, fields) + fun trace(event: String, vararg fields: Pair) = write("TRACE", event, fields) + fun info(event: String, vararg fields: Pair) = write("INFO", event, fields) + + private fun write(level: String, event: String, fields: Array>) { + if ((rank[level] ?: Int.MAX_VALUE) < configured) return + val text = buildString { + append("event=").append(event) + fields.forEach { (key, value) -> append(' ').append(key).append('=').append(safe(key, value)) } + } + when (level) { "INFO" -> Log.i(TAG, text); else -> Log.d(TAG, text) } + } + + fun safeUrl(url: HttpUrl): String = url.newBuilder().query(null).build().toString() + private fun safe(key: String, value: Any?): String { + val lower = key.lowercase() + if (lower.contains("token") || lower.contains("password") || lower.contains("secret") || lower.contains("cookie") || lower.contains("authorization") || lower.contains("key")) return "[redacted]" + return value?.toString()?.replace(SENSITIVE_QUERY_VALUE, "$1[redacted]") ?: "null" + } + + private val SENSITIVE_QUERY_VALUE = Regex("([?&](?:api[_-]?key|token|auth(?:orization)?|t)=)[^&\\s]+", RegexOption.IGNORE_CASE) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 1598dec..4ff0984 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -60,6 +60,7 @@ import coil.load import coil.request.ImageRequest import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.diagnostics.MembyDiagnostics import com.ponzischeme89.memby.data.audioPassthroughPreference import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.IntroSegment @@ -609,6 +610,9 @@ class PlayerActivity : ComponentActivity() { trace.mark(PlaybackTrace.PLAYER_BUILT) playback.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { + MembyDiagnostics.debug("media3_state", "playback" to playSessionId, "item" to itemId, "state" to media3StateName(playbackState), + "play_when_ready" to playback.playWhenReady, "position_ms" to playback.currentPosition, + "buffered_ms" to playback.bufferedPosition) recordPlaybackState(playbackState) when (playbackState) { Player.STATE_BUFFERING -> { @@ -640,6 +644,8 @@ class PlayerActivity : ComponentActivity() { } override fun onIsPlayingChanged(isPlaying: Boolean) { + MembyDiagnostics.debug("media3_playing", "playback" to playSessionId, "item" to itemId, "is_playing" to isPlaying, + "position_ms" to playback.currentPosition) if (playbackStarted && !stopReported) { reportProgress( playback.currentPosition, @@ -664,6 +670,7 @@ class PlayerActivity : ComponentActivity() { } override fun onTracksChanged(tracks: Tracks) { + MembyDiagnostics.trace("media3_tracks_changed", "playback" to playSessionId, "item" to itemId, "groups" to tracks.groups.size) updateStreamStatus(tracks) ensureSubtitleSelected(tracks) } @@ -674,10 +681,14 @@ class PlayerActivity : ComponentActivity() { } override fun onPlayerError(error: PlaybackException) { + MembyDiagnostics.debug("media3_error", "playback" to playSessionId, "item" to itemId, "code" to error.errorCodeName, + "exception" to error.cause?.javaClass?.simpleName, "detail" to error.message) handlePlaybackError(error) } override fun onRenderedFirstFrame() { + MembyDiagnostics.info("media3_first_frame", "playback" to playSessionId, "item" to itemId, + "startup_ms" to (SystemClock.elapsedRealtime() - requestStartedAtMs)) renderedFirstFrame = true trailerStartupTimeoutJob?.cancel() trailerStartupTimeoutJob = null @@ -1726,6 +1737,14 @@ class PlayerActivity : ComponentActivity() { ) } + private fun media3StateName(state: Int): String = when (state) { + Player.STATE_IDLE -> "idle" + Player.STATE_BUFFERING -> "buffering" + Player.STATE_READY -> "ready" + Player.STATE_ENDED -> "ended" + else -> "unknown($state)" + } + private fun updateStreamStatus(tracks: Tracks) { val selected = tracks.groups.flatMap { group -> (0 until group.length) diff --git a/server/README.md b/server/README.md index 950a87d..b16da80 100644 --- a/server/README.md +++ b/server/README.md @@ -61,8 +61,8 @@ The server writes one aligned line per event, designed for `docker compose logs -f server` and for the admin page's live log: ``` -2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 title="Severance – Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0 -2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0 +2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 correlation=req-8F2A1C play_session_id=8F2A1C title="Severance – Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0 +2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 correlation=req-4D91B7 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0 ``` The timestamp is a column rather than a `time=` field, and the fields are always in the @@ -75,9 +75,11 @@ into the admin log when the gateway starts. Compose mounts that path from the pe `memby-logs` volume, so replacing the container for a new version keeps the previous history. The archive is compacted to the configured buffer capacity and remains bounded. -Every line from a request carries the viewer, the television, the app build and the -`component` — the part of the app the call came from, derived from the route, so it is -right even for an APK too old to report anything about itself. +Every line from a request carries the viewer, the television, the app build, a correlation +identifier and the `component` — the part of the app the call came from, derived from the +route, so it is right even for an APK too old to report anything about itself. The same +identifier is returned as `X-Memby-Correlation`; the TV's network diagnostics record it +beside the request outcome. Playback events also carry the Emby play-session identifier. Beyond the per-request line, these are logged as events in their own right: sign-in, sign-out and rejected sign-ins; a device removed or renamed from another TV; what @@ -88,8 +90,10 @@ library imports (start, progress per 500-item page, final counts and duration). At the default `MEMBY_LOG_LEVEL=INFO`, successful health checks, live maintenance polls, artwork requests, search terms and ten-second playback progress reports are hidden; -warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG` -temporarily and recreate the server container when they are useful during diagnosis. +warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG` for +diagnostic request and playback detail, or `TRACE` for the deepest negotiation and request +trace, then recreate the server container. URLs and sensitive structured attributes are +redacted before they reach either the console or admin log history. The build that wrote a line is `internal/buildinfo/VERSION`, embedded at compile time — bump it with a meaningful server change. It is also on `/healthz` and in the admin rail. @@ -614,7 +618,7 @@ can review and revoke signed-in TVs from the app's Settings screen. | `MEMBY_DATABASE_URL` | *required* | Postgres DSN | | `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | | | `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | | -| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests | +| `MEMBY_LOG_LEVEL` | `INFO` | `DEBUG` adds diagnostic request/playback detail; `TRACE` adds deep negotiation tracing | | `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded persistent admin event history; `0` disables capture | | `MEMBY_LOG_HISTORY_PATH` | `/data/logs/events.jsonl` | JSONL history restored after container replacement | | `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` | diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 5585751..d0fb4df 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -61,6 +61,10 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy)) mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy)) mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy)) + mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy)) + mux.Handle("POST /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy)) + mux.Handle("GET /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations)) + mux.Handle("POST /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations)) mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy)) mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch)) mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy)) @@ -459,8 +463,10 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request return } valid := make(map[string]bool, len(known)) + usernames := make(map[string]string, len(known)) for _, user := range known { valid[user.ID] = true + usernames[user.ID] = user.Username } seen := map[string]bool{} allowed := make([]string, 0, len(req.AllowedUserIDs)) @@ -482,7 +488,15 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "could not save request access") return } - s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed)) + allowedNames := make([]string, 0, len(allowed)) + for _, id := range allowed { + allowedNames = append(allowedNames, usernames[id]) + } + s.loggerFor(r.Context()).Info("Media Request access level updated", + "gateway_version", buildinfo.Version(), + "allowed_usernames", strings.Join(allowedNames, ", "), + "allowed_user_ids", strings.Join(allowed, ", "), + "allowed_users", len(allowed)) writeJSON(w, http.StatusOK, policy) } diff --git a/server/internal/api/admin_arr_integrations.go b/server/internal/api/admin_arr_integrations.go new file mode 100644 index 0000000..53ccbcb --- /dev/null +++ b/server/internal/api/admin_arr_integrations.go @@ -0,0 +1,44 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +type arrIntegrationStatus struct { + SonarrConfigured bool `json:"sonarrConfigured"` + RadarrConfigured bool `json:"radarrConfigured"` + SonarrEnabled bool `json:"sonarrEnabled"` + RadarrEnabled bool `json:"radarrEnabled"` +} + +func (s *Server) arrIntegrationStatus(r *http.Request) arrIntegrationStatus { + policy, err := s.store.ArrIntegrationPolicy(r.Context()) + if err != nil { + policy = store.DefaultArrIntegrationPolicy() + } + return arrIntegrationStatus{SonarrConfigured: s.sonarr != nil, RadarrConfigured: s.radarr != nil, SonarrEnabled: s.sonarr != nil && policy.SonarrEnabled, RadarrEnabled: s.radarr != nil && policy.RadarrEnabled} +} + +func (s *Server) handleAdminArrIntegrations(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r)) + return + } + var req struct { + SonarrEnabled bool `json:"sonarrEnabled"` + RadarrEnabled bool `json:"radarrEnabled"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if err := s.store.SetArrIntegrationPolicy(r.Context(), store.ArrIntegrationPolicy{SonarrEnabled: req.SonarrEnabled, RadarrEnabled: req.RadarrEnabled}); err != nil { + writeError(w, http.StatusInternalServerError, "could not save integration settings") + return + } + s.loggerFor(r.Context()).Info("arr integrations changed", "sonarr_enabled", req.SonarrEnabled, "radarr_enabled", req.RadarrEnabled) + writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r)) +} diff --git a/server/internal/api/admin_radarr_requests.go b/server/internal/api/admin_radarr_requests.go new file mode 100644 index 0000000..8659bf9 --- /dev/null +++ b/server/internal/api/admin_radarr_requests.go @@ -0,0 +1,106 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +type radarrProfileOption struct { + ID int `json:"id"` + Name string `json:"name"` + Recommended bool `json:"recommended"` +} + +type radarrRequestAdminPolicy struct { + QualityProfileID int `json:"qualityProfileId"` + SearchImmediately bool `json:"searchImmediately"` + Profiles []radarrProfileOption `json:"profiles"` + RecommendedID int `json:"recommendedId"` + Configured bool `json:"configured"` + Error string `json:"error,omitempty"` +} + +func (s *Server) radarrRequestAdminPolicy(ctx context.Context) radarrRequestAdminPolicy { + policy, err := s.store.RadarrRequestPolicy(ctx) + if err != nil { + return radarrRequestAdminPolicy{Profiles: []radarrProfileOption{}, Error: "Could not read the Radarr request policy."} + } + result := radarrRequestAdminPolicy{QualityProfileID: policy.QualityProfileID, SearchImmediately: policy.SearchImmediately, Profiles: []radarrProfileOption{}} + if s.radarr == nil { + result.Error = "Radarr is not configured." + return result + } + profiles, err := s.radarr.QualityProfiles(ctx) + if err != nil { + result.Error = "Could not read Radarr quality profiles." + return result + } + for _, profile := range profiles { + recommended := is720pProfile(profile.Name) + if recommended && result.RecommendedID == 0 { + result.RecommendedID = profile.ID + } + if profile.ID == policy.QualityProfileID { + result.Configured = true + } + result.Profiles = append(result.Profiles, radarrProfileOption{ID: profile.ID, Name: profile.Name, Recommended: recommended}) + } + if policy.QualityProfileID == 0 && result.RecommendedID != 0 { + result.QualityProfileID, result.Configured = result.RecommendedID, true + } + if !result.Configured && result.Error == "" { + result.Error = "Choose an existing Radarr quality profile before accepting movie requests." + } + return result +} + +type radarrRequestPolicyRequest struct { + QualityProfileID int `json:"qualityProfileId"` + SearchImmediately bool `json:"searchImmediately"` +} + +func (s *Server) handleAdminRadarrRequestPolicy(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context())) + return + } + if s.radarr == nil { + writeError(w, http.StatusServiceUnavailable, "Radarr is not configured") + return + } + var req radarrRequestPolicyRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if req.QualityProfileID <= 0 { + writeError(w, http.StatusBadRequest, "choose a Radarr quality profile") + return + } + profiles, err := s.radarr.QualityProfiles(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "could not validate Radarr quality profiles") + return + } + found := false + for _, profile := range profiles { + if profile.ID == req.QualityProfileID { + found = true + break + } + } + if !found { + writeError(w, http.StatusBadRequest, "the selected Radarr quality profile no longer exists") + return + } + if err := s.store.SetRadarrRequestPolicy(r.Context(), store.RadarrRequestPolicy{QualityProfileID: req.QualityProfileID, SearchImmediately: req.SearchImmediately}); err != nil { + s.loggerFor(r.Context()).Error("Radarr request policy write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not save Radarr request policy") + return + } + s.loggerFor(r.Context()).Info("Radarr request policy changed", "quality_profile_id", req.QualityProfileID, "search_immediately", req.SearchImmediately) + writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context())) +} diff --git a/server/internal/api/alerts.go b/server/internal/api/alerts.go index 2f4d74b..a13df34 100644 --- a/server/internal/api/alerts.go +++ b/server/internal/api/alerts.go @@ -180,7 +180,7 @@ 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.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 { + if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 { return nil } row, err := s.sonarrAiringTodayRow(ctx) diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 3ad7f54..6a0b9b9 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -153,6 +153,30 @@ func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) { s.adminEvents.Publish(ctx, event) } +func (s *Server) sonarrEnabled(ctx context.Context) bool { + if s.sonarr == nil || s.store == nil { + return false + } + policy, err := s.store.ArrIntegrationPolicy(ctx) + if err != nil { + s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err) + return false + } + return policy.SonarrEnabled +} + +func (s *Server) radarrEnabled(ctx context.Context) bool { + if s.radarr == nil || s.store == nil { + return false + } + policy, err := s.store.ArrIntegrationPolicy(ctx) + if err != nil { + s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err) + return false + } + return policy.RadarrEnabled +} + func (s *Server) Routes() http.Handler { // The client API lives on its own mux so maintenance mode can gate all of it at // once, without the gate ever touching health checks or the admin page. @@ -404,6 +428,9 @@ func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() r, identity := withRequestIdentity(r) + w.Header().Set("X-Memby-Correlation", identity.correlation) + s.loggerFor(r.Context()).Log(r.Context(), serverlogging.LevelTrace, "request started", + "method", r.Method, "path", r.URL.Path, "query_keys", queryKeys(r)) rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) // Polling the live-log endpoint must not create another live-log record and @@ -417,7 +444,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler { // // Path only: query strings can carry image tokens. level := requestLogLevel(r.URL.Path, rec.status) - fields := []any{"component", identity.component} + fields := []any{"component", identity.component, "correlation", identity.correlation} fields = append(fields, identity.viewerAttrs()...) // The app build keeps its placeholder where the viewer does not, because "which // build made this call" always has an answer worth seeing, including "it did @@ -439,6 +466,17 @@ func (s *Server) withLogging(next http.Handler) http.Handler { }) } +// queryKeys is diagnostic context without values: query values can contain title searches, +// tokens or other private values, while their keys are enough to explain the route shape. +func queryKeys(r *http.Request) string { + keys := make([]string, 0, len(r.URL.Query())) + for key := range r.URL.Query() { + keys = append(keys, key) + } + slices.Sort(keys) + return strings.Join(keys, ",") +} + func clientLogValue(value string) string { if value == "" { return "unknown" diff --git a/server/internal/api/calendar.go b/server/internal/api/calendar.go index 6ef76c1..9871b2e 100644 --- a/server/internal/api/calendar.go +++ b/server/internal/api/calendar.go @@ -62,7 +62,7 @@ type calendarDay struct { func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) { ctx := r.Context() - if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) { + if !s.sonarrEnabled(ctx) || !s.featureEnabled(ctx, featureTVCalendar) { writeJSON(w, http.StatusOK, emptyCalendar()) return } diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 71d0fa0..923975a 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -65,8 +65,8 @@ type homeResponse struct { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() limit := queryInt(r, "limit", 24, 100) - sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r) - radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r) + sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r) + radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r) hero := supportsHomeHero(r) key := cache.UserKey( sess.EmbyUserID, diff --git a/server/internal/api/images.go b/server/internal/api/images.go index d641f56..f73a2c1 100644 --- a/server/internal/api/images.go +++ b/server/internal/api/images.go @@ -84,7 +84,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store. } func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) { - if s.radarr == nil { + if !s.radarrEnabled(r.Context()) { writeError(w, http.StatusNotFound, "unknown image") return } @@ -132,7 +132,7 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI } func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) { - if s.sonarr == nil { + if !s.sonarrEnabled(r.Context()) { writeError(w, http.StatusNotFound, "unknown image") return } diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index 946e294..968c3eb 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -2,6 +2,8 @@ package api import ( "context" + "crypto/rand" + "encoding/hex" "log/slog" "net" "net/http" @@ -19,11 +21,12 @@ import ( // still read what an inner layer filled in. A request is served on one goroutine and the // handler has returned by the time the middleware reads this, so no lock is needed. type requestIdentity struct { - component string - user string - device string - client string - protocol string + component string + user string + device string + client string + protocol string + correlation string } type identityKey struct{} @@ -31,9 +34,10 @@ type identityKey struct{} // withRequestIdentity installs an empty identity for this request and returns it. func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) { identity := &requestIdentity{ - component: componentFor(r.URL.Path), - client: clientVersion(r), - protocol: clientProtocol(r), + component: componentFor(r.URL.Path), + client: clientVersion(r), + protocol: clientProtocol(r), + correlation: requestCorrelation(r), } return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity } @@ -79,9 +83,35 @@ func (i *requestIdentity) attrs() []any { if i.client != "" { attrs = append(attrs, "client", i.client) } + if i.correlation != "" { + attrs = append(attrs, "correlation", i.correlation) + } return attrs } +// requestCorrelation accepts a client-supplied safe identifier or creates one at the +// gateway boundary. The same value is attached to every log line within the exchange and +// returned to the client, making a playback launch traceable through gateway and Emby work. +func requestCorrelation(r *http.Request) string { + if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 { + for _, rune := range value { + if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') { + return newCorrelation() + } + } + return value + } + return newCorrelation() +} + +func newCorrelation() string { + var raw [4]byte + if _, err := rand.Read(raw[:]); err != nil { + return "request-unknown" + } + return "req-" + strings.ToUpper(hex.EncodeToString(raw[:])) +} + // viewerAttrs names the person and the television, and only when they are known: an // unauthenticated probe has neither, and "user=unknown" on every health check is noise. func (i *requestIdentity) viewerAttrs() []any { diff --git a/server/internal/api/my_shows.go b/server/internal/api/my_shows.go index ab9cd5c..cebd06e 100644 --- a/server/internal/api/my_shows.go +++ b/server/internal/api/my_shows.go @@ -77,7 +77,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store. return } sonarrSeries := []sonarr.Series{} - if s.sonarr != nil { + if s.sonarrEnabled(r.Context()) { if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil { sonarrSeries = value } else { @@ -186,7 +186,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses func (s *Server) syncReturnNotifications( r *http.Request, sess store.Session, prefs store.NotificationPreferences, ) { - if s.sonarr == nil { + if !s.sonarrEnabled(r.Context()) { return } shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID) diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 0ad1e64..f8a5dda 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -8,8 +8,10 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/ponzischeme89/memby/server/internal/emby" + serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -161,9 +163,11 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto subtitleIndex = &parsed } } + negotiationStarted := time.Now() + forceTranscode := queryBool(r, "forceTranscode") subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "", - queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess), + forceTranscode, s.effectivePlaybackCapabilities(ctx, sess), ) streamURL := s.emby.StreamURL(cred, target.ID) if negotiatedURL != "" { @@ -198,6 +202,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto "subtitles", len(subtitles), "subtitle_track", clientLogValue(selectedSubtitleID), "subtitle_language", clientLogValue(subtitleLanguage), + "media_source_id", mediaSourceID, + "play_session_id", clientLogValue(playSessionID), + "force_transcode", forceTranscode, + "negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond), ) writeJSON(w, http.StatusOK, playbackResponse{ @@ -392,15 +400,20 @@ func (s *Server) playbackSubtitles( subtitleIndex *int, currentPlaySessionID string, forceTranscode bool, capabilities emby.PlaybackCapabilities, ) ([]playableSubtitle, string, string, string, string) { + started := time.Now() + log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode, + "subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond)) + log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started") info, err := s.emby.PlaybackInfo( ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode, capabilities, ) if err != nil { - s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err) + log.Warn("Emby playback negotiation failed", "duration", time.Since(started).Round(time.Millisecond), "error", err) return []playableSubtitle{}, itemID, "", "", "DirectPlay" } if len(info.MediaSources) == 0 { + log.Warn("Emby playback negotiation returned no media sources", "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID) return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay" } out := make([]playableSubtitle, 0) @@ -457,12 +470,37 @@ func (s *Server) playbackSubtitles( // download that produced it. out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID)) delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil) + log.Debug("Emby playback source selected", + "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID, + "media_source_id", source.ID, "media_sources", len(info.MediaSources), "subtitles", len(out), + "play_method", playMethod, "selection_reason", playbackSelectionReason(source, forceTranscode || subtitleIndex != nil), + "supports_direct_play", source.SupportsDirectPlay, "supports_direct_stream", source.SupportsDirectStream, + "supports_transcoding", source.SupportsTranscoding) if delivery != "" { delivery = s.emby.DeliveryURL(cred, delivery) } return out, source.ID, info.PlaySessionID, delivery, playMethod } +func playbackSelectionReason(source emby.MediaSourceInfo, forceTranscode bool) string { + switch { + case forceTranscode && source.TranscodingURL != "": + return "forced by viewer or burned-in subtitle" + case source.SupportsDirectPlay: + return "source supports direct play" + case source.SupportsDirectStream && source.DirectStreamURL != "": + return "direct play unavailable; source supports direct stream" + case source.SupportsTranscoding && source.TranscodingURL != "": + return "source requires transcoding" + case source.DirectStreamURL != "": + return "fallback direct-stream URL available" + case source.TranscodingURL != "": + return "fallback transcode URL available" + default: + return "Emby supplied no alternate delivery URL" + } +} + func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities { capabilities := emby.PlaybackCapabilities{} for _, value := range sess.ClientCapabilities { @@ -729,6 +767,8 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se log := s.loggerFor(r.Context()).With( "title", s.playbackTitles.name(report.ItemID), "item", report.ItemID, + "media_source_id", clientLogValue(report.MediaSourceID), + "play_session_id", clientLogValue(report.PlaySessionID), ) err := s.emby.ReportPlayback( @@ -756,6 +796,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se log.Info("playback started", "position", millisecondDuration(report.PositionMs), "play_method", clientLogValue(report.PlayMethod), + "event_name", clientLogValue(report.EventName), ) case "stopped": log.Info("playback stopped", @@ -767,6 +808,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se log.Debug("playback progress", "position", millisecondDuration(report.PositionMs), "paused", report.IsPaused, + "event_name", clientLogValue(report.EventName), ) } diff --git a/server/internal/api/radarr.go b/server/internal/api/radarr.go index 7c3bbca..f4d3ca3 100644 --- a/server/internal/api/radarr.go +++ b/server/internal/api/radarr.go @@ -45,7 +45,7 @@ type radarrScheduleItem struct { } func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) { - if s.radarr == nil { + if !s.radarrEnabled(ctx) { return nil, nil } location := s.cfg.RadarrLocation @@ -243,7 +243,7 @@ const radarrMovieCacheKey = "radarr:movies:v1" // Failure degrades to asking Radarr directly — a cache that is down costs latency, never the // answer. func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) { - if s.radarr == nil { + if !s.radarrEnabled(ctx) { return nil, fmt.Errorf("radarr: not configured") } if movies := s.cachedRadarrMovies(ctx); movies != nil { diff --git a/server/internal/api/requests.go b/server/internal/api/requests.go index 563682d..3af4839 100644 --- a/server/internal/api/requests.go +++ b/server/internal/api/requests.go @@ -48,7 +48,7 @@ type requestLookupResponse struct { } func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool { - if s.store == nil || (s.sonarr == nil && s.radarr == nil) { + if s.store == nil || (!s.sonarrEnabled(r.Context()) && !s.radarrEnabled(r.Context())) { return false } policy, err := s.store.RequestPolicy(r.Context()) @@ -73,7 +73,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses var movieCandidates []requestCandidate var seriesCandidates []requestCandidate var wg sync.WaitGroup - if s.radarr != nil { + if s.radarrEnabled(r.Context()) { wg.Add(1) go func() { defer wg.Done() @@ -98,7 +98,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses } }() } - if s.sonarr != nil { + if s.sonarrEnabled(r.Context()) { wg.Add(1) go func() { defer wg.Done() @@ -235,7 +235,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor switch req.MediaType { case "movie": - if s.radarr == nil { + if !s.radarrEnabled(r.Context()) { s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured")) writeError(w, http.StatusServiceUnavailable, "movie requests are not configured") return @@ -265,13 +265,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor return } req.Title = movie.Title - added, err := s.radarr.AddRequested(r.Context(), movie) + requestOptions, rootFolder, profileName, err := s.radarrRequestOptions(r.Context()) if err != nil { + s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", "", 0, "", false, err) + s.publishRadarrRequestConfigurationProblem(r.Context(), err) + writeError(w, http.StatusServiceUnavailable, "movie requests are unavailable: "+err.Error()) + return + } + added, err := s.radarr.AddRequested(r.Context(), movie, rootFolder, requestOptions) + if err != nil { + s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err) s.logMediaRequest(r.Context(), req, "failed", err) s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie") return } req.Title = added.Title + s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil) s.recordMediaRequest(r.Context(), sess, req, added.Year, radarrCoverURL(added.Images, "poster")) s.logMediaRequest(r.Context(), req, "successful", nil) @@ -279,7 +288,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor return } case "series": - if s.sonarr == nil { + if !s.sonarrEnabled(r.Context()) { s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured")) writeError(w, http.StatusServiceUnavailable, "series requests are not configured") return @@ -392,7 +401,7 @@ func (s *Server) writeRequestUpstreamError( // sonarrRequestOptions validates every component before a POST can reach Sonarr. A missing // configured profile is an error, not permission to fall back to Sonarr's "Any" profile. func (s *Server) sonarrRequestOptions(ctx context.Context) (sonarr.RequestOptions, string, string, error) { - if s.sonarr == nil { + if !s.sonarrEnabled(ctx) { return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable") } policy, err := s.store.SonarrRequestPolicy(ctx) @@ -465,3 +474,74 @@ func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, e Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}), }) } + +func (s *Server) radarrRequestOptions(ctx context.Context) (radarr.RequestOptions, string, string, error) { + if !s.radarrEnabled(ctx) { + return radarr.RequestOptions{}, "", "", errors.New("Radarr integration is unavailable") + } + policy, err := s.store.RadarrRequestPolicy(ctx) + if err != nil { + return radarr.RequestOptions{}, "", "", errors.New("could not read the Radarr request policy") + } + roots, err := s.radarr.RootFolders(ctx) + if err != nil { + return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr root folders") + } + if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" { + return radarr.RequestOptions{}, "", "", errors.New("Radarr has no valid root folder") + } + profiles, err := s.radarr.QualityProfiles(ctx) + if err != nil { + return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr quality profiles") + } + profileID := policy.QualityProfileID + if profileID == 0 { + for _, profile := range profiles { + if is720pProfile(profile.Name) { + profileID = profile.ID + break + } + } + if profileID == 0 { + return radarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Radarr has no 720p profile") + } + policy.QualityProfileID = profileID + if err := s.store.SetRadarrRequestPolicy(ctx, policy); err != nil { + return radarr.RequestOptions{}, "", "", errors.New("could not save the default Radarr request quality profile") + } + } + for _, profile := range profiles { + if profile.ID == profileID { + return radarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil + } + } + return radarr.RequestOptions{}, "", "", errors.New("the configured Radarr request quality profile no longer exists") +} + +func (s *Server) logRadarrRequest( + ctx context.Context, sess store.Session, req requestPayload, movie radarr.Movie, movieID int, + outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error, +) { + fields := []any{ + "user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID, + "title", clientLogValue(movie.Title), "tmdb_id", movie.TMDBID, + "radarr_movie_id", movieID, "quality_profile", profileName, + "quality_profile_id", profileID, "monitoring_strategy", "movie", + "root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome, + } + if err != nil { + fields = append(fields, "radarr_result", err.Error()) + s.loggerFor(ctx).Warn("Radarr movie request", fields...) + return + } + fields = append(fields, "radarr_result", "created") + s.loggerFor(ctx).Info("Radarr movie request", fields...) +} + +func (s *Server) publishRadarrRequestConfigurationProblem(ctx context.Context, err error) { + s.publishAdmin(ctx, adminevents.Event{ + Type: "radarr.request_configuration", Severity: adminevents.SeverityError, + Title: "Radarr movie requests need attention", Summary: err.Error(), Actor: "memby-server", + Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}), + }) +} diff --git a/server/internal/api/requests_list.go b/server/internal/api/requests_list.go index 9edb0ae..fe46053 100644 --- a/server/internal/api/requests_list.go +++ b/server/internal/api/requests_list.go @@ -88,7 +88,7 @@ func (s *Server) decorateRequests( ) now := time.Now() - if len(movieIDs) > 0 && s.radarr != nil { + if len(movieIDs) > 0 && s.radarrEnabled(ctx) { wg.Add(1) go func() { defer wg.Done() @@ -111,7 +111,7 @@ func (s *Server) decorateRequests( } }() } - if len(seriesIDs) > 0 && s.sonarr != nil { + if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) { wg.Add(1) go func() { defer wg.Done() diff --git a/server/internal/api/sonarr.go b/server/internal/api/sonarr.go index b8972fe..698ca55 100644 --- a/server/internal/api/sonarr.go +++ b/server/internal/api/sonarr.go @@ -38,7 +38,7 @@ const sonarrScheduleDays = 5 // Every failure degrades to asking Sonarr directly: a cache that is down must cost latency, // never the answer. func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) { - if s.sonarr == nil { + if !s.sonarrEnabled(ctx) { return nil, fmt.Errorf("sonarr: not configured") } if series := s.cachedSonarrSeries(ctx); series != nil { @@ -95,7 +95,7 @@ type prerollScheduleEntry struct { } func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) { - if s.sonarr == nil { + if !s.sonarrEnabled(r.Context()) { writeJSON(w, http.StatusOK, emptyPrerollSchedule()) return } @@ -340,7 +340,7 @@ func (index seriesIndex) logo(title string, year int) (string, string) { } func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) { - if s.sonarr == nil { + if !s.sonarrEnabled(ctx) { return nil, nil } location := s.cfg.SonarrLocation diff --git a/server/internal/api/sonarr_status.go b/server/internal/api/sonarr_status.go index 5d33e71..a1a1205 100644 --- a/server/internal/api/sonarr_status.go +++ b/server/internal/api/sonarr_status.go @@ -15,7 +15,7 @@ import ( // daily. History stores changes rather than identical daily snapshots: it still records // the complete lifecycle while making an active-to-cancelled transition unambiguous. func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) { - if s.sonarr == nil || interval <= 0 { + if !s.sonarrEnabled(ctx) || interval <= 0 { return } scan := func() { diff --git a/server/internal/logging/console.go b/server/internal/logging/console.go index 5f6b6d4..9b71244 100644 --- a/server/internal/logging/console.go +++ b/server/internal/logging/console.go @@ -59,7 +59,7 @@ func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error { var line strings.Builder line.WriteString(record.Time.UTC().Format(time.DateTime)) line.WriteByte(' ') - line.WriteString(pad(record.Level.String(), levelWidth)) + line.WriteString(pad(LevelName(record.Level), levelWidth)) line.WriteByte(' ') line.WriteString(pad(record.Message, messageWidth)) for _, f := range fields { @@ -103,7 +103,7 @@ func appendField(target []field, groups []string, attr slog.Attr) []field { return target } key := strings.Join(append(append([]string{}, groups...), attr.Key), ".") - return append(target, field{key: key, value: attributeValue(attr.Value)}) + return append(target, field{key: key, value: safeAttribute(key, attributeValue(attr.Value))}) } // fieldRank puts the fields that identify *who and where* first, in the same order on @@ -111,15 +111,17 @@ func appendField(target []field, groups []string, attr slog.Attr) []field { // Anything unranked keeps the order the caller wrote it in, which is usually the order // that reads best for that particular event. var fieldRank = map[string]int{ - "component": 1, - "user": 2, - "device": 3, - "client": 4, - "protocol": 5, - "method": 6, - "path": 7, - "status": 8, - "duration": 9, + "component": 1, + "user": 2, + "device": 3, + "client": 4, + "correlation": 5, + "play_session_id": 6, + "protocol": 7, + "method": 8, + "path": 9, + "status": 10, + "duration": 11, // Constant per process, so it belongs at the end of the line rather than in front // of the fields that differ between events. "version": 900, diff --git a/server/internal/logging/logging.go b/server/internal/logging/logging.go index e7b14b7..461201e 100644 --- a/server/internal/logging/logging.go +++ b/server/internal/logging/logging.go @@ -7,6 +7,7 @@ import ( "encoding/json" "io" "log/slog" + "net/url" "os" "path/filepath" "strconv" @@ -16,11 +17,25 @@ import ( "time" ) +// LevelTrace is deliberately below DEBUG. It is disabled unless MEMBY_LOG_LEVEL=TRACE, +// so tight diagnostic loops can be instrumented without making ordinary DEBUG unusable. +const LevelTrace slog.Level = slog.LevelDebug - 4 + +// LevelName presents custom levels consistently in every configured output format. +func LevelName(level slog.Level) string { + if level == LevelTrace { + return "TRACE" + } + return level.String() +} + // ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid // values. Keeping this forgiving prevents a typo in Docker configuration from stopping // the gateway. func ParseLevel(value string) slog.Level { switch strings.ToUpper(strings.TrimSpace(value)) { + case "TRACE": + return LevelTrace case "DEBUG": return slog.LevelDebug case "WARN", "WARNING": @@ -128,6 +143,11 @@ func NewBuffered( if attr.Key == slog.TimeKey { return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339)) } + if attr.Key == slog.LevelKey { + if level, ok := attr.Value.Any().(slog.Level); ok { + return slog.String(slog.LevelKey, LevelName(level)) + } + } return attr }, } @@ -233,7 +253,7 @@ func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error { }) h.buffer.append(Event{ OccurredAt: record.Time.UTC(), - Level: record.Level.String(), + Level: LevelName(record.Level), Message: record.Message, Attributes: attributes, }) @@ -266,7 +286,35 @@ func addAttribute(target map[string]string, groups []string, attr slog.Attr) { } return } - target[key] = attributeValue(attr.Value) + target[key] = safeAttribute(key, attributeValue(attr.Value)) +} + +// safeAttribute is the final guard before a structured record reaches the browser-visible +// buffer and optional on-disk history. Call sites should never log credentials, but this +// makes one accidental token-bearing URL or header non-disclosive by construction. +func safeAttribute(key, value string) string { + lower := strings.ToLower(key) + if strings.Contains(lower, "password") || strings.Contains(lower, "token") || + strings.Contains(lower, "secret") || strings.Contains(lower, "cookie") || + strings.Contains(lower, "authorization") || strings.Contains(lower, "api_key") || strings.Contains(lower, "apikey") { + return "[redacted]" + } + if parsed, err := url.Parse(value); err == nil && parsed.RawQuery != "" { + query := parsed.Query() + changed := false + for key := range query { + lowerKey := strings.ToLower(key) + if strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "key") || strings.Contains(lowerKey, "auth") || lowerKey == "t" { + query.Set(key, "[redacted]") + changed = true + } + } + if changed { + parsed.RawQuery = query.Encode() + return parsed.String() + } + } + return value } func (b *Buffer) append(event Event) { diff --git a/server/internal/logging/logging_test.go b/server/internal/logging/logging_test.go index ded5f78..593bc02 100644 --- a/server/internal/logging/logging_test.go +++ b/server/internal/logging/logging_test.go @@ -63,6 +63,29 @@ func TestConsoleQuotesOnlyAmbiguousValues(t *testing.T) { } } +func TestLoggingRedactsSensitiveAttributesAndURLs(t *testing.T) { + var output bytes.Buffer + logger, buffer := NewBuffered(&output, LevelTrace, 10, FormatConsole) + logger.Log(nil, LevelTrace, "diagnostic request", + "authorization", "Bearer private-token", + "url", "https://emby.example/stream?api_key=private-key&quality=720p", + ) + + line := output.String() + if strings.Contains(line, "private-token") || strings.Contains(line, "private-key") { + t.Fatalf("console leaked a secret: %s", line) + } + if !strings.Contains(line, "TRACE") { + t.Fatalf("console did not render TRACE: %s", line) + } + page := buffer.Events(0, 1) + if len(page.Events) != 1 || page.Events[0].Level != "TRACE" || + page.Events[0].Attributes["authorization"] != "[redacted]" || + strings.Contains(page.Events[0].Attributes["url"], "private-key") { + t.Fatalf("buffer leaked or mislabelled diagnostic event: %+v", page.Events) + } +} + func TestParseFormat(t *testing.T) { tests := map[string]Format{ "": FormatConsole, @@ -144,6 +167,7 @@ func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) { func TestParseLevel(t *testing.T) { tests := map[string]slog.Level{ "": slog.LevelInfo, + "trace": LevelTrace, "debug": slog.LevelDebug, "WARNING": slog.LevelWarn, "error": slog.LevelError, diff --git a/server/internal/radarr/client.go b/server/internal/radarr/client.go index 13f6d17..76e6c62 100644 --- a/server/internal/radarr/client.go +++ b/server/internal/radarr/client.go @@ -58,7 +58,31 @@ type RootFolder struct { } type QualityProfile struct { - ID int `json:"id"` + ID int `json:"id"` + Name string `json:"name"` +} + +// RequestOptions are Memby's deliberate movie-request policy. Radarr defaults are never +// allowed to choose a profile or initiate a search on Memby's behalf. +type RequestOptions struct { + QualityProfileID int + SearchImmediately bool +} + +func (c *Client) RootFolders(ctx context.Context) ([]RootFolder, error) { + var roots []RootFolder + if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { + return nil, err + } + return roots, nil +} + +func (c *Client) QualityProfiles(ctx context.Context) ([]QualityProfile, error) { + var profiles []QualityProfile + if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil { + return nil, err + } + return profiles, nil } type APIError struct { @@ -116,29 +140,22 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) { return movies, nil } -// AddRequested adds a title, monitors it and asks Radarr to search for it immediately. -// A request that merely creates an unmonitored catalogue row never reaches a downloader, -// which is indistinguishable from a broken button to the viewer who made it. -func (c *Client) AddRequested(ctx context.Context, movie Movie) (Movie, error) { - var roots []RootFolder - if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil { - return Movie{}, err +// AddRequested adds a monitored movie using the supplied request policy. +func (c *Client) AddRequested(ctx context.Context, movie Movie, rootFolder string, options RequestOptions) (Movie, error) { + if strings.TrimSpace(rootFolder) == "" { + return Movie{}, fmt.Errorf("radarr: request root folder is required") } - var profiles []QualityProfile - if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil { - return Movie{}, err - } - if len(roots) == 0 || len(profiles) == 0 { - return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured") + if options.QualityProfileID <= 0 { + return Movie{}, fmt.Errorf("radarr: request quality profile is required") } movie.ID = 0 - movie.RootFolderPath = roots[0].Path - movie.QualityProfileID = profiles[0].ID + movie.RootFolderPath = rootFolder + movie.QualityProfileID = options.QualityProfileID movie.Monitored = true body := struct { Movie AddOptions map[string]bool `json:"addOptions"` - }{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}} + }{Movie: movie, AddOptions: map[string]bool{"searchForMovie": options.SearchImmediately}} var added Movie if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil { return Movie{}, err diff --git a/server/internal/radarr/client_test.go b/server/internal/radarr/client_test.go index afe0c5d..a32e0e4 100644 --- a/server/internal/radarr/client_test.go +++ b/server/internal/radarr/client_test.go @@ -48,13 +48,9 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) { } } -func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) { +func TestAddRequestedUsesExplicitPolicyWithoutSearching(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/v3/rootfolder": - _, _ = w.Write([]byte(`[{"path":"/movies"}]`)) - case "/api/v3/qualityprofile": - _, _ = w.Write([]byte(`[{"id":4}]`)) case "/api/v3/movie": var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -65,8 +61,8 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) { t.Errorf("unexpected add body: %#v", body) } options := body["addOptions"].(map[string]any) - if options["searchForMovie"] != true { - t.Errorf("movie search was not enabled: %#v", body) + if options["searchForMovie"] != false { + t.Errorf("movie search was unexpectedly enabled: %#v", body) } _, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`)) default: @@ -76,7 +72,7 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) { defer upstream.Close() added, err := New(upstream.URL, "secret", time.Second).AddRequested( - context.Background(), Movie{TMDBID: 22, Title: "Arrival"}, + context.Background(), Movie{TMDBID: 22, Title: "Arrival"}, "/movies", RequestOptions{QualityProfileID: 4}, ) if err != nil { t.Fatal(err) diff --git a/server/internal/store/settings.go b/server/internal/store/settings.go index 3cd7c84..3454eee 100644 --- a/server/internal/store/settings.go +++ b/server/internal/store/settings.go @@ -22,6 +22,55 @@ const RequestPolicyKey = "request_policy" // may ask; this policy answers the safe, household-wide way a TV request is created. const SonarrRequestPolicyKey = "sonarr_request_policy" +const RadarrRequestPolicyKey = "radarr_request_policy" + +const ArrIntegrationPolicyKey = "arr_integration_policy" + +// ArrIntegrationPolicy lets the operator stop either *arr integration without removing +// credentials or request policy. Both start enabled for existing households. +type ArrIntegrationPolicy struct { + SonarrEnabled bool `json:"sonarrEnabled"` + RadarrEnabled bool `json:"radarrEnabled"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func DefaultArrIntegrationPolicy() ArrIntegrationPolicy { + return ArrIntegrationPolicy{SonarrEnabled: true, RadarrEnabled: true} +} + +func (s *Store) ArrIntegrationPolicy(ctx context.Context) (ArrIntegrationPolicy, error) { + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, ArrIntegrationPolicyKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return DefaultArrIntegrationPolicy(), nil + } + if err != nil { + return DefaultArrIntegrationPolicy(), fmt.Errorf("store: read arr integration policy: %w", err) + } + var policy ArrIntegrationPolicy + if err := json.Unmarshal(raw, &policy); err != nil { + return DefaultArrIntegrationPolicy(), fmt.Errorf("store: decode arr integration policy: %w", err) + } + return policy, nil +} + +func (s *Store) SetArrIntegrationPolicy(ctx context.Context, policy ArrIntegrationPolicy) error { + policy.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(policy) + if err != nil { + return 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()`, + ArrIntegrationPolicyKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write arr integration policy: %w", err) + } + return nil +} + // PlaybackPolicyKey controls presentation behaviour that should be adjustable without // shipping a new TV build. const PlaybackPolicyKey = "playback_policy" @@ -433,6 +482,55 @@ func (s *Store) SetSonarrRequestPolicy(ctx context.Context, policy SonarrRequest return nil } +// RadarrRequestPolicy is the movie equivalent of SonarrRequestPolicy. It persists the +// stable profile id so a renamed or removed profile becomes a safe configuration error. +type RadarrRequestPolicy struct { + QualityProfileID int `json:"qualityProfileId"` + SearchImmediately bool `json:"searchImmediately"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func DefaultRadarrRequestPolicy() RadarrRequestPolicy { return RadarrRequestPolicy{} } + +func (s *Store) RadarrRequestPolicy(ctx context.Context) (RadarrRequestPolicy, error) { + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RadarrRequestPolicyKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return DefaultRadarrRequestPolicy(), nil + } + if err != nil { + return DefaultRadarrRequestPolicy(), fmt.Errorf("store: read Radarr request policy: %w", err) + } + var policy RadarrRequestPolicy + if err := json.Unmarshal(raw, &policy); err != nil { + return DefaultRadarrRequestPolicy(), fmt.Errorf("store: decode Radarr request policy: %w", err) + } + if policy.QualityProfileID < 0 { + policy.QualityProfileID = 0 + } + return policy, nil +} + +func (s *Store) SetRadarrRequestPolicy(ctx context.Context, policy RadarrRequestPolicy) error { + if policy.QualityProfileID <= 0 { + return fmt.Errorf("store: Radarr request quality profile is required") + } + policy.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(policy) + if err != nil { + return 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()`, + RadarrRequestPolicyKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write Radarr request policy: %w", err) + } + return nil +} + func (p RequestPolicy) Allows(userID string) bool { for _, allowed := range p.AllowedUserIDs { if allowed == userID {