From b9374baaf1d4ab0af00f7158d6a8e032ddc0209b Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 16 Aug 2026 12:13:51 +1200 Subject: [PATCH] 0.2.68 --- .env.example | 6 +- CHANGELOG.md | 17 +- CLAUDE.md | 117 ++- admin-ui/dist/assets/index-B-5op1DD.js | 12 - admin-ui/dist/assets/index-C4RyKtIU.js | 12 + admin-ui/dist/assets/index-DSlxKU2t.css | 1 - admin-ui/dist/assets/index-Dyz7s7hT.css | 1 + admin-ui/dist/index.html | 4 +- admin-ui/src/App.tsx | 2 + admin-ui/src/api/types.ts | 48 ++ admin-ui/src/components/Layout.tsx | 65 +- admin-ui/src/lib/gateway.tsx | 4 +- admin-ui/src/nav.ts | 8 + admin-ui/src/pages/Credits.tsx | 180 +++++ admin-ui/src/styles.css | 205 +++++- app/build.gradle.kts | 2 +- .../memby/ui/DetailPageComponents.kt | 696 +++++++++++++----- .../memby/ui/EpisodeDetailsOverlay.kt | 123 ++-- .../ponzischeme89/memby/ui/MainActivity.kt | 47 ++ .../memby/ui/MediaDetailsOverlay.kt | 21 +- .../memby/ui/SeriesDetailsOverlay.kt | 31 +- .../ponzischeme89/memby/ui/detail/CastGrid.kt | 16 +- .../memby/ui/detail/DetailCardFocus.kt | 44 ++ .../memby/ui/detail/DetailHeroPhases.kt | 153 ++++ .../memby/ui/DetailHeroPhaseTest.kt | 162 ++++ .../memby/ui/EpisodeDetailScreenshotTest.kt | 51 ++ server/cmd/memby-server/main.go | 11 + server/internal/api/admin.go | 7 + server/internal/api/admin_access_test.go | 4 + server/internal/api/admin_credits.go | 141 ++++ server/internal/api/installer_auth.go | 52 +- server/internal/api/playback.go | 57 +- server/internal/buildinfo/VERSION | 2 +- server/internal/config/config.go | 9 +- server/internal/credits/README.md | 20 +- server/internal/credits/candidates.go | 45 ++ server/internal/credits/credits.go | 32 + server/internal/credits/postgres.go | 17 + server/internal/credits/queue.go | 19 + server/internal/credits/queue_test.go | 14 + server/internal/credits/service.go | 98 ++- server/internal/credits/service_test.go | 59 ++ server/internal/credits/tracearr.go | 19 +- server/internal/recommend/magic.go | 306 ++++++++ server/internal/recommend/profile.go | 35 +- server/internal/store/credits.go | 156 +++- server/internal/store/schema.sql | 26 + 47 files changed, 2793 insertions(+), 364 deletions(-) delete mode 100644 admin-ui/dist/assets/index-B-5op1DD.js create mode 100644 admin-ui/dist/assets/index-C4RyKtIU.js delete mode 100644 admin-ui/dist/assets/index-DSlxKU2t.css create mode 100644 admin-ui/dist/assets/index-Dyz7s7hT.css create mode 100644 admin-ui/src/pages/Credits.tsx create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailHeroPhases.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/DetailHeroPhaseTest.kt create mode 100644 server/internal/api/admin_credits.go create mode 100644 server/internal/recommend/magic.go diff --git a/.env.example b/.env.example index 3f98ed9..19eb2d3 100644 --- a/.env.example +++ b/.env.example @@ -135,7 +135,8 @@ MEMBY_TRACEARR_FULL_INTERVAL=24h # Credits detection: where an episode's closing credits begin, discovered for the few # episodes the household is actually about to watch. Tracearr is what says which those are, # so this never scans the library — it prepares a sliding window a few episodes ahead of -# each viewer and stops. An episode is scanned once per media version and never again. +# each viewer and stops. An accepted marker is scanned once per media version; inconclusive +# attempts observe the retry delay configured in the admin console. # # It is the only thing in the gateway that reads media bytes, which is why it is off by # default. With ffmpeg absent it still runs, writing markers from where viewers actually @@ -153,7 +154,8 @@ MEMBY_CREDITS_FFMPEG= MEMBY_CREDITS_PREFETCH_EPISODES=3 MEMBY_CREDITS_MAX_PREFETCH=5 # Pending candidates. Past this, low-priority speculation is discarded rather than queued: -# a backlog of episodes nobody reached has stopped describing demand. +# a backlog of episodes nobody reached has stopped describing demand. These three values +# are first-run defaults; Admin Console → Credits detection can override them durably. MEMBY_CREDITS_QUEUE_LIMIT=20 MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e36f4..a2cdc5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,25 +1,22 @@ ## 0.2.67 — 2026-08-16 -- Improved: Movie and show pages. The header now moves out of the way as you go down into the tabs, so the content gets the whole screen instead of a strip at the bottom. -- Added: Cast is its own tab, as a full grid of everybody in it rather than six faces in a corner. It remembers which face you were on. -- Added: An Extras tab for trailers, featurettes, deleted scenes and interviews, on the titles that have them. -- Added: A Details tab for release date, studios, directors, writers, countries, languages and the file's own particulars. -- Improved: Tabs only appear when there is something in them, and no longer shuffle sideways while a page is still loading. +- Improved: Movie and show pages. ## 0.2.66 - 2026-08-15 -- Improved: Design of skip end credits display. +- Improved: Skip end credits. ## 0.2.65 - 2026-08-15 - Added: Server now detects end credits more accurately. -- Fixed: Security improvements to API endpoints. ## 0.2.64 — 2026-08-14 -- Bug fixes & general improvements. +- Improved: General app bug fixes & polish. +- Fixed: Backend improvements. ## 0.2.63 — 2026-08-14 -- Bug fixes & general improvements. +- Improved: General app bug fixes & polish. +- Improved: Backend. ## 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. +- Fixed: Rolled back version 0.2.60. ## 0.2.60 — 2026-08-12 - Added: Voice search on the Requests page, beside the query, the same as Search. diff --git a/CLAUDE.md b/CLAUDE.md index 2a62f05..1c37dc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1936,7 +1936,16 @@ continuously while somebody is merely browsing, so: home hero) and `MembyChoiceChip`. There were three: this one, a hand-rolled copy in the hero with the same look and different metrics, and raw `androidx.tv.material3.Button`s with glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours -nothing around them uses. +nothing around them uses. `MembyArtworkPlayCue` is the fourth member and the same rule: the +circular Play mark over a focused artwork card, on the launcher's posters *and* on a detail +page's episode rows, which used to draw their own black disc at their own diameter. + +**And one card-focus language on the detail pages**, `Modifier.detailCardFocus` in +`ui/detail/DetailCardFocus.kt`. The cast grid, the "More like this" posters and the Extras +thumbnails had three copies of the same lift-and-grow at 1.06, 1.06 and 1.05 over 110ms, +100ms and 100ms; nobody chose three figures, they were written on three days. The animated +value is read only inside the `graphicsLayer` block, so travelling a grid redraws two cards +rather than recomposing every card in it — which is why it is a modifier and not a wrapper. **"Close Memby?" is the one full-stop dialog**, `ui/ExitConfirmation.kt`, drawn when the viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over* @@ -2104,8 +2113,13 @@ midnight, which is why the zone offset is a parameter to the pure `localEpochDay arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand. **Detail pages** are one editorial layout shared by movies and series: `DetailPageScaffold` -in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt` and -`ui/detail/DetailTabs.kt`. It is a full-bleed cinematic hero — backdrop under two scrims, +in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`, +`ui/detail/DetailTabs.kt` and `ui/detail/DetailHeroPhases.kt`. Movie, series and episode are +three sets of parameters to that one scaffold, never three layouts — the shared pieces are +`DetailHeroActions` (so a Continue Watching page cannot grow its own Play button metrics), +`DetailIdentity`, `DetailStripFrame` and `Modifier.detailCardFocus`, and anything that has to +differ between them is a scaffold parameter so the difference is stated in one place. It is a +full-bleed cinematic hero — backdrop under two scrims, logo or title, `heroFacts` line (year · length · certificate) with the score and format badges trailing it, genres, three lines of synopsis, one recommendation reason, then Play and the circular secondary actions — with an uppercase tab strip on a hairline rule anchored @@ -2122,15 +2136,71 @@ because a show's synopsis is already in the hero and what a show is *for* is its - **The hero collapses instead.** `detailHeroCollapsed(zone)` is the whole rule and it is pure and tested: whole while focus is on Play, out of the way the moment focus is below it. A single `animateFloatAsState` drives it and is read **only inside layout and draw - lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for the cross-fade - — so a collapse costs a measure pass rather than recomposing the page sixty times a second. - Two details hold the promise that no focused thing ever resizes: the hero is measured at - its *full* height and slid upward under a clip, so nothing inside it re-wraps; and the pane - takes `weight(1f)`, so it simply receives what the hero gives up. -- **What is left is `DetailCollapsedHeader`** — the title and the fact line and nothing else. - Not a shrunken hero: the synopsis, the ratings, the reason and the actions all answer "is - this worth watching", which somebody down in the Cast grid has already answered. What - remains is only what stops the page becoming anonymous. + lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for everything + else — so a collapse costs a measure pass rather than recomposing the page sixty times a + second. Two details hold the promise that no focused thing ever resizes: the hero is + measured at its *full* height and slid upward under a clip, so nothing inside it re-wraps; + and the pane takes `weight(1f)`, so it simply receives what the hero gives up. +- **One number drives the whole transformation**, and what each part of it means is + `ui/detail/DetailHeroPhases.kt`. The height, the backdrop's opacity, the deepening wash, + the supporting text, the action row and the pinned header are **bands of that one value** + rather than animations of their own — which is what makes the collapse read as one + movement, and what stops the parts drifting out of step when it is interrupted half way. + `DetailHeroPhaseTest` pins the *ordering* rather than the figures: the supporting half is + always at least as far gone as the primary half (that is progressive disclosure, stated as + an assertion), the two headings are never both on screen, and the plate always leads the + pinned header in. It is a **critically damped spring**, not a tween: a held D-pad produces + a press every few frames, and a spring retargets from wherever it is rather than restarting + a duration — and it can never overshoot past 1, which on a value driving opacities would be + a flicker. +- **The backdrop recedes; it never disappears.** `heroArtworkAlpha` takes it down as the + content takes over and the wash over it deepens as a gradient, so the picture settles back + rather than a sheet being drawn across it. Behind the pinned header is a soft plate + (`pinnedScrimAlpha`) occupying exactly the band the header lands in and fading from nothing + at its top edge — a **gradient, never a bar**: the point is a perceptible separation + between a fixed header and scrolling content, and a solid block reads as a phone toolbar. +- **What is left is `DetailCollapsedHeader`** — the title, the fact line, and on an episode + the show and `S03E04`. Not a shrunken hero: the synopsis, the ratings, the reason and the + actions all answer "is this worth watching", which somebody down in the Cast grid has + already answered. What remains is only what stops the page becoming anonymous. The compact + eyebrow is its own parameter (`pinnedEyebrow`) because "SEASON 3 · EPISODE 4" spelled out + pushes the fact line off the end of a one-line header. +- **The hero is a budget, and the reading column is what pays.** The expanded content is + anchored to the bottom of its band and grows upward, so `DetailHeroMetrics.TopInset` is the + ceiling it may not cross — and with a ceiling, something has to give. What gives is the + **supporting block** (ratings, genres, synopsis, pace, reason), which is the Column's one + `weight(1f, fill = false)` child: weighted children are measured from what the unweighted + ones left over, so a two-line title costs prose rather than costing the primary action its + shape. Getting that inversion wrong is precisely the 0.2.67 regression — an episode page + reached from Continue Watching carries more than any other variant, and the Play button, + being last in the column, was handed whatever height was left and rendered as a squeezed + sliver. It is the same inversion the home hero already makes, and it is now asserted: + `EpisodeDetailScreenshotTest` renders the most crowded hero the app can produce and fails + the build if the primary action is under its natural height. +- **Prose gives way a whole line at a time.** `Modifier.wholeLines` reports a height rounded + down to the last complete line, with the clip *outside* it — a `Text` handed less room + still draws every line it was asked for, and without the clip the dropped lines painted + over whatever the column placed underneath. Two lines instead of three reads as nothing at + all; prose sliced through the middle of its letters reads as a rendering fault. + `DetailHeroMetrics.SynopsisLines` is **two**, not three: the whole description is one press + away in Overview, and the third line was the difference between the movie hero fitting its + band and overflowing it. +- **The identity block reserves its height only where a logo could actually appear.** + Deciding whether a logo is legible means fetching and decoding it, so every page begins on + the text fallback and changes its mind a moment later — a swap that used to move everything + under it on the opening frame. `logoUrl != null` is known synchronously from the image + tags, so a title Emby holds no logo for is drawn at its natural height and gives the space + to the synopsis. Very wide and very tall logos are both a letterboxed picture inside a + fixed box: a logo can never change the page's shape. +- **The ratings strip is the one place it does not reserve.** Most of a library has no + scores — an episode is rated as its series, a household with no MDBList key has none at all + — and 42dp held open for them was 42dp taken off the synopsis on every one of those pages. + It sits inside the flexible block, so a strip arriving late costs a line of prose rather + than moving anything the viewer is aiming at. +- **The circular actions name themselves.** A heart, a tick, a bookmark and a film reel are + guesses at three metres, so the focused one's description is printed on a **reserved** line + under the row — reserved, because a line that appeared when focus reached the second button + would move the whole hero on every press of Right. - **A pane fits its slot, or is a grid.** Prose panes (Overview, Details) do not scroll — a page that scrolls *and* has tabs gives the D-pad two meanings for Down — and now have roughly three times the room they had, since `detailPaneHeight(viewportHeight)` is what the @@ -2145,6 +2215,15 @@ because a show's synopsis is already in the hero and what a show is *for* is its one tab while a different one stays open would need a second press to mean anything and would show content that contradicts the highlight. A *click* reports itself as arriving in the strip too, or a mouse or a test could change the pane and leave the hero over it. + They are still **marked separately**, and that is not a contradiction: the accent underline + is *selection* — which pane is open — and the plate behind the label is *focus* — where the + remote is. One causes the other while the viewer is in the strip, and they come apart the + moment they press Down, which is the case the distinction exists for. The season scroller + on an episode page marks them the same two ways for the same reason. +- **Both bands wear `DetailStripFrame`** — the plate, the gutters, the hairline and the + chevron. The tab strip and the season scroller define the same fold, and two hand-written + copies of it is exactly how the two pages came to sit their content a couple of pixels + apart. - **The strip is content-driven, and frozen once the viewer is inside it.** `detailTabs` is pure over a `DetailTabAvailability`, and `rememberDetailTabs` recomputes it only while focus is still in the hero. That is the seam between two real failures: a tab leading to an @@ -2169,7 +2248,19 @@ because a show's synopsis is already in the hero and what a show is *for* is its destroys `rememberSaveable` with it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live) and then restores the band, once, only if that band has something placed to land on. Deliberately not persisted: a TV switched on the next morning - should open a show where the *show* is up to. + should open a show where the *show* is up to. The band is reported on the way *in* to it, + never on every focus move inside it — the pane's `hasFocus` fires for each card a viewer + walks past, and each of those was a write into the store, on the one press that must feel + free. +- **Returning from the player comes back to the page it was launched from.** + `MainActivity` holds a `DetailsReturn` — the item, the trail and the airing notice — across + the playback and reopens on `LifecycleResumeEffect`. Deliberately a *reopen* rather than + leaving the page composed under the player: the page has to tell the viewer the truth, and + the episode they just finished is now watched, the film is now part-way through, and the + next episode is a different one, so the item is re-requested on the way in. Reopening also + runs the same restore path a Back press through the trail already uses, which is what + brings back the tab, the season, the grid offset and the band that held focus — rather than + depending on Compose having held focus across another activity's window. - Every `focusProperties { up/down/left/right = … }` target must be attached **on the current frame**. Season chips and episode cards do not exist while the episode request is in flight, on a one-season show, or on any tab but Episodes — pointing at their diff --git a/admin-ui/dist/assets/index-B-5op1DD.js b/admin-ui/dist/assets/index-B-5op1DD.js deleted file mode 100644 index 4a9669a..0000000 --- a/admin-ui/dist/assets/index-B-5op1DD.js +++ /dev/null @@ -1,12 +0,0 @@ -import{r as p,a as Ss,u as qe,L as ee,b as Fe,m as $e,O as Cs,N as Ms,c as Re,B as Rs,R as Es,d as U,e as As}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))t(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const i of c.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&t(i)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function t(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();var os={exports:{}},Ee={};/** - * @license React - * react-jsx-runtime.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Is=p,$s=Symbol.for("react.element"),Ts=Symbol.for("react.fragment"),Ls=Object.prototype.hasOwnProperty,Ds=Is.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,qs={key:!0,ref:!0,__self:!0,__source:!0};function cs(s,n,r){var t,l={},c=null,i=null;r!==void 0&&(c=""+r),n.key!==void 0&&(c=""+n.key),n.ref!==void 0&&(i=n.ref);for(t in n)Ls.call(n,t)&&!qs.hasOwnProperty(t)&&(l[t]=n[t]);if(s&&s.defaultProps)for(t in n=s.defaultProps,n)l[t]===void 0&&(l[t]=n[t]);return{$$typeof:$s,type:s,key:c,ref:i,props:l,_owner:Ds.current}}Ee.Fragment=Ts;Ee.jsx=cs;Ee.jsxs=cs;os.exports=Ee;var e=os.exports,ds,Be=Ss;ds=Be.createRoot,Be.hydrateRoot;const hs={overview:"M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",library:"M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4",people:"M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2",person:"M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",tv:"M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5",sliders:"M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8",pulse:"M3 12h3.5L9 19l5-14 2.5 7H21",chart:"M4 19V9m5 10V5m5 14v-7m5 7V3",chip:"M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22",database:"M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6",download:"M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15",upload:"M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",sync:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",search:"M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",star:"m12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z",sparkle:"m10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z",bell:"M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0",shield:"m12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6",wrench:"m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",play:"M8 5.2v13.6L19 12 8 5.2ZM4 5v14",list:"M4 7h16M4 12h16M4 17h10",inbox:"M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",history:"M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8",check:"m5 12.5 4.5 4.5L19 7.5",alert:"M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z",power:"M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0",key:"M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z",captions:"M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",journey:"M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",plug:"M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3",calendar:"M4 6h16v15H4zM8 3v5M16 3v5M4 11h16",trash:"M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5",plus:"M12 5v14M5 12h14",close:"M6 6l12 12M18 6 6 18",caret:"m6 9 6 6 6-6",external:"M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10",refresh:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",filter:"M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z",menu:"M4 7h16M4 12h16M4 17h16",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z",logout:"M15 17l5-5-5-5M20 12H9M12 4H5v16h7"};function ne({name:s,className:n}){const r=hs[s];return r?e.jsx("svg",{className:n??"ico",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",preserveAspectRatio:"xMidYMid meet","aria-hidden":"true",children:e.jsx("path",{d:r})}):null}function Ae({name:s,tone:n}){return hs[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(ne,{name:s})}):null}const ve=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],Fs=ve.flatMap(s=>s.items),Ps=ve.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Ve extends Error{constructor(n,r){super(n),this.status=r,this.name="ApiError"}}const Os=5*60*1e3;let us=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{us=Date.now()},{passive:!0});const Bs=()=>Date.now()-us({}));throw new Ve(t.error??`Request failed (${r.status})`,r.status)}if(r.status!==204)return await r.json()}function Se(s){const n=new URLSearchParams;for(const[t,l]of Object.entries(s))l==null||l===""||l===!1||n.set(t,String(l));const r=n.toString();return r?`?${r}`:""}const F={get:s=>Ce(s),post:(s,n)=>Ce(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Ce(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Ce(s,{method:"DELETE"})},Us=3e4,ms=p.createContext(null);function Ws({children:s}){var u;const[n,r]=p.useState(),[t,l]=p.useState(!0),[c,i]=p.useState(""),[o,d]=p.useState(""),[a,v]=p.useState(!0),k=p.useRef(0),h=p.useCallback(async()=>{const b=++k.current;try{const j=await F.get("/admin/api/status");if(b!==k.current)return;r(j),l(!0),d("")}catch(j){if(b!==k.current)return;l(!1),d(j instanceof Error?j.message:String(j))}finally{b===k.current&&(i(new Date().toISOString()),v(!1))}},[]),m=p.useCallback(async b=>{var j;await F.post("/admin/api/maintenance",{enabled:b,message:((j=n==null?void 0:n.maintenance)==null?void 0:j.message)??""}),await h()},[h,(u=n==null?void 0:n.maintenance)==null?void 0:u.message]);p.useEffect(()=>{h();let b;const j=()=>{window.clearInterval(b),b=document.hidden?void 0:window.setInterval(()=>void h(),Us)},S=()=>{j(),document.hidden||h()};return j(),document.addEventListener("visibilitychange",S),()=>{window.clearInterval(b),document.removeEventListener("visibilitychange",S)}},[h]);const g=p.useMemo(()=>({status:n,version:(n==null?void 0:n.serverVersion)??"",online:t,checkedAt:c,error:o,loading:a,reload:h,setMaintenance:m}),[n,t,c,o,a,h,m]);return e.jsx(ms.Provider,{value:g,children:s})}function ie(){const s=p.useContext(ms);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function _s(s,n){const r=s.label.toLowerCase(),t=s.group.toLowerCase();return r.startsWith(n)?4:r.includes(n)?3:t.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function zs(){const s=qe(),{status:n}=ie(),[r,t]=p.useState(!1),[l,c]=p.useState(""),[i,o]=p.useState(0),d=p.useRef(null),a=p.useRef(null),v=p.useMemo(()=>{const h=l.trim().toLowerCase();return[...Ps,...((n==null?void 0:n.requestUsers)??[]).map(g=>({id:`user-${g.id}`,path:`/admin/accounts/${encodeURIComponent(g.id)}`,label:g.username||"Unnamed user",title:`User: ${g.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(g=>({id:`device-${g.deviceId}`,path:`/admin/devices/${encodeURIComponent(g.deviceId)}`,label:g.deviceName||"Unnamed device",title:`Device: ${g.deviceName||"Unnamed device"}`,intro:`${g.username||"Unknown user"} · ${g.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((g,u)=>({item:g,rank:h?_s(g,h):1,index:u})).filter(g=>g.rank>0).sort((g,u)=>u.rank-g.rank||g.index-u.index).map(({item:g,rank:u})=>({item:g,rank:u}))},[l]);p.useEffect(()=>o(0),[l]),p.useEffect(()=>{const h=m=>{var g;(g=d.current)!=null&&g.contains(m.target)||t(!1)};return document.addEventListener("pointerdown",h),()=>document.removeEventListener("pointerdown",h)},[]),p.useEffect(()=>{const h=m=>{var u,b;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const g=document.activeElement;g&&(g.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(g.tagName))||(m.preventDefault(),(u=a.current)==null||u.focus(),(b=a.current)==null||b.select())};return window.addEventListener("keydown",h),()=>window.removeEventListener("keydown",h)},[]);const k=h=>{var m;t(!1),c(""),(m=a.current)==null||m.blur(),s(h)};return e.jsxs("div",{className:"omni",ref:d,"data-open":r||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(ne,{name:"search"}),e.jsx("input",{ref:a,type:"search",value:l,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":r,onFocus:()=>t(!0),onChange:h=>{c(h.target.value),t(!0)},onKeyDown:h=>{var m;if(h.key==="Escape")c(""),t(!1),(m=a.current)==null||m.blur();else if(h.key==="ArrowDown")h.preventDefault(),o(g=>Math.min(g+1,v.length-1));else if(h.key==="ArrowUp")h.preventDefault(),o(g=>Math.max(g-1,0));else if(h.key==="Enter"){const g=v[i];if(!g)return;h.preventDefault(),k(g.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),r?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((h,m)=>e.jsxs("a",{className:m===i?"omni-item on":"omni-item",href:h.item.path,role:"option","aria-selected":m===i,onPointerEnter:()=>o(m),onClick:g=>{g.preventDefault(),k(h.item.path)},children:[h.item.icon?e.jsx(ne,{name:h.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:h.item.label}),e.jsx("small",{children:h.item.intro})]}),e.jsx("span",{className:"omni-group",children:h.item.group})]},h.item.id))}):null]})}const N=s=>(s??0).toLocaleString(),O=s=>s?new Date(s).toLocaleString():"—",Hs=s=>s?new Date(s).toLocaleTimeString():"—";function ke(s){if(!s)return"0s";if(s<1e3)return`${Math.round(s)}ms`;const n=Math.round(s/1e3);if(n<60)return`${n}s`;const r=Math.floor(n/60);return r<60?`${r}m ${n%60}s`:`${Math.floor(r/60)}h ${r%60}m`}function Te(s){if(!s||s<=0)return"on request only";if(s<60)return`every ${s}s`;const n=Math.round(s/60);if(n<60)return`every ${n} min`;const r=Math.round(n/60);return r<48?r===1?"hourly":`every ${r} hours`:`every ${Math.round(r/24)} days`}function fe(s){const n=["B","KB","MB","GB"];let r=Number(s??0),t=0;for(;r>=1024&&tString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Le=s=>`${Math.round((s??0)*100)}%`;function be(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const r=Math.floor(n/1e3);if(r<45)return"just now";const t=Math.floor(r/60);if(t<60)return`${t} min ago`;const l=Math.floor(t/60);if(l<24)return`${l}h ago`;const c=Math.floor(l/24);return c<30?`${c}d ago`:new Date(s).toLocaleDateString()}const xs=15*60*1e3,Ks=3*60*60*1e3,Ne=s=>!!s&&Date.now()-new Date(s).getTime(){var j;try{const S=await F.get(`/admin/api/notifications?limit=${We}`);r(S.events),l(S.unread),i(S.types),k.current=Math.max(k.current,((j=S.events[0])==null?void 0:j.id)??0),v("")}catch(S){v(S instanceof Error?S.message:String(S))}},[]),m=p.useCallback(j=>{k.current=Math.max(k.current,j.id),r(S=>S.some(x=>x.id===j.id)?S:[j,...S].sort((x,y)=>y.id-x.id).slice(0,We)),j.readAt||l(S=>S+1),i(S=>S.some(x=>x.type===j.type)?S.map(x=>x.type===j.type?{...x,count:x.count+1}:x):[...S,{type:j.type,count:1}])},[]);p.useEffect(()=>{h()},[h]),p.useEffect(()=>{let j=null,S,x=!1;return(()=>{x||(j=new EventSource(`/admin/api/notifications/stream?after=${k.current}`),j.addEventListener("open",()=>{d(!0),window.clearInterval(S),S=void 0}),j.addEventListener("admin",R=>{try{m(JSON.parse(R.data))}catch{}}),j.addEventListener("error",()=>{d(!1),S===void 0&&(S=window.setInterval(()=>void h(),Gs))}))})(),()=>{x=!0,j==null||j.close(),window.clearInterval(S)}},[m,h]);const g=p.useCallback(async j=>{const S=j.filter(x=>x>0);if(S.length!==0){r(x=>x.map(y=>S.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const x=await F.post("/admin/api/notifications/read",{ids:S});l(x.unread)}catch{h()}}},[h]),u=p.useCallback(async()=>{l(0),r(j=>j.map(S=>S.readAt?S:{...S,readAt:new Date().toISOString()}));try{const j=await F.post("/admin/api/notifications/read",{all:!0});l(j.unread)}catch{h()}},[h]),b=p.useMemo(()=>({events:n,unread:t,types:c,connected:o,error:a,markRead:g,markAllRead:u,reload:h}),[n,t,c,o,a,g,u,h]);return e.jsx(js.Provider,{value:b,children:s})}function Oe(){const s=p.useContext(js);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function vs(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function bs(s){return s.startsWith("auth.")?"key":s.startsWith("device.")?"tv":s.startsWith("admin.")?"shield":s.startsWith("task.")?"clock":s.startsWith("integration.")?"plug":s.startsWith("library.")?"library":s.startsWith("emby.")?"globe":s.startsWith("server.")?"power":"bell"}function _e(s){return{"auth.login":"Signed in","auth.login_failed":"Sign-in refused","auth.logout":"Signed out","device.registered":"New device","device.removed":"Device removed","device.renamed":"Device renamed","admin.sign_in":"Admin sign-in","server.started":"Server started","server.maintenance":"Maintenance","task.completed":"Task finished","task.failed":"Task failed","integration.failed":"Integration failed","integration.test":"Integration test","library.sync":"Library sync","emby.unreachable":"Emby unreachable","emby.recovered":"Emby recovered"}[s]??s.replace(/[._]/g," ")}const ze=99;function Js(){const{events:s,unread:n,connected:r,markRead:t,markAllRead:l}=Oe(),[c,i]=p.useState(!1),o=p.useRef(null);return p.useEffect(()=>{const d=v=>{var k;(k=o.current)!=null&&k.contains(v.target)||i(!1)},a=v=>{v.key==="Escape"&&i(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",a),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",a)}},[]),p.useEffect(()=>{if(!c)return;const d=s.filter(a=>!a.readAt).map(a=>a.id);d.length>0&&t(d)},[c]),e.jsxs("div",{className:"bell",ref:o,"data-open":c||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":c,onClick:()=>i(d=>!d),children:[e.jsx(ne,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>ze?`${ze}+`:n}):null]}),c?e.jsxs("div",{className:"bell-panel",children:[e.jsxs("div",{className:"bell-head",children:[e.jsx("b",{children:"Activity"}),e.jsxs("div",{className:"row tight",children:[r?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void l(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(d=>{const a=e.jsxs(e.Fragment,{children:[e.jsx(Ae,{name:bs(d.type),tone:vs(d)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:d.title||d.type}),d.summary?e.jsx("p",{children:d.summary}):null,e.jsx("time",{dateTime:d.occurredAt,children:be(d.occurredAt)})]})]});return d.link?e.jsx(ee,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>i(!1),children:a},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:a},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(ee,{to:"/admin/activity",onClick:()=>i(!1),children:"All activity"})})]}):null]})}function V({title:s,intro:n,actions:r,crumbs:t,icon:l}){var o;const c=Fe(),i=l??((o=Fs.find(d=>$e({path:d.path,end:!0},c.pathname)))==null?void 0:o.icon);return e.jsxs("header",{className:"page-head",children:[t?e.jsx("nav",{className:"crumbs",children:t}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[i?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(ne,{name:i})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),r?e.jsx("div",{className:"page-head-actions",children:r}):null]})]})}function I({title:s,intro:n,icon:r,tone:t,actions:l,footer:c,children:i}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[r?e.jsx(Ae,{name:r,tone:t}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),l?e.jsx("div",{className:"card-head-actions",children:l}):null]}):null,i,c?e.jsx("div",{className:"card-foot",children:c}):null]})}function de({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function ae({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Ae,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function C({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function Q({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function G({children:s}){return e.jsx("p",{className:"empty",children:s})}function te({columns:s,children:n}){return e.jsx("tr",{children:e.jsx("td",{colSpan:s,className:"muted",children:e.jsx("p",{className:"empty",children:n})})})}function me({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function A({children:s,onClick:n,variant:r,size:t,disabled:l,busy:c,icon:i,type:o="button",title:d}){return e.jsxs("button",{type:o,className:"","data-variant":r,"data-size":t,disabled:l||c,onClick:n,title:d,children:[c?e.jsx("span",{className:"spinner"}):i?e.jsx(ne,{name:i}):null,s]})}function q({label:s,hint:n,children:r,grow:t}){return e.jsxs("label",{className:t?"field grow":"field",children:[e.jsx("span",{children:s}),r,n?e.jsx("small",{children:n}):null]})}function W({label:s,hint:n,checked:r,onChange:t,disabled:l}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:r,disabled:l,onChange:c=>t(c.target.checked)}),e.jsx("span",{className:"switch"}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:s}),n?e.jsx("p",{children:n}):null]})]})}function Me({value:s,options:n,onChange:r}){return e.jsx("div",{className:"segments",role:"group",children:n.map(t=>e.jsx("button",{type:"button","aria-pressed":t.value===s,onClick:()=>r(t.value),children:t.label},String(t.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function gs({data:s,labelOf:n,valueOf:r,toneOf:t,title:l}){if(s.length===0)return e.jsx(G,{children:"Nothing in this window."});const c=s.map(o=>r(o)),i=Math.max(1,...c);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((o,d)=>{const a=r(o);return e.jsx("div",{className:"bar","data-tone":t==null?void 0:t(o),"data-empty":a===0||void 0,style:{height:`${Math.max(2,a/i*100)}%`},title:l?l(o):`${n(o,d)}: ${a}`},d)})}),e.jsxs("div",{className:"bars-axis",children:[e.jsx("span",{children:n(s[0],0)}),e.jsx("span",{children:n(s[s.length-1],s.length-1)})]})]})}function Ys({value:s,total:n,tone:r}){const t=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":r,children:e.jsx("div",{style:{width:`${t*100}%`}})})}function B({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(ne,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})}):null]}):null}function _({rows:s=3}){return e.jsxs("div",{className:"loading-page","aria-busy":"true","aria-label":"Loading",children:[e.jsxs("div",{className:"loading-heading",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]}),e.jsx("div",{className:"loading-tiles",children:Array.from({length:4},(n,r)=>e.jsx("span",{className:"skeleton"},r))}),Array.from({length:s},(n,r)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},r))]})}function he({title:s,body:n,confirmLabel:r="Confirm",destructive:t,busy:l,onConfirm:c,onCancel:i}){const o=p.useId(),d=p.useRef(null);return p.useEffect(()=>{var v;(v=d.current)==null||v.focus();const a=k=>{k.key==="Escape"&&i()};return document.addEventListener("keydown",a),()=>document.removeEventListener("keydown",a)},[i]),e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":o,ref:d,tabIndex:-1,children:[e.jsx("h2",{id:o,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{onClick:i,variant:"quiet",children:"Cancel"}),e.jsx(A,{onClick:c,variant:t?"danger":"primary",busy:l,children:r})]})]})})}function He({rows:s}){return e.jsx("div",{className:"kv",children:s.map(n=>e.jsxs("div",{className:"kv-row",children:[e.jsx("span",{children:n.label}),e.jsx("span",{children:n.value})]},n.label))})}function fs({tiles:s}){return e.jsx("div",{className:"tiles plain",children:s.map(n=>e.jsxs("div",{className:"tile",children:[e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function Xs({open:s,onNavigate:n}){const{unread:r}=Oe(),t=Fe(),[l,c]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),i=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};p.useEffect(()=>{const d=ve.find(a=>a.items.some(v=>$e({path:v.path,end:!0},t.pathname)));d&&c(a=>{const v={...a};return ve.forEach(k=>{k.collapsible!==!1&&(v[k.id]=k.id!==d.id)}),i(v),v})},[t.pathname]);const o=(d,a=!1)=>{c(v=>{const k={...v},h=!(v[d]??a);return ve.forEach(m=>{m.collapsible!==!1&&(k[m.id]=m.id===d?h:!0)}),i(k),k})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:ve.map(d=>{const a=d.items.filter(m=>!m.hidden);if(a.length===0)return null;const v=a.some(m=>$e({path:m.path,end:!0},t.pathname)),k=d.collapsible!==!1,h=v||!k||!(l[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&k?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":h,onClick:()=>o(d.id,d.defaultCollapsed),children:[d.label,e.jsx(ne,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,h?a.map(m=>e.jsx(Ms,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:g})=>g?"on":"","aria-current":void 0,children:({isActive:g})=>e.jsxs("span",{style:{display:"contents"},ref:u=>{const b=u==null?void 0:u.parentElement;b&&(g?b.setAttribute("aria-current","page"):b.removeAttribute("aria-current"))},children:[m.icon?e.jsx(ne,{name:m.icon}):null,m.label,m.badge==="notifications"&&r>0?e.jsx("span",{className:"rail-badge",children:r>99?"99+":r}):null]})},m.id)):null]},d.id)})})}function Qs(){var g;const{version:s,online:n,checkedAt:r,status:t,setMaintenance:l}=ie(),[c,i]=p.useState(!1),[o,d]=p.useState(!1),[a,v]=p.useState(!1),k=Fe(),h=!!((g=t==null?void 0:t.maintenance)!=null&&g.enabled),m=async()=>{if(!(o||!t)){d(!0);try{await l(!h)}finally{d(!1)}}};return p.useEffect(()=>i(!1),[k.pathname]),p.useEffect(()=>{if(!c)return;const u=document.body.style.overflow;document.body.style.overflow="hidden";const b=j=>{j.key==="Escape"&&i(!1)};return window.addEventListener("keydown",b),()=>{document.body.style.overflow=u,window.removeEventListener("keydown",b)}},[c]),e.jsxs(e.Fragment,{children:[e.jsx("a",{className:"skip",href:"#main",children:"Skip to content"}),e.jsxs("header",{className:"topbar",children:[e.jsxs("a",{className:"topbar-brand",href:"/admin",children:[e.jsx("span",{className:"brand-mark","aria-hidden":"true",children:"M"}),e.jsx("span",{className:"brand-word",children:"Memby Gateway"})]}),e.jsx("button",{type:"button",className:"rail-toggle","aria-label":"Sections","aria-expanded":c,"aria-controls":"rail",onClick:()=>i(u=>!u),children:e.jsx(ne,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(zs,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":n&&!h?"ok":"bad","aria-pressed":h,disabled:!t||o,title:h?"Bring Memby back online":"Take Memby offline",onClick:()=>h?void m():v(!0),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:h?"offline":n?"online":"not responding"}),e.jsx("span",{children:h?"click to bring back online":r?`updated ${Hs(r)}`:""})]}),e.jsx(Js,{}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsx("button",{type:"submit","data-variant":"quiet","data-size":"sm",title:"Sign out",children:e.jsx(ne,{name:"logout"})})})]})]}),c?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>i(!1)}):null,e.jsx(Xs,{open:c,onNavigate:()=>i(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Cs,{})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available.",confirmLabel:"Go offline",destructive:!0,busy:o,onConfirm:()=>{v(!1),m()},onCancel:()=>v(!1)}):null]})}const ys=p.createContext(null),en=5e3;function sn({children:s}){const[n,r]=p.useState([]),t=p.useRef(1),l=p.useCallback(d=>{r(a=>a.filter(v=>v.id!==d))},[]),c=p.useCallback((d,a="ok")=>{const v=t.current++;r(k=>[...k,{id:v,message:d,tone:a}]),window.setTimeout(()=>l(v),en)},[l]),i=p.useCallback(async(d,a)=>{try{const v=await d();return a&&c(a,"ok"),v}catch(v){c(v instanceof Error?v.message:String(v),"bad");return}},[c]),o=p.useMemo(()=>({show:c,wrap:i}),[c,i]);return e.jsxs(ys.Provider,{value:o,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(d=>e.jsxs("div",{className:"toast","data-tone":d.tone,children:[e.jsx(ne,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>l(d.id),"aria-label":"Dismiss",children:e.jsx(ne,{name:"close"})})]},d.id))})]})}function se(){const s=p.useContext(ys);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:r,enabled:t=!0}=n,[l,c]=p.useState(),[i,o]=p.useState(""),[d,a]=p.useState(t),[v,k]=p.useState(!1),h=p.useRef(0),m=p.useRef(!1),g=p.useCallback(async()=>{if(!t)return;const u=++h.current;m.current&&k(!0);try{const b=await F.get(s);if(u!==h.current)return;c(b),o(""),m.current=!0}catch(b){if(u!==h.current)return;o(b instanceof Error?b.message:String(b))}finally{u===h.current&&(a(!1),k(!1))}},[s,t]);return p.useEffect(()=>(m.current=!1,a(!0),g(),()=>{h.current+=1}),[g]),p.useEffect(()=>{if(!r||!t)return;let u;const b=()=>{window.clearInterval(u),u=document.hidden?void 0:window.setInterval(()=>void g(),r)},j=()=>{b(),document.hidden||g()};return b(),document.addEventListener("visibilitychange",j),()=>{window.clearInterval(u),document.removeEventListener("visibilitychange",j)}},[r,t,g]),{data:l,error:i,loading:d,refreshing:v,reload:g,set:c}}function Y(){const[s,n]=p.useState(null),r=p.useRef(!0);p.useEffect(()=>()=>{r.current=!1},[]);const t=p.useCallback(async(l,c)=>{n(l);try{return await c(),!0}finally{r.current&&n(null)}},[]);return{busy:s,run:t}}function nn(){var b,j,S,x,y;const{status:s,error:n,loading:r}=ie(),t=J("/admin/api/runtime",{pollMs:3e4}),l=J("/admin/api/views",{pollMs:6e4});if(r||!s)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(_,{})]});const c=s.features??{features:[],revision:0,safeMode:!1},i=c.features??[],o=s.clients??[],d=o.filter(R=>Ne(R.lastSeen)).length,a=s.updatePolicy??{},v=!!a.minimumVersion&&a.minimumVersion===a.latestVersion,k=s.playbackPolicy,h=s.mdblist,m=s.forYou,g=(s.runs??[]).slice(0,5),u=t.data;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(ae,{tiles:[{label:"items in the library",value:N(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:N((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:N(o.length),icon:"tv",tone:"info"},{label:`visits today · ${((b=l.data)==null?void 0:b.lastWeek.visits)??0} this time last week`,value:N((j=l.data)==null?void 0:j.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((S=l.data)==null?void 0:S.lastWeek.viewers)??0} this time last week`,value:N((x=l.data)==null?void 0:x.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${i.filter(R=>R.enabled).length} / ${i.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(He,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(C,{tone:"bad",children:"offline for maintenance"}):e.jsx(C,{tone:"ok",children:"online"})},{label:"Feature control plane",value:c.safeMode?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["revision r",N(c.revision)]})},{label:"App update prompt",value:a.enabled?e.jsxs(C,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",a.latestVersion]}):e.jsx(C,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(C,{tone:"warn",children:"running"}):e.jsxs(C,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(k==null?void 0:k.prerollEnabled)===!1?e.jsx(C,{children:"off"}):e.jsxs(C,{tone:"ok",children:[((k==null?void 0:k.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(I,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(He,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"MDBList ratings",value:h!=null&&h.enabled?e.jsxs(C,{tone:"ok",children:[N(h.cachedTitles)," titles stored"]}):e.jsx(C,{children:h!=null&&h.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(C,{tone:"warn",children:"rebuilding"}):e.jsxs(C,{children:[N((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:N((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(ee,{to:"/admin/imports",children:"All imports"}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Written"})]})}),e.jsx("tbody",{children:g.length===0?e.jsx(te,{columns:4,children:"No imports have run yet."}):g.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(R.startedAt)}),e.jsx("td",{children:R.kind}),e.jsx("td",{children:e.jsx(C,{tone:R.status==="success"?"ok":R.status==="running"?"warn":"bad",children:R.status})}),e.jsx("td",{className:"num",children:N(R.itemsUpserted)})]},R.id||R.startedAt))})]})})}),e.jsx(I,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:u?e.jsxs(e.Fragment,{children:[e.jsx(fs,{tiles:[{label:"goroutines",value:N(u.goroutines)},{label:"heap in use",value:fe(u.heapInuse)},{label:"reserved",value:fe(u.sys)},{label:"collections",value:N(u.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",fe(u.nextGc)," · memory limit"," ",u.memoryLimit>0&&u.memoryLimit`/admin/api/notifications${Se({days:l,type:i,severity:d,unread:v,limit:g,offset:h*g})}`,[l,i,d,v,h]),{data:b,error:j,loading:S,reload:x}=J(u),y=async()=>{await r(),await x()};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Activity",intro:"Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from.",actions:s>0?e.jsx(A,{onClick:()=>void y(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(B,{message:j}),e.jsx(ae,{tiles:[{label:"Events in window",value:N((b==null?void 0:b.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:N(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:N((b==null?void 0:b.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:l,options:tn.map(R=>({value:R.value,label:R.label})),onChange:R=>{c(R),m(0)}})}),e.jsx(q,{label:"Kind",children:e.jsxs("select",{value:i,onChange:R=>{o(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((b==null?void 0:b.types)??[]).map(R=>e.jsxs("option",{value:R.type,children:[_e(R.type)," (",R.count,")"]},R.type))]})}),e.jsx(q,{label:"Severity",children:e.jsxs("select",{value:d,onChange:R=>{a(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Any"}),e.jsx("option",{value:"info",children:"Information"}),e.jsx("option",{value:"warning",children:"Warning"}),e.jsx("option",{value:"error",children:"Error"})]})}),e.jsx(q,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:R=>{k(R.target.value==="unread"),m(0)},children:[e.jsx("option",{value:"",children:"All"}),e.jsx("option",{value:"unread",children:"Unread only"})]})}),e.jsx("div",{className:"filter-actions",children:e.jsx(A,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{x(),t()},children:"Refresh"})})]}),S?e.jsx(_,{}):e.jsx(I,{title:"Events",icon:"bell",tone:"info",footer:((b==null?void 0:b.total)??0)>g?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:h===0,onClick:()=>m(h-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(h+1)*g>=((b==null?void 0:b.total)??0),onClick:()=>m(h+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((b==null?void 0:b.events.length)??0)===0?e.jsx(te,{columns:6,children:"Nothing has happened in this window."}):b==null?void 0:b.events.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(R.occurredAt),children:be(R.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Ae,{name:bs(R.type),tone:vs(R)}),_e(R.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:R.title}),R.summary?e.jsx("div",{className:"muted",children:R.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:R.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:R.target||"—"}),e.jsxs("td",{className:"nowrap",children:[R.readAt?null:e.jsx(C,{tone:"ok",children:"new"}),R.link?e.jsx(ee,{className:"table-row-link",to:R.link,children:"Open"}):null]})]},R.id))})]})})})]})}function rn(){const{data:s,error:n,loading:r}=J("/admin/api/accounts",{pollMs:6e4}),t=(s==null?void 0:s.accounts)??[],l=t.flatMap(o=>o.devices??[]),c=t.filter(o=>{var d;return(d=o.recommendations)==null?void 0:d.completed}).length,i=t.filter(o=>{var d,a;return((d=o.recommendations)==null?void 0:d.prompted)&&!((a=o.recommendations)!=null&&a.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(B,{message:n}),e.jsx(me,{tone:"info",children:"This is the Memby user list, not the Emby user directory. A person appears here only after signing in to the Memby app. Removing access signs their Memby devices out and does not delete or change their Emby account."}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Memby users",value:N(t.length),icon:"people",tone:"note"},{label:"signed-in devices",value:N(l.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(l.filter(o=>Ne(o.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:N(c),icon:"check",tone:"ok"},{label:"setup prompts queued",value:N(i),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:t.length===0?e.jsx(G,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):t.map(o=>{var h,m;const d=o.devices??[],a=d.filter(g=>Ne(g.lastSeen)).length,v=(h=o.recommendations)!=null&&h.completed?{label:"personalised",tone:"ok"}:(m=o.recommendations)!=null&&m.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},k=Pe(o.lastSeen);return e.jsxs(ee,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(o.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:ps(o.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[o.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":k.tone,title:k.label})]}),e.jsxs("span",{className:"list-meta",children:[N(d.length)," device",d.length===1?"":"s",a?` · ${a} active now`:""," · last seen ",O(o.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(C,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},o.id)})})]})]})}function Ie(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function ln(){const{userId:s=""}=Re(),n=qe(),{wrap:r}=se(),{busy:t,run:l}=Y(),c=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:i,error:o,loading:d,reload:a}=J("/admin/api/accounts",{pollMs:3e4}),[v,k]=p.useState(null),[h,m]=p.useState(null),[g,u]=p.useState(null),[b,j]=p.useState(null),[S,x]=p.useState(null),y=((i==null?void 0:i.accounts)??[]).find(E=>E.id===s),R=(i==null?void 0:i.catalogue)??[],M=(i==null?void 0:i.themes)??[];p.useEffect(()=>{var E;v===null&&y&&k({...((E=y.settings)==null?void 0:E.preferences)??{}})},[y,v]),p.useEffect(()=>{g===null&&y&&u({...y.notifications})},[y,g]),p.useEffect(()=>{if(h!==null||!y)return;const E=y.themes??[];m(E.length===0?M.map(T=>T.id):E)},[y,h,M]);const H=p.useMemo(()=>{const E=[];for(const T of R){let z=E.find(f=>f.name===T.area);z||E.push(z={name:T.area,definitions:[]}),z.definitions.push(T)}return E},[R]),w=(E,T,z,f)=>l(E,async()=>{const D=await r(T,z);j(null),D!==void 0&&(f==null||f()),await a()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(_,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User",crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"})}),e.jsx(B,{message:o}),e.jsx(I,{children:e.jsx(G,{children:"This user is no longer signed in to Memby."})})]});const L=y.devices??[],X=L.filter(E=>Ne(E.lastSeen)).length,re=y.settings??{},K=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:y.username||"Unnamed user",intro:`Memby user · ${N(L.length)} device${L.length===1?"":"s"} · last seen ${O(y.lastSeen)}`,crumbs:e.jsx(ee,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:ps(y.username)}),X?e.jsxs(C,{tone:"ok",children:[X," active now"]}):e.jsx(C,{children:"idle"}),e.jsx(Q,{children:y.id})]})}),e.jsx(B,{message:o}),e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Devices",intro:"Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed.",icon:"tv",tone:"info",children:L.length===0?e.jsx(G,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:L.map(E=>{const T=Pe(E.lastSeen);return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":T.tone,title:T.label})," ",e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(E.id)}`,children:E.name||"Memby TV"})]}),e.jsxs("p",{children:[E.version?`Memby ${E.version}`:"Legacy Memby client"," · ",T.label," · last seen ",O(E.lastSeen)," · signed in ",O(E.signedInAt)]}),(E.versions??[]).length>0?e.jsx("div",{className:"chips",children:(E.versions??[]).map(z=>e.jsxs(Q,{tone:z.version===E.version?"ok":void 0,children:[z.version,z.version===E.version?" · now":""]},z.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(A,{size:"sm",disabled:!E.id,onClick:()=>x({id:E.id,name:E.name}),children:"Rename"}),e.jsx(A,{size:"sm",variant:"danger",disabled:!E.id,onClick:()=>j({kind:"remove-device",deviceId:E.id,name:E.name}),children:"Sign out"})]})]},E.id||E.name)})})}),e.jsxs(I,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:K.completed?e.jsx(A,{busy:t==="reset-rec",onClick:()=>j({kind:"reset-recommendations"}),children:"Clear stored choices"}):K.prompted?e.jsx(A,{onClick:()=>j({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(A,{variant:"primary",busy:t==="prompt",onClick:()=>void w("prompt",()=>F.put(`${c}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:K.completed?e.jsx(C,{tone:"ok",children:"completed"}):K.prompted?e.jsx(C,{tone:"warn",children:"prompt queued"}):e.jsx(C,{children:"not invited"})}),e.jsx(on,{prompt:K})]})]}),e.jsx(I,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:g!=null&&g.enabled?e.jsx(C,{tone:"ok",children:"enabled"}):e.jsx(C,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="notifications",onClick:()=>void w("notifications",()=>F.put(`${c}/notifications`,g),"Notification settings saved.",()=>u(null)),children:"Save notifications"}),e.jsx(A,{onClick:()=>{u(null),a()},children:"Discard changes"})]}),children:g?e.jsxs("div",{className:"checks columns",children:[e.jsx(W,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:g.enabled,onChange:E=>u(T=>T&&{...T,enabled:E})}),e.jsx(W,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:g.showReturnAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,showReturnAlerts:E})}),e.jsx(W,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:g.sonarrAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,sonarrAlerts:E})}),e.jsx(W,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:g.radarrAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,radarrAlerts:E})}),e.jsx(W,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:g.updateAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,updateAlerts:E})}),e.jsx(W,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:g.libraryAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,libraryAlerts:E})}),e.jsx(W,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:g.systemAlerts,disabled:!g.enabled,onChange:E=>u(T=>T&&{...T,systemAlerts:E})})]}):null}),e.jsx(I,{title:"Settings",intro:"These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off.",icon:"sliders",tone:"ok",actions:re.saved?e.jsxs(C,{tone:re.source==="admin"?"warn":"ok",children:["r",N(re.revision)," · ",re.source||"device"," · ",O(re.updatedAt)]}):e.jsx(C,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="push",onClick:()=>void w("push",()=>F.put(`${c}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>k(null)),children:"Push to their televisions"}),e.jsx(A,{onClick:()=>{k(null),a()},children:"Discard changes"}),e.jsx(A,{onClick:()=>j({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(ee,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:H.map(E=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:E.name}),E.definitions.map(T=>e.jsx(cn,{definition:T,value:v==null?void 0:v[T.key],onChange:z=>k(f=>({...f??{},[T.key]:z}))},T.key))]},E.name))}),e.jsx(I,{title:"Colour schemes",intro:"Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight.",icon:"sparkle",tone:"note",actions:(y.themes??[]).length===0?e.jsx(C,{children:"all schemes"}):e.jsxs(C,{tone:"note",children:[N((y.themes??[]).length)," of ",N(M.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:t==="themes",onClick:()=>(h??[]).length===0?j({kind:"no-themes"}):void w("themes",()=>F.put(`${c}/themes`,{themes:h??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx(A,{onClick:()=>m(M.map(E=>E.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:M.map(E=>{var T,z,f;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(h??[]).includes(E.id),onChange:D=>m($=>D.target.checked?[...$??[],E.id]:($??[]).filter(P=>P!==E.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":Ie((T=E.palette)==null?void 0:T.surface),"--swatch-accent":Ie((z=E.palette)==null?void 0:z.accent),"--swatch-hairline":Ie((f=E.palette)==null?void 0:f.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:E.name}),e.jsx("p",{children:E.description})]})]},E.id)})})}),e.jsx(I,{title:"Remove Memby access",intro:"Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched.",icon:"alert",tone:"bad",children:e.jsx(A,{variant:"danger",onClick:()=>j({kind:"remove-account"}),children:"Remove Memby access"})}),S?e.jsx(dn,{initial:S.name,busy:t==="rename",onCancel:()=>x(null),onConfirm:E=>void w("rename",()=>F.put(`${c}/devices/${encodeURIComponent(S.id)}`,{deviceName:E}),"Device renamed.",()=>x(null))}):null,b?e.jsx(hn,{pending:b,busy:t,username:y.username,onCancel:()=>j(null),onConfirm:()=>{switch(b.kind){case"remove-device":return void w("remove-device",()=>F.del(`${c}/devices/${encodeURIComponent(b.deviceId)}`),"Device signed out.");case"remove-account":return void w("remove-account",()=>F.del(`${c}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void w("reset-rec",()=>F.del(`${c}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void w("reset-prefs",()=>F.del(`${c}/preferences`),"Defaults restored.",()=>k(null));case"no-themes":return void w("themes",()=>F.put(`${c}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function on({prompt:s}){const n=s.ratings??[],r=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],t=[...n.map(l=>e.jsxs(Q,{tone:"warn",children:[l.title," · ",N(l.rating)," ★"]},`r:${l.title}`)),...r.flatMap(([l,c])=>(c??[]).map(i=>e.jsxs(Q,{children:[l,": ",i]},`${l}:${i}`)))];return t.length===0?e.jsx(G,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:t})}function cn({definition:s,value:n,onChange:r}){if(s.kind==="toggle")return e.jsx(W,{label:s.name,hint:s.description,checked:!!n,onChange:r});if(s.kind==="choice"||s.kind==="number"){const l=s.unit??"",c=s.kind==="number"?(s.numbers??[]).map(i=>({value:String(i),label:i===0?"No limit":l?`${i} ${l}`:String(i)})):s.options??[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:i=>r(s.kind==="number"?Number(i.target.value):i.target.value),children:c.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})})}if(s.kind==="multi"){const l=Array.isArray(n)?n:[],c=[...l,...(s.options??[]).map(i=>i.value).filter(i=>!l.includes(i))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:c.map(i=>{const o=(s.options??[]).find(d=>d.value===i);return o?e.jsx(W,{label:o.label,checked:l.includes(i),onChange:d=>r(d?[...l,i]:l.filter(a=>a!==i))},i):null})})]})}const t=Array.isArray(n)?n:[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:t.join(` -`),onChange:l=>r(l.target.value.split(` -`).map(c=>c.trim()).filter(Boolean))})})}function dn({initial:s,busy:n,onConfirm:r,onCancel:t}){const[l,c]=p.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:i=>i.target===i.currentTarget&&t(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(q,{label:"Device name",children:e.jsx("input",{type:"text",value:l,autoFocus:!0,maxLength:80,onChange:i=>c(i.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:t,children:"Cancel"}),e.jsx(A,{variant:"primary",busy:n,disabled:!l.trim(),onClick:()=>r(l.trim()),children:"Rename"})]})]})})}function hn({pending:s,busy:n,username:r,onConfirm:t,onCancel:l}){const i={"remove-device":{title:"Sign this device out of Memby?",body:"Its Emby account will not be changed. The set can sign in again at any time.",label:"Sign out",destructive:!0},"remove-account":{title:`Remove Memby access for ${r||"this user"}?`,body:"Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.",label:"Remove access",destructive:!0},"reset-recommendations":{title:"Clear this person's stored recommendation choices?",body:"Viewing history remains intact; only the explicit setup answers are removed.",label:"Clear",destructive:!0},"cancel-prompt":{title:"Cancel this person's queued recommendation prompt?",body:"They will not be invited to set up recommendations on their next launch.",label:"Cancel prompt",destructive:!1},"reset-preferences":{title:"Restore the Memby defaults for this person?",body:"Their televisions will pick the change up the next time they check in.",label:"Restore defaults",destructive:!0},"no-themes":{title:"Allow this person no colour schemes?",body:"They will be left on Midnight with nothing to choose between.",label:"Save anyway",destructive:!0}}[s.kind];return e.jsx(he,{title:i.title,body:i.body,confirmLabel:i.label,destructive:i.destructive,busy:!!n,onConfirm:t,onCancel:l})}function un(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const t=(s.options??[]).find(l=>l.value===n);return t?t.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const r=Array.isArray(n)?n:[];return r.length===0?"None":r.map(t=>{var l;return((l=(s.options??[]).find(c=>c.value===t))==null?void 0:l.label)??t}).join(", ")}function mn(){const{userId:s=""}=Re(),{wrap:n}=se(),{busy:r,run:t}=Y(),l=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:c,error:i,loading:o,reload:d}=J(`${l}/preferences/history`,{pollMs:3e4}),[a,v]=p.useState(new Set),[k,h]=p.useState(null),m=(c==null?void 0:c.username)||"this account",g=(c==null?void 0:c.devices)??[],u=(c==null?void 0:c.revisions)??[],b=(c==null?void 0:c.catalogue)??[],j=x=>v(y=>{const R=new Set(y);return R.has(x)?R.delete(x):R.add(x),R}),S=x=>t("restore",async()=>{await n(()=>F.post(`${l}/preferences/revisions/${x}/restore`),`Restored r${x}.`),h(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(ee,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Where each device has got to",intro:"A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway.",icon:"tv",tone:"info",actions:c!=null&&c.saved?e.jsxs(C,{tone:c.currentSource==="admin"?"warn":"ok",children:["now on r",N(c.currentRevision)," · ",c.currentSource||"device"]}):e.jsx(C,{children:"defaults · never synced"}),children:g.length===0?e.jsx(G,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:g.map(x=>{const y=x.never?void 0:x.behind?"bad":"ok";return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":y})," ",x.name||"Memby TV"," ",x.signedOut?e.jsx(C,{children:"signed out"}):null]}),e.jsxs("p",{children:[x.never?"Has not fetched these settings yet":`Holding r${N(x.revision)} · taken ${O(x.ackedAt)}`,x.clientVersion?` · Memby ${x.clientVersion}`:"",x.signedOut?"":` · last seen ${O(x.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:x.never?e.jsx(C,{children:"never taken one"}):x.behind?e.jsxs(C,{tone:"bad",children:[N(x.behind)," behind"]}):e.jsx(C,{tone:"ok",children:"up to date"})})]},x.deviceId||x.name)})})}),e.jsx(I,{title:"Change history",intro:"Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{className:"num",children:"Rev"}),e.jsx("th",{children:"Changed by"}),e.jsx("th",{children:"What changed"}),e.jsx("th",{className:"num",children:"Taken by"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(te,{columns:6,children:"Nothing has been changed on this account yet."}):u.flatMap(x=>{const y=a.has(x.revision),R=x.acks??[],M=x.changes??[],H=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(x.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",N(x.revision)," ",x.current?e.jsx(C,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(C,{tone:x.source==="admin"?"warn":"ok",children:x.author}),x.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",N(x.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:x.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):M.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:M.map((w,L)=>e.jsxs(Q,{children:[w.name,": ",w.before," → ",w.after]},`${w.name}:${L}`))})}),e.jsx("td",{className:"num",children:R.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:R.map(w=>w.deviceName||w.deviceId).join(", "),children:N(R.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(A,{size:"sm",onClick:()=>j(x.revision),children:y?"Hide":"Show"}),x.current?null:e.jsx(A,{size:"sm",onClick:()=>h(x.revision),children:"Restore"})]})})]},x.revision)];return y&&H.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:b.map(w=>{var L;return e.jsxs(Q,{children:[w.name,":"," ",un(w,(L=x.preferences)==null?void 0:L[w.key])]},w.key)})})})},`${x.revision}:detail`)),H})})]})})})]}),k!==null?e.jsx(he,{title:`Restore revision ${k}?`,body:"It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to.",confirmLabel:"Restore",busy:r==="restore",onConfirm:()=>void S(k),onCancel:()=>h(null)}):null]})}function pn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(r=>e.jsx(Q,{tone:r.version===s.version?"ok":void 0,children:r.version},r.version))})}function xn(){const{status:s,error:n,loading:r}=ie(),t=(s==null?void 0:s.clients)??[],l=t.filter(i=>(i.capabilities??[]).includes("server_features_v1")),c=new Set(t.map(i=>i.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(B,{message:n}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"devices known",value:N(t.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(t.filter(i=>Ne(i.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:N(l.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:N(c.size),icon:"download",tone:"note"}]}),e.jsx(I,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:6,children:"No devices have signed in yet."}):t.map(i=>{const o=(i.capabilities??[]).includes("server_features_v1"),d=Pe(i.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":d.tone,title:d.label}),e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:i.username}),e.jsx("td",{className:"mono",children:i.version||"legacy"}),e.jsx("td",{children:e.jsx(pn,{client:i})}),e.jsx("td",{children:e.jsx(C,{tone:o?"ok":"warn",children:o?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:O(i.lastSeen)})]},`${i.deviceId}:${i.username}`)})})]})})})]})]})}const Ke={user:"",q:"",ip:"",outcome:"",from:"",to:""},jn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function vn(){var x,y,R,M,H,w;const[s,n]=p.useState("log"),[r,t]=p.useState(7),[l,c]=p.useState(Ke),[i,o]=p.useState(0),d=100,a=p.useMemo(()=>Se({...l,days:l.from?void 0:r||void 0,limit:d,offset:i*d}),[l,r,i]),v=J(`/admin/api/logins${a}`,{enabled:s==="log"}),k=J(`/admin/api/logins/devices${a}`,{enabled:s==="devices"}),h=((x=v.data)==null?void 0:x.users)??((y=k.data)==null?void 0:y.users)??[],m=((R=v.data)==null?void 0:R.totals)??((M=k.data)==null?void 0:M.totals),g=((H=v.data)==null?void 0:H.retentionDays)??((w=k.data)==null?void 0:w.retentionDays)??90,u=s==="log"?v.loading:k.loading,b=s==="log"?v.error:k.error,j=L=>{c(X=>({...X,...L})),o(0)},S=Object.entries(l).some(([,L])=>L!=="")||!!l.from;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Sign-in history",intro:"Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected.",actions:e.jsx(Me,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(B,{message:b}),m?e.jsx(ae,{tiles:[{label:"Successful sign-ins",value:N(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:N(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:N(m.devices),icon:"tv",tone:"info"},{label:"People",value:N(m.users),icon:"people",tone:"note"},{label:"Addresses",value:N(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${g} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Me,{value:l.from?-1:r,options:jn.map(L=>({value:L.value,label:L.label})),onChange:L=>{t(L),j({from:"",to:""})}})}),e.jsx(q,{label:"Person",children:e.jsxs("select",{value:l.user,onChange:L=>j({user:L.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),h.map(L=>e.jsx("option",{value:L.id,children:L.username||L.id},L.id))]})}),e.jsx(q,{label:"Outcome",children:e.jsxs("select",{value:l.outcome,onChange:L=>j({outcome:L.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(q,{label:"Address",children:e.jsx("input",{type:"text",value:l.ip,placeholder:"10.0.0.4",onChange:L=>j({ip:L.target.value})})}),e.jsx(q,{label:"From",children:e.jsx("input",{type:"date",value:l.from,onChange:L=>j({from:L.target.value})})}),e.jsx(q,{label:"To",children:e.jsx("input",{type:"date",value:l.to,onChange:L=>j({to:L.target.value})})}),e.jsx(q,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:l.q,placeholder:"Name, device or address",onChange:L=>j({q:L.target.value})})}),e.jsx("div",{className:"filter-actions",children:S?e.jsx(A,{variant:"quiet",size:"sm",onClick:()=>{c(Ke),o(0)},children:"Clear"}):null})]}),u?e.jsx(_,{}):s==="log"?e.jsx(bn,{data:v.data,page:i,limit:d,onPage:o}):e.jsx(gn,{data:k.data})]})}function bn({data:s,page:n,limit:r,onPage:t}){if(!s)return null;const l=s.events.length,c=s.total===0?0:n*r+1;return e.jsxs(e.Fragment,{children:[e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Attempts per day",intro:"Grouped in the household's own timezone, so an evening sign-in stays on the day it happened.",icon:"chart",tone:"info",children:e.jsx(gs,{data:s.days,labelOf:i=>i.day,valueOf:i=>i.logins+i.failures,toneOf:i=>i.failures>i.logins?"bad":void 0,title:i=>`${i.day}: ${i.logins} in, ${i.failures} refused, ${i.devices} televisions`})}),e.jsx(I,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(G,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(i=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:i.ipAddress}),e.jsxs("p",{children:[N(i.logins)," in",i.failures>0?` · ${N(i.failures)} refused`:""]})]}),i.failures>0&&i.logins===0?e.jsx(C,{tone:"bad",children:"only refused"}):null]},i.ipAddress))})})]}),e.jsx(I,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${N(c)}–${N(c+l-1)} of ${N(s.total)}`}),footer:s.total>r?e.jsxs(e.Fragment,{children:[e.jsx(A,{size:"sm",disabled:n===0,onClick:()=>t(n-1),children:"Newer"}),e.jsx(A,{size:"sm",disabled:(n+1)*r>=s.total,onClick:()=>t(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(te,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(i.occurredAt)}),e.jsx("td",{children:i.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:i.deviceId?e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||i.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:i.ipAddress||"—"}),e.jsx("td",{className:"mono",children:i.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:i.success?i.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:i.failureReason||"refused"})})]},i.id))})]})})})]})}function gn({data:s}){return s?e.jsx(I,{title:"Televisions",intro:"Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Television"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Today"}),e.jsx("th",{className:"num",children:"Sign-ins"}),e.jsx("th",{className:"num",children:"Refused"}),e.jsx("th",{className:"num",children:"Addresses"}),e.jsx("th",{className:"nowrap",children:"Last address"}),e.jsx("th",{className:"nowrap",children:"Last sign-in"}),e.jsx("th",{children:"Build"})]})}),e.jsx("tbody",{children:s.devices.length===0?e.jsx(te,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(ee,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?N(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:N(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:N(n.failures)}):"—"}),e.jsx("td",{className:"num",children:N(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?O(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const fn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function yn(){const{deviceId:s=""}=Re(),[n,r]=p.useState(7),t=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:l,error:c,loading:i}=J(t,{enabled:!!s}),o=l==null?void 0:l.summary,d=(o==null?void 0:o.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:d,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(ee,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(ee,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Me,{value:n,options:fn.map(a=>({value:a.value,label:a.label})),onChange:r})}),e.jsx(B,{message:c}),i?e.jsx(_,{}):l?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Sign-ins today",value:N((o==null?void 0:o.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:N((o==null?void 0:o.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:N((o==null?void 0:o.failures)??0),icon:"shield",tone:((o==null?void 0:o.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:N((o==null?void 0:o.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:o!=null&&o.firstLogin?O(o.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:o!=null&&o.lastLogin?O(o.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(de,{cols:"wide",children:[e.jsx(I,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(gs,{data:l.days,labelOf:a=>a.day,valueOf:a=>a.logins+a.failures,toneOf:a=>a.failures>a.logins?"bad":void 0,title:a=>`${a.day}: ${a.logins} in${a.failures?`, ${a.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(I,{title:"Identity",icon:"tv",tone:"info",children:e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Person"}),e.jsx("p",{children:(o==null?void 0:o.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:l.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(o==null?void 0:o.clientVersion)||"unknown"})]})})]})}),e.jsx(I,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:l.versions.length===0?e.jsx(G,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:l.versions.map(a=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.version}),e.jsxs("p",{children:[O(a.firstSeen)," → ",O(a.lastSeen)]})]})},a.version))})})]})]}),e.jsx(I,{title:"Addresses",icon:"globe",tone:"data",children:l.addresses.length===0?e.jsx(G,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:l.addresses.map(a=>e.jsxs(Q,{tone:a.failures>0?"warn":"data",children:[a.ipAddress," · ",N(a.logins),a.failures>0?` (+${N(a.failures)} refused)`:""]},a.ipAddress))})}),e.jsx(I,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[N(l.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:l.events.length===0?e.jsx(te,{columns:6,children:"This television has not connected in the selected window."}):l.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"muted",children:a.method}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]}):null]})}function wn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),a=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),k=h=>i(h,async()=>{await l(()=>F.post("/admin/api/sync",{kind:h}),h==="full"?"Full re-import started.":"Import started."),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(B,{message:n}),r||!s?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"items",value:N(s.library.total),icon:"library",tone:"data"},...Object.keys(a).sort().map(h=>({label:h,value:N(a[h]),icon:"list"})),{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(I,{title:"Import the catalogue",intro:"Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live.",icon:"library",tone:"data",footer:e.jsx("span",{className:"hint",children:v?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"sync",disabled:v,busy:c==="incremental",onClick:()=>void k("incremental"),children:"Sync new items"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full re-import"})]})})]}),o?e.jsx(he,{title:"Re-import the entire library?",body:"A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout.",confirmLabel:"Re-import",busy:c==="full",onConfirm:()=>void k("full"),onCancel:()=>d(!1)}):null]})}const kn={imdb:"IMDb",tomatoes:"Rotten Tomatoes",audience:"Rotten Tomatoes Audience",metacritic:"Metacritic",letterboxd:"Letterboxd",rogerebert:"Roger Ebert",tmdb:"TMDb",trakt:"Trakt",mal:"MyAnimeList",anilist:"AniList",anidb:"AniDB",kitsu:"Kitsu",score:"MDBList Score",score_average:"MDBList Average"};function Nn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),[a,v]=p.useState(""),[k,h]=p.useState(!1),[m,g]=p.useState([]),[u,b]=p.useState(!1),j=s==null?void 0:s.mdblist;p.useEffect(()=>{u||!j||(d(j.enabled),g(j.sources??[]))},[j,u]);const S=()=>i("save",async()=>{await l(()=>F.post("/admin/api/mdblist-settings",{enabled:o,apiKey:a.trim(),clearApiKey:k,sources:m}),"Ratings settings saved."),v(""),h(!1),b(!1),await t()}),x=(j==null?void 0:j.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(B,{message:n}),r||!j?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"titles stored",value:N(x),icon:"database",tone:"data"},{label:"due to be re-checked",value:N(j.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:N((j.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:j.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:j.apiKeyConfigured?"ok":void 0}]}),e.jsxs(de,{cols:"2",children:[e.jsxs(I,{title:"MDBList connection",intro:"The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title.",icon:"star",tone:"note",actions:o?e.jsxs(C,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(C,{children:j.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:o,onChange:y=>{d(y),b(!0)}}),e.jsx(q,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:a,placeholder:j.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>v(y.target.value)})}),e.jsx(W,{label:"Remove the saved key",checked:k,onChange:h})]}),e.jsx(I,{title:"Sources shown on televisions",intro:"A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched.",icon:"list",tone:"data",children:(j.availableSources??[]).length===0?e.jsx(G,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(j.availableSources??[]).map(y=>e.jsx(W,{label:kn[y]??y,checked:m.includes(y),onChange:R=>{b(!0),g(M=>R?[...M,y]:M.filter(H=>H!==y))}},y))})})]}),e.jsx(I,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void S(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:x?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function Sn(){var k;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),o=(s==null?void 0:s.requestUsers)??[],d=((k=s==null?void 0:s.requestPolicy)==null?void 0:k.allowedUserIds)??[],a=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(h=>[h.userId,h])),[s==null?void 0:s.requestUsage]),v=h=>i(`access-${h}`,async()=>{const m=d.includes(h)?d.filter(g=>g!==h):[...d,h];await l(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(h)?"Request access granted.":"Request access removed."),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(C,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(C,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(G,{children:"Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The button stays hidden on every television until one of them is."}):null}),e.jsx(I,{title:"Request access and activity",intro:"One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state.",icon:"people",tone:"note",children:o.length===0?e.jsx(G,{children:"No one has signed in yet."}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"User"}),e.jsx("th",{children:"Last seen"}),e.jsx("th",{className:"num",children:"Sent to services"}),e.jsx("th",{children:"Last request"}),e.jsx("th",{children:"Access"})]})}),e.jsx("tbody",{children:o.map(h=>{const m=a.get(h.id),g=d.includes(h.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:h.username})}),e.jsx("td",{className:"muted nowrap",children:O(h.lastSeen)}),e.jsx("td",{className:"num",children:(m==null?void 0:m.requests)??0}),e.jsx("td",{className:"muted nowrap",children:m!=null&&m.lastRequest?O(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx(A,{size:"sm",variant:g?"quiet":"primary",busy:c===`access-${h.id}`,onClick:()=>void v(h.id),children:g?"Remove access":"Give access"})})]},h.id)})})]})})})]})]})}function Cn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(!1),a=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),k=(h,m,g)=>i(m,async()=>{await l(()=>F.post("/admin/api/for-you",{action:h}),g),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tracearr sessions",value:N((a==null?void 0:a.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:N((a==null?void 0:a.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:N((a==null?void 0:a.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:O(a==null?void 0:a.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(I,{title:"Pool maintenance",intro:"Prepared pools refresh in the background; these are the manual versions of the same work. A rebuild is safe at any time — televisions read the last finished pool until a new one lands.",icon:"sparkle",tone:"note",footer:e.jsx("span",{className:"hint",children:v?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",icon:"download",disabled:v,busy:c==="import",onClick:()=>void k("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(A,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(A,{icon:"sync",disabled:v,busy:c==="rebuild",onClick:()=>void k("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(I,{title:"Reading a person's scores",intro:"The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order.",icon:"search",tone:"info",actions:e.jsx(ee,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),o?e.jsx(he,{title:"Backfill all Tracearr history?",body:"Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while.",confirmLabel:"Backfill",busy:c==="full",onConfirm:()=>void k("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const Mn=[["Genre","genres"],["Studio","studios"],["Actor","actors"],["Director","directors"],["Franchise","franchises"],["Runtime","runtimeRanges"],["Age rating","ageRatings"],["Community rating","communityRatings"],["Release period","releasePeriods"],["Content type","contentTypes"]];function Rn(s){return s?Mn.flatMap(([n,r])=>Object.entries(s[r]??{}).map(([t,l])=>({dimension:n,name:t,weight:l.weight??0,evidence:l.evidence??0}))).sort((n,r)=>Math.abs(r.weight)-Math.abs(n.weight)):[]}const Ge=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function En(){const{status:s}=ie(),{wrap:n}=se(),{busy:r,run:t}=Y(),[l,c]=p.useState(""),[i,o]=p.useState("default"),[d,a]=p.useState("0"),[v,k]=p.useState(""),[h,m]=p.useState(null),[g,u]=p.useState("Choose a person to inspect their recommendations."),[b,j]=p.useState(""),S=(s==null?void 0:s.requestUsers)??[],x=()=>t("run",async()=>{if(!l){j("Choose a person to pressure-test.");return}j(""),u("Running the permission check and the scorer…");const w=new URLSearchParams({userId:l,context:i,minutes:d||"0",limit:"100"});v&&w.set("at",new Date(v).toISOString());const L=await n(()=>F.get(`/admin/api/recommendations?${w.toString()}`));L?(m(L),u(`Scored at ${new Date().toLocaleTimeString()}.`)):u("Pressure test failed.")}),y=Rn((h==null?void 0:h.profile)??null).slice(0,24),R=(h==null?void 0:h.actions)??[],M=(h==null?void 0:h.items)??[],H=(h==null?void 0:h.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(B,{message:b}),e.jsx(I,{title:"Run a pressure test",intro:"Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose.",icon:"search",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:r==="run",onClick:()=>void x(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:g})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Person",children:e.jsxs("select",{value:l,onChange:w=>c(w.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),S.map(w=>e.jsx("option",{value:w.id,children:w.username},w.id))]})}),e.jsx(q,{label:"Context",children:e.jsxs("select",{value:i,onChange:w=>o(w.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(q,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:w=>a(w.target.value)})}),e.jsx(q,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:w=>k(w.target.value)})})]})}),h?e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"prepared pool",value:N(h.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:N(h.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:N(M.length),icon:"sparkle",tone:"note"},{label:"source events",value:N(H.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:H.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:O(H.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(I,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[y.length===0?e.jsx(G,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(w=>e.jsxs(Q,{tone:w.weight<0?"bad":void 0,children:[w.dimension,": ",w.name," ",Ge(w.weight)," · n=",N(w.evidence)]},`${w.dimension}:${w.name}`))}),R.length===0?e.jsx(G,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:R.map((w,L)=>e.jsxs(Q,{tone:"ok",children:[w.action,": ",w.title||w.itemId]},`${w.action}:${L}`))})]}),M.length===0?e.jsx(I,{children:e.jsx(G,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(de,{children:M.map((w,L)=>{const X=w.explanation??{},re=Object.entries(X.components??{}).sort((T,z)=>Math.abs(z[1])-Math.abs(T[1])),K=w.exposure??{},E=[w.type,w.year,w.runtimeMinutes?`${w.runtimeMinutes} min`:null,...w.genres??[]].filter(Boolean).join(" · ");return e.jsxs(I,{title:`#${L+1} · ${w.title}`,intro:E,actions:e.jsx(Q,{tone:"ok",children:Number(X.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[w.preparedReason||"No legacy prepared explanation",w.compatibilityLabel?` · ${w.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(X.reasonCodes??[]).map(T=>e.jsx(Q,{tone:"ok",children:T},T)),re.map(([T,z])=>e.jsxs(Q,{tone:z<0?"bad":void 0,children:[T,"=",Ge(z)]},T))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",N(w.baseRank??0)," · base ",Number(w.baseScore??0).toFixed(3)," · affinity ",Number(w.affinityScore??0).toFixed(3)," · compatibility"," ",Number(w.compatibilityScore??0).toFixed(3)," · impressions"," ",N(K.impressions??0)," · focuses ",N(K.focuses??0)," · selects"," ",N(K.selects??0)]}),e.jsx("div",{className:"chips",children:(w.eligibleRows??[]).map(T=>e.jsx(Q,{tone:"ok",children:T},T))}),w.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",w.preparedEvidenceTitle]}):null]})]},`${L}:${w.title}`)})})]}):null]})}const An=4,xe=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],ge=()=>({pinnedItems:[],primeSubtitle:""}),De=[{value:1,short:"Mon",label:"Monday"},{value:2,short:"Tue",label:"Tuesday"},{value:3,short:"Wed",label:"Wednesday"},{value:4,short:"Thu",label:"Thursday"},{value:5,short:"Fri",label:"Friday"},{value:6,short:"Sat",label:"Saturday"},{value:0,short:"Sun",label:"Sunday"}];function Ze(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Je(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function In(s){return s.frequency??"once"}function $n(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=De.filter(t=>(s.weekdays??[]).includes(t.value));return`${(n.length===7?"Every day":n.map(t=>t.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function Ye(s,n){const r=new Date;r.setMinutes(Math.ceil(r.getMinutes()/30)*30,0,0);const t=new Date(r.getTime()+2*60*60*1e3),l=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:r.toISOString(),endAt:t.toISOString(),priority:0,enabled:!0,placements:[l?n:"home"]}}function Tn(){var E,T,z;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l,show:c}=se(),{busy:i,run:o}=Y(),[d,a]=p.useState("home"),[v,k]=p.useState({home:ge(),movies:ge(),tv_shows:ge()}),[h,m]=p.useState(!1),[g,u]=p.useState(""),[b,j]=p.useState(null),[S,x]=p.useState([]),[y,R]=p.useState(null),M=s==null?void 0:s.heroPolicy;p.useEffect(()=>{var f,D,$;h||!M||(k({home:((f=M.placements)==null?void 0:f.home)??{pinnedItems:M.pinnedItems??[],primeSubtitle:M.primeSubtitle??""},movies:((D=M.placements)==null?void 0:D.movies)??ge(),tv_shows:(($=M.placements)==null?void 0:$.tv_shows)??ge()}),x(M.schedules??[]))},[M,h]);const H=()=>o("search",async()=>{const f=g.trim();if(!f)return;const D=await l(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(f)}`));D&&j(D.items??[])}),w=v[d],L=w.pinnedItems??[],X=f=>{k(D=>({...D,[d]:{...D[d],...f}})),m(!0)},re=f=>{if(!L.some(D=>D.id===f.id)){if(L.length>=An){c("Remove a pinned title before adding another.","bad");return}X({pinnedItems:[...L,f]})}},K=()=>o("save",async()=>{await l(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([f,D])=>[f,{pinnedItemIds:(D.pinnedItems??[]).map($=>$.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:S}),"Hero saved."),m(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(de,{children:xe.map(f=>{var le;const D=v[f.id],$=S.filter(ue=>ue.enabled&&(ue.placements??["home"]).includes(f.id)).sort((ue,Ns)=>Ns.priority-ue.priority)[0],P=(D.pinnedItems??[]).length?"Manual":$?"Schedule ready":"Automatic",oe=(le=M==null?void 0:M.items)==null?void 0:le.find(ue=>ue.id===($==null?void 0:$.itemId)),ce=(D.pinnedItems??[]).map(ue=>ue.name).join(", ")||(oe==null?void 0:oe.name)||($==null?void 0:$.itemId)||"Resolved for each viewer";return e.jsx(I,{title:f.label,intro:`${P} · ${ce}`,tone:f.id===d?"info":void 0,children:e.jsxs(A,{size:"sm",variant:"quiet",onClick:()=>a(f.id),children:["Manage ",f.label]})},f.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:xe.map(f=>e.jsx(A,{variant:d===f.id?"primary":"quiet",onClick:()=>a(f.id),children:f.label},f.id))}),e.jsxs(I,{title:`${(E=xe.find(f=>f.id===d))==null?void 0:E.label} hero`,intro:`Pinned ${(T=xe.find(f=>f.id===d))==null?void 0:T.type} lead this section only. Empty places use this placement’s automatic selection.`,icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void K(),children:"Save hero"}),e.jsx(A,{onClick:()=>{X({pinnedItems:[]})},children:"Clear pins"}),h?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[L.length===0?e.jsx(G,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:L.map((f,D)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:D+1}),e.jsxs("span",{children:[e.jsx("b",{children:f.name}),e.jsxs("small",{children:[f.type,f.year?` · ${f.year}`:""]})]}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>R(Ye(f,d)),children:"Schedule"}),e.jsx(A,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${f.name}`,onClick:()=>X({pinnedItems:L.filter($=>$.id!==f.id)})})]},f.id))}),e.jsx(q,{label:"Prime-card subtitle",hint:"Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason.",children:e.jsx("input",{type:"text",maxLength:160,value:w.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:f=>{X({primeSubtitle:f.target.value})}})})]}),e.jsx(I,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(C,{tone:"info",children:(M==null?void 0:M.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void K(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:S.length===0?e.jsx(G,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...S].sort((f,D)=>Number(D.enabled)-Number(f.enabled)||D.priority-f.priority).map(f=>{const D=[...(M==null?void 0:M.items)??[],...L,...b??[]].find($=>$.id===f.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":f.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:f.frequency==="weekly"?"Weekly":f.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:f.frequency?f.startTime:f.startAt?new Date(f.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(D==null?void 0:D.name)??f.itemId}),e.jsx(C,{tone:f.enabled?"ok":void 0,children:f.enabled?"enabled":"paused"})]}),e.jsx("p",{children:$n(f)}),e.jsxs("div",{className:"chips",children:[(f.placements??["home"]).map($=>{var P;return e.jsx("span",{className:"chip",children:(P=xe.find(oe=>oe.id===$))==null?void 0:P.label},$)}),f.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",f.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(A,{size:"sm",onClick:()=>R({...f}),children:"Edit"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{x($=>$.map(P=>P.id===f.id?{...P,enabled:!P.enabled}:P)),m(!0)},children:f.enabled?"Pause":"Enable"}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>{x($=>$.filter(P=>P.id!==f.id)),m(!0)},children:"Remove"})]})]},f.id)})})}),e.jsxs(I,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(z=xe.find(f=>f.id===d))==null?void 0:z.label} placement; switch tabs to show it in more than one section.`,icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(q,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:g,placeholder:"Search films and television shows",onChange:f=>u(f.target.value),onKeyDown:f=>{f.key==="Enter"&&H()}})}),e.jsx(A,{busy:i==="search",icon:"search",onClick:()=>void H(),children:"Search"})]}),b===null?null:b.length===0?e.jsx(G,{children:"No playable films or series matched that search."}):e.jsx(de,{children:b.map(f=>e.jsx(I,{title:f.name,intro:`${f.type||"Title"} · ${f.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(A,{size:"sm",icon:"plus",disabled:L.some(D=>D.id===f.id)||d==="movies"&&f.type!=="Movie"||d==="tv_shows"&&f.type!=="Series",onClick:()=>re(f),children:L.some(D=>D.id===f.id)?"Pinned":"Add to hero"}),e.jsx(A,{size:"sm",icon:"clock",onClick:()=>R(Ye(f,d)),children:"Schedule"})]})},f.id))})]})]}),y?e.jsx(Ln,{schedule:y,item:[...(M==null?void 0:M.items)??[],...L,...b??[]].find(f=>f.id===y.itemId),timeZone:(M==null?void 0:M.timeZone)||"server local time",isNew:!S.some(f=>f.id===y.id),onCancel:()=>R(null),onSave:f=>{x(D=>D.some($=>$.id===f.id)?D.map($=>$.id===f.id?f:$):[...D,f]),R(null),m(!0)}}):null]})}function Ln({schedule:s,item:n,timeZone:r,isNew:t,onSave:l,onCancel:c}){const[i,o]=p.useState({...s,weekdays:[...s.weekdays??[]]}),d=In(i),a=u=>{const b=new Date,j=new Date(b.getTime()+2*60*60*1e3);o(S=>{var x;return u==="once"?{...S,frequency:void 0,startAt:Je(S.startAt)?S.startAt:b.toISOString(),endAt:Je(S.endAt)?S.endAt:j.toISOString()}:{...S,frequency:u,startTime:S.startTime||"18:00",endTime:S.endTime||"22:00",weekdays:u==="weekly"?(x=S.weekdays)!=null&&x.length?S.weekdays:[1,2,3,4,5]:[]}})},v=i.placements??["home"],k=u=>u==="home"||u==="movies"&&(n==null?void 0:n.type)==="Movie"||u==="tv_shows"&&(n==null?void 0:n.type)==="Series",h=!!(i.startAt&&i.endAt&&new Date(i.endAt)>new Date(i.startAt)),m=!!(i.startTime&&i.endTime&&i.startTime!==i.endTime&&(d!=="weekly"||(i.weekdays??[]).length>0)),g=d==="once"?h:m;return e.jsx("div",{className:"scrim",onPointerDown:u=>u.target===u.currentTarget&&c(),children:e.jsxs("div",{className:"dialog hero-schedule-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"hero-schedule-title",children:[e.jsxs("div",{className:"hero-schedule-dialog-head",children:[e.jsx("span",{className:"hero-schedule-kicker",children:"Hero schedule"}),e.jsx("h2",{id:"hero-schedule-title",children:(n==null?void 0:n.name)??i.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",r,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(u=>e.jsxs("button",{type:"button","aria-pressed":d===u,onClick:()=>a(u),children:[e.jsx("b",{children:u==="once"?"One time":u==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:u==="once"?"A date range":u==="daily"?"Same time daily":"Choose days"})]},u))}),d==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:i.startAt?Ze(new Date(i.startAt)):"",onChange:u=>o(b=>({...b,startAt:u.target.value?new Date(u.target.value).toISOString():void 0}))})}),e.jsx(q,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:i.endAt?Ze(new Date(i.endAt)):"",onChange:u=>o(b=>({...b,endAt:u.target.value?new Date(u.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts each time",children:e.jsx("input",{type:"time",value:i.startTime??"",onChange:u=>o(b=>({...b,startTime:u.target.value}))})}),e.jsx(q,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:i.endTime??"",onChange:u=>o(b=>({...b,endTime:u.target.value}))})})]}),d==="weekly"?e.jsxs("div",{className:"schedule-days",children:[e.jsxs("div",{className:"schedule-days-head",children:[e.jsx("b",{children:"Days"}),e.jsxs("div",{children:[e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>o(u=>({...u,weekdays:De.map(b=>b.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:De.map(u=>{const b=(i.weekdays??[]).includes(u.value);return e.jsx("button",{type:"button","aria-pressed":b,title:u.label,onClick:()=>o(j=>({...j,weekdays:b?(j.weekdays??[]).filter(S=>S!==u.value):[...j.weekdays??[],u.value]})),children:u.short},u.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:xe.map(u=>e.jsx(W,{label:u.label,checked:v.includes(u.id),disabled:!k(u.id),onChange:b=>o(j=>{const S=j.placements??["home"],x=b?[...S,u.id]:S.filter(y=>y!==u.id);return{...j,placements:x.length?[...new Set(x)]:S}})},u.id))})]}),e.jsx(q,{label:"Priority",hint:"Higher rules win when schedules overlap.",children:e.jsx("input",{type:"number",min:-1e3,max:1e3,step:10,value:i.priority,onChange:u=>o(b=>({...b,priority:Number(u.target.value)}))})})]}),e.jsx(W,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:i.enabled,onChange:u=>o(b=>({...b,enabled:u}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx(A,{variant:"primary",disabled:!g,onClick:()=>l(i),children:t?"Add rule":"Save rule"})]})]})})}function Dn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),a=s==null?void 0:s.features,v=(a==null?void 0:a.features)??[],k=(s==null?void 0:s.clients)??[],h=(a==null?void 0:a.revision)??0,m=(j,S,x,y)=>i(S,async()=>{await l(()=>F.post("/admin/api/features",{action:j,expectedRevision:h,overrides:y??{}}),x),d(null),await t()}),g=k.filter(j=>(j.capabilities??[]).includes("server_features_v1")).length,u=!!(a!=null&&a.safeMode),b=(j,S)=>({...Object.fromEntries(v.filter(x=>x.source==="override").map(x=>[x.key,x.enabled])),[j]:S});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(B,{message:n}),r||!a?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"Control plane",intro:"Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional.",icon:"sliders",tone:"ok",actions:e.jsx(A,{variant:u?void 0:"danger",busy:c==="safe",onClick:()=>u?void m("leave-safe-mode","safe","Safe mode ended."):d({action:"safe-mode",title:"Enable safe mode?",body:"Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.",label:"Enable safe mode"}),children:u?"Leave safe mode":"Enable safe mode"}),children:e.jsx(fs,{tiles:[{label:"features active",value:`${v.filter(j=>j.enabled).length} / ${v.length}`},{label:"explicit overrides",value:N(v.filter(j=>j.source==="override").length)},{label:"televisions reporting the control plane",value:`${g} / ${k.length}`},{label:"published revision",value:`r${N(h)}`}]})}),e.jsx(de,{cols:"2",children:v.length===0?e.jsx(I,{title:"Nothing registered",icon:"sliders",children:e.jsx(G,{children:"No server features are registered."})}):v.map(j=>e.jsxs(I,{title:j.name,intro:j.description,actions:e.jsx(C,{tone:j.enabled?"ok":void 0,children:j.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",j.recovery]}),children:[e.jsx(W,{label:j.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:j.enabled,disabled:c===j.key,onChange:S=>d({action:"feature",key:j.key,enabled:S,title:`${S?"Turn on":"Turn off"} ${j.name}?`,body:`${S?"Enable":"Disable"} this feature for every compatible television. ${j.recovery}`,label:S?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(Q,{children:j.key}),e.jsxs(Q,{children:["protocol ",N(j.minimumProtocol),"+"]}),e.jsx(Q,{tone:j.compatible?"ok":"warn",children:j.compatible?"server compatible":"compatibility blocked"}),e.jsx(Q,{tone:"note",children:j.area})]})]},j.key))}),e.jsx(I,{children:e.jsxs("div",{className:"row",children:[e.jsx(A,{disabled:!a.canRollback,onClick:()=>d({action:"rollback",title:"Roll back one revision?",body:"The previous published feature revision is restored on every television.",label:"Roll back"}),children:"Roll back one revision"}),e.jsx(A,{onClick:()=>d({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),u?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["live · revision r",N(h)]})]})})]}),o?e.jsx(he,{title:o.title,body:o.body,confirmLabel:o.label,destructive:o.action!=="rollback",busy:c===o.action,onConfirm:()=>void m(o.action==="feature"?"save":o.action,o.action==="feature"?o.key??"feature":o.action,`${o.label} done.`,o.action==="feature"&&o.key?b(o.key,!!o.enabled):void 0),onCancel:()=>d(null)}):null]})}function qn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l,show:c}=se(),{busy:i,run:o}=Y(),[d,a]=p.useState(!0),[v,k]=p.useState("6.5"),[h,m]=p.useState(!1);p.useEffect(()=>{var u,b;h||!s||(a(((u=s.playbackPolicy)==null?void 0:u.prerollEnabled)!==!1),k(String((((b=s.playbackPolicy)==null?void 0:b.prerollDurationMs)??6500)/1e3)))},[s,h]);const g=()=>o("save",async()=>{const u=Number(v);if(!Number.isFinite(u)||u<1||u>30){c("The preroll duration must be between 1 and 30 seconds.","bad");return}await l(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(u*1e3)}),"Playback policy saved."),m(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsxs(I,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:d?e.jsxs(C,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(C,{children:"off"}),footer:e.jsx(A,{variant:"primary",busy:i==="save",onClick:()=>void g(),children:"Save playback policy"}),children:[e.jsx(W,{label:"Show the preroll before a title starts",checked:d,onChange:u=>{a(u),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Duration",hint:"Between 1 and 30 seconds. The stream is already playing behind it.",children:e.jsx("input",{type:"number",min:1,max:30,step:.5,value:v,onChange:u=>{k(u.target.value),m(!0)}})})})]})]})}function Fn(){const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),[a,v]=p.useState(null),[k,h]=p.useState(!1),m=s==null?void 0:s.subtitles,g=m==null?void 0:m.stored;p.useEffect(()=>{o||!m||d({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,o]);const u=x=>d(y=>y&&{...y,...x}),b=()=>i("save",async()=>{o&&(await l(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:o.bazarr,openSubtitlesEnabled:o.openSubtitles,openSubtitlesApiKey:o.key.trim(),clearOpenSubtitlesApiKey:o.clearKey,openSubtitlesUsername:o.username.trim(),openSubtitlesPassword:o.password,clearOpenSubtitlesLogin:o.clearLogin}),"Subtitle settings saved."),d(null),await t())}),j=()=>i("test",async()=>{v(null);const x=await l(()=>F.post("/admin/api/subtitle-test"));v((x==null?void 0:x.results)??[])}),S=()=>i("clear",async()=>{await l(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(B,{message:n}),r||!m||!o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:N((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:N((g==null?void 0:g.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:O(g==null?void 0:g.latest),small:!0,icon:"clock"}]}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"Bazarr",intro:"Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it.",icon:"wrench",tone:"data",actions:m.bazarrConfigured?o.bazarr?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{children:"off"}):e.jsx(C,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:m.bazarrConfigured?`Configured at ${m.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(W,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:o.bazarr,disabled:!m.bazarrConfigured,onChange:x=>u({bazarr:x})})}),e.jsxs(I,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:m.openSubtitlesEnabled?e.jsx(C,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(C,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(W,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:o.openSubtitles,onChange:x=>u({openSubtitles:x})}),e.jsx(q,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:x=>u({key:x.target.value})})}),e.jsx(W,{label:"Remove the saved key",checked:o.clearKey,onChange:x=>u({clearKey:x})}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:o.username,placeholder:"Not signed in",onChange:x=>u({username:x.target.value})})}),e.jsx(q,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.password,onChange:x=>u({password:x.target.value})})})]}),e.jsx(W,{label:"Sign out and forget the account",checked:o.clearLogin,onChange:x=>u({clearLogin:x})})]})]}),e.jsxs(I,{children:[e.jsxs("div",{className:"row",children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>void b(),children:"Save subtitle settings"}),e.jsx(A,{busy:c==="test",icon:"pulse",onClick:()=>void j(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:m.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),a===null?null:a.length===0?e.jsx(G,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:a.map(x=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:x.provider}),e.jsx("p",{children:x.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(C,{tone:x.ok?"ok":"bad",children:x.ok?"reachable":"not reachable"})})]},x.provider))})]}),e.jsx(I,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:g!=null&&g.count?e.jsxs(C,{tone:"data",children:[N(g.count)," files · ",fe(g.bytes)]}):e.jsx(C,{children:"nothing held"}),footer:e.jsx(A,{variant:"danger",disabled:!(g!=null&&g.count),onClick:()=>h(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),k?e.jsx(he,{title:"Delete every stored subtitle?",body:"Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby.",confirmLabel:"Delete",destructive:!0,busy:c==="clear",onConfirm:()=>void S(),onCancel:()=>h(!1)}):null]})}function Pn(){var z;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(null),[a,v]=p.useState(!1),[k,h]=p.useState(!1),[m,g]=p.useState(""),[u,b]=p.useState(""),[j,S]=p.useState(!1),[x,y]=p.useState(null),[R,M]=p.useState(""),H=p.useRef(""),w=s==null?void 0:s.updatePolicy;p.useEffect(()=>{o||!w||d({version:w.latestVersion??"",url:w.downloadUrl??"",notes:w.notes??"",retireBelow:w.retireBelowVersion??"",required:!!w.minimumVersion&&w.minimumVersion===w.latestVersion,destructive:!!w.retireBelowVersion&&w.retireBelowVersion===w.latestVersion})},[w,o]),p.useEffect(()=>{let f=!0;const D=async()=>{try{const P=await F.get("/admin/api/release-builder");if(!f)return;y(P),M(""),P.state==="succeeded"&&P.finishedAt&&H.current!==P.finishedAt&&(H.current=P.finishedAt,d(null),await t())}catch(P){if(!f)return;M(P instanceof Error?P.message:"The release builder is unavailable.")}};D();const $=window.setInterval(()=>void D(),3e3);return()=>{f=!1,window.clearInterval($)}},[t]);const L=f=>i(f?"save":"off",async()=>{o&&(await l(()=>F.post("/admin/api/update-policy",{enabled:f,latestVersion:o.version.trim(),downloadUrl:o.url.trim(),notes:o.notes.trim(),required:o.required,destructive:o.destructive,retireBelowVersion:o.retireBelow.trim()}),f?"Update policy saved.":"Update prompts turned off."),v(!1),d(null),await t())}),X=!!(w!=null&&w.minimumVersion)&&(w==null?void 0:w.minimumVersion)===(w==null?void 0:w.latestVersion),re=!!(w!=null&&w.retireBelowVersion)&&(w==null?void 0:w.retireBelowVersion)===(w==null?void 0:w.latestVersion),K=f=>d(D=>D&&{...D,...f}),E=()=>i("release",async()=>{const f=await l(()=>F.post("/admin/api/release-builder",{tag:m.trim(),notes:u.trim(),mandatory:j}),"Memby release started.");f&&(y(f),M(""),h(!1))}),T=(x==null?void 0:x.state)==="succeeded"?"ok":(x==null?void 0:x.state)==="failed"?"bad":(x==null?void 0:x.state)==="running"?"warn":void 0;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(B,{message:n}),e.jsx(B,{message:R}),e.jsxs(I,{title:"Build and publish",intro:"Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway.",icon:"download",tone:"data",actions:e.jsx(C,{tone:T,children:(x==null?void 0:x.state)??"checking"}),footer:e.jsx(A,{variant:"primary",busy:c==="release"||(x==null?void 0:x.state)==="running",disabled:!!R,onClick:()=>j?h(!0):void E(),children:m.trim()?`Build ${m.trim()}`:"Build latest release"}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"GitHub tag override",hint:"Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades.",children:e.jsx("input",{type:"text",value:m,disabled:(x==null?void 0:x.state)==="running",placeholder:"v0.2.64 (blank uses latest)",onChange:f=>g(f.target.value)})}),e.jsx(q,{label:"Release notes",hint:"Leave blank to use this version's CHANGELOG entry.",children:e.jsx("input",{type:"text",value:u,disabled:(x==null?void 0:x.state)==="running",placeholder:"What's new on the television",onChange:f=>b(f.target.value)})})]}),e.jsx(W,{label:"Make this update required",hint:"Older televisions cannot dismiss the update prompt. You will confirm before the build starts.",checked:j,disabled:(x==null?void 0:x.state)==="running",onChange:S}),x!=null&&x.message?e.jsx(me,{tone:T,children:x.message}):null,(z=x==null?void 0:x.logs)!=null&&z.length?e.jsx("pre",{className:"code",children:x.logs.join(` -`)}):null,e.jsxs(me,{children:["Command-line fallback: ",e.jsx("code",{children:(x==null?void 0:x.fallback)??"docker compose run --rm --build memby-builder release"})]})]}),r||!o?e.jsx(_,{rows:1}):e.jsxs(I,{title:"Update policy",intro:"Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works.",icon:"download",tone:"info",actions:w!=null&&w.enabled?e.jsxs(C,{tone:X?"warn":"ok",children:[re?"sign-out · ":X?"required · ":"optional · ",w.latestVersion]}):e.jsx(C,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:c==="save",onClick:()=>o.required?v(!0):void L(!0),children:"Save policy"}),e.jsx(A,{busy:c==="off",onClick:()=>void L(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Latest version",children:e.jsx("input",{type:"text",value:o.version,placeholder:"0.2.63",onChange:f=>K({version:f.target.value})})}),e.jsx(q,{label:"APK URL",children:e.jsx("input",{type:"text",value:o.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:f=>K({url:f.target.value})})})]}),e.jsx(q,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:o.notes,placeholder:"One line the viewer reads",onChange:f=>K({notes:f.target.value})})}),e.jsx(q,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:o.retireBelow,placeholder:"0.2.44",onChange:f=>K({retireBelow:f.target.value})})}),e.jsx(W,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:o.required,onChange:f=>K({required:f})}),e.jsx(W,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:o.destructive,onChange:f=>K(f?{destructive:!0,required:!0,retireBelow:o.version.trim()}:{destructive:!1,retireBelow:o.retireBelow.trim()===o.version.trim()?"":o.retireBelow})})]}),a&&o?e.jsx(he,{title:o.destructive?"Sign every older television out?":"Require this update?",body:o.destructive?"This deletes sessions on every older television and forces viewers to sign in again after updating.":"Required updates block the home screen on every television below this version until they update.",confirmLabel:"Publish",destructive:o.destructive,busy:c==="save",onConfirm:()=>void L(!0),onCancel:()=>v(!1)}):null,k?e.jsx(he,{title:"Build a required update?",body:"When this signed APK is published, every older television will be blocked until it installs the update.",confirmLabel:"Build and publish",busy:c==="release",onConfirm:()=>void E(),onCancel:()=>h(!1)}):null]})}const On=2e4,Bn=3e3,Vn=[{value:60,label:"Every minute"},{value:300,label:"Every 5 minutes"},{value:600,label:"Every 10 minutes"},{value:900,label:"Every 15 minutes"},{value:1800,label:"Every 30 minutes"},{value:3600,label:"Hourly"},{value:10800,label:"Every 3 hours"},{value:21600,label:"Every 6 hours"},{value:43200,label:"Every 12 hours"},{value:86400,label:"Daily"},{value:604800,label:"Weekly"}];function Un(s){const n=[...Vn];for(const r of[s.defaultIntervalSeconds,s.intervalSeconds])r>0&&!n.some(t=>t.value===r)&&n.push({value:r,label:Te(r).replace(/^every /,"Every ")});return n.sort((r,t)=>r.value-t.value)}function Xe(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Wn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),[t,l]=p.useState(!1),{data:c,error:i,loading:o,reload:d}=J("/admin/api/tasks?limit=60",{pollMs:t?Bn:On}),a=(c==null?void 0:c.tasks)??[],v=a.some(y=>y.running);v!==t&&l(v);const k=y=>r(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await d()}),h=(y,R)=>r(`${y.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{enabled:R}),R?`${y.name} switched on.`:`${y.name} switched off.`),await d()}),m=(y,R)=>r(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:R}),`${y.name} now runs ${Te(R)}.`),await d()}),g=y=>r(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:0}),`${y.name} back to its default cadence.`),await d()}),u=a.filter(y=>{var R;return((R=y.lastRun)==null?void 0:R.status)==="failed"}).length,b=a.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,j=a.filter(y=>!y.enabled).length,S=(c==null?void 0:c.groups)??[],x=a.filter(y=>!y.group);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Scheduled tasks",intro:"The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"Tasks",value:N(a.length),icon:"clock",tone:"info"},{label:"Running now",value:N(a.filter(y=>y.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:N(u),icon:"alert",tone:u>0?"bad":void 0},{label:"Switched off",value:N(j),icon:"power",tone:j>0?"warn":void 0},{label:"Retimed",value:N(b),icon:"clock",tone:b>0?"note":void 0}]}),u>0?e.jsx(me,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...S,...x.length>0?[""]:[]].map(y=>{const R=a.filter(M=>M.group===y);return R.length===0?null:e.jsx(I,{title:y||"Other",icon:y==="System"?"chip":y==="Analytics"?"chart":"wrench",tone:y==="System"?"info":y==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:R.map(M=>{var H;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[M.name," ",M.running?e.jsx(C,{tone:"info",children:"running"}):null,M.enabled?null:e.jsx(C,{tone:"warn",children:"off"}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(C,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:M.description}),e.jsxs("p",{className:"quiet",children:[Te(M.intervalSeconds),M.enabled&&M.nextRun?` · next ${be(M.nextRun).replace(" ago","")}`:"",M.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:O(M.lastRun.startedAt),children:be(M.lastRun.startedAt)}),` in ${ke(M.lastRun.durationMs)}`,M.lastRun.detail?` — ${M.lastRun.detail}`:""]}):" · never run"]}),(H=M.lastRun)!=null&&H.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(C,{tone:"bad",children:M.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[M.lastRun?e.jsx(C,{tone:Xe(M.lastRun.status),children:M.lastRun.status}):e.jsx(C,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${M.name} runs`,value:M.intervalSeconds,disabled:n===`${M.id}:interval`||M.running,onChange:w=>void m(M,Number(w.target.value)),children:Un(M).map(w=>e.jsxs("option",{value:w.value,children:[w.label,w.value===M.defaultIntervalSeconds?" (default)":""]},w.value))}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(A,{size:"sm",icon:"refresh",busy:n===`${M.id}:interval`,onClick:()=>void g(M),children:"Default"}):null,e.jsx(W,{label:"",checked:M.enabled,disabled:n===`${M.id}:enabled`,onChange:w=>void h(M,w)}),e.jsx(A,{size:"sm",icon:"play",busy:n===M.id,disabled:M.running,onClick:()=>void k(M),children:"Run now"})]})]},M.id)})})},y||"other")}),e.jsx(I,{title:"Recent runs",intro:"Every task together and in order, which is what shows two jobs interfering with each other.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Started"}),e.jsx("th",{children:"Task"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"}),e.jsx("th",{children:"Detail"})]})}),e.jsx("tbody",{children:((c==null?void 0:c.runs.length)??0)===0?e.jsx(te,{columns:6,children:"No task has run yet."}):c==null?void 0:c.runs.map(y=>{var R;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(y.startedAt),children:be(y.startedAt)}),e.jsx("td",{children:((R=a.find(M=>M.id===y.taskId))==null?void 0:R.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(C,{tone:Xe(y.status),children:y.status})}),e.jsx("td",{className:"num muted",children:ke(y.durationMs)}),e.jsx("td",{className:"muted",children:y.error||y.detail||"—"})]},y.id)})})]})})})]})]})}const _n={id:"",name:"Discord",url:"",enabled:!0,events:[]};function zn(){const{wrap:s,show:n}=se(),{busy:r,run:t}=Y(),{data:l,error:c,loading:i,reload:o}=J("/admin/api/integrations",{pollMs:6e4}),[d,a]=p.useState(null),[v,k]=p.useState(null),h=(l==null?void 0:l.catalogue)??[],m=(l==null?void 0:l.integrations)??[],g=S=>a({id:S.id,name:S.name,url:"",enabled:S.enabled,events:S.events??[]}),u=()=>t("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(a(null),await o())}),b=S=>t("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(S.id)}`),`${S.name} removed.`),k(null),await o()}),j=S=>t(`test:${S.id}`,async()=>{const x=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(S.id)}/test`));x&&n(x.message,x.ok?"ok":"bad"),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Integrations",intro:"Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere.",actions:e.jsx(A,{variant:"primary",icon:"plus",onClick:()=>a(_n),children:"Add a webhook"})}),e.jsx(B,{message:c}),e.jsx(Hn,{}),e.jsx(Kn,{}),e.jsx(Gn,{}),((l==null?void 0:l.dropped)??0)>0?e.jsxs(me,{tone:"warn",children:[N((l==null?void 0:l.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,i?e.jsx(_,{}):m.length===0&&!d?e.jsx(I,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(G,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):m.map(S=>e.jsx(Zn,{integration:S,catalogue:h,busy:r,onEdit:()=>g(S),onTest:()=>void j(S),onRemove:()=>k(S)},S.id)),d?e.jsx(Jn,{draft:d,catalogue:h,busy:r==="save",onChange:a,onSave:()=>void u(),onCancel:()=>a(null)}):null,v?e.jsx(he,{title:`Remove ${v.name}?`,body:"The webhook address and its delivery history go with it. Events already published stay in the activity feed.",confirmLabel:"Remove",destructive:!0,busy:r==="remove",onConfirm:()=>void b(v),onCancel:()=>k(null)}):null]})}function Hn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/arr-integrations"),o=d=>r("arr-integrations",async()=>{t&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:d.sonarrEnabled??t.sonarrEnabled,radarrEnabled:d.radarrEnabled??t.radarrEnabled}),"Integration settings saved."),await i())});return e.jsxs(I,{title:"Sonarr and Radarr",intro:"Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests.",icon:"plug",tone:"info",children:[e.jsx(B,{message:l??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(W,{label:"Sonarr enabled",hint:t!=null&&t.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(t!=null&&t.sonarrEnabled),disabled:!(t!=null&&t.sonarrConfigured)||n==="arr-integrations",onChange:d=>void o({sonarrEnabled:d})}),e.jsx(W,{label:"Radarr enabled",hint:t!=null&&t.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(t!=null&&t.radarrEnabled),disabled:!(t!=null&&t.radarrConfigured)||n==="arr-integrations",onChange:d=>void o({radarrEnabled:d})})]})]})}function Kn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/sonarr-request-policy"),[o,d]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const k=()=>r("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:o,searchImmediately:a}),"Sonarr TV request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(m=>m.id===o);return e.jsxs(I,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="sonarr-request-policy",disabled:c||o<=0,onClick:()=>void k(),children:"Save Sonarr policy"}),children:[e.jsx(B,{message:l??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:m=>d(Number(m.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:a,onChange:v}),h?e.jsxs(me,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), be monitored using Memby’s existing all-episodes strategy, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Gn(){const{wrap:s}=se(),{busy:n,run:r}=Y(),{data:t,error:l,loading:c,reload:i}=J("/admin/api/radarr-request-policy"),[o,d]=p.useState(0),[a,v]=p.useState(!1);p.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const k=()=>r("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:o,searchImmediately:a}),"Radarr movie request policy saved."),await i()}),h=t==null?void 0:t.profiles.find(m=>m.id===o);return e.jsxs(I,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(A,{variant:"primary",busy:n==="radarr-request-policy",disabled:c||o<=0,onClick:()=>void k(),children:"Save Radarr policy"}),children:[e.jsx(B,{message:l??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Request quality profile",hint:"Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:m=>d(Number(m.target.value)),disabled:!(t!=null&&t.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),t==null?void 0:t.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(W,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:a,onChange:v}),h?e.jsxs(me,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:h.name})," (profile ID ",h.id,"), remain monitored, and ",a?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Zn({integration:s,catalogue:n,busy:r,onEdit:t,onTest:l,onRemove:c}){const i=s.health,o=!i.lastFailure||i.lastSuccess&&i.lastSuccess>i.lastFailure,d=s.events??[];return e.jsxs(I,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{tone:"warn",children:"off"}),i.deliveries>0?e.jsx(C,{tone:o?"ok":"bad",children:o?"delivering":"failing"}):e.jsx(C,{children:"never used"}),e.jsx(A,{size:"sm",icon:"pulse",busy:r===`test:${s.id}`,onClick:l,children:"Test"}),e.jsx(A,{size:"sm",onClick:t,children:"Edit"}),e.jsx(A,{size:"sm",variant:"danger",icon:"trash",onClick:c,title:"Remove"})]}),children:[e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Events sent"}),e.jsx("p",{children:d.length===0?"None selected — this destination is configured but will never post anything.":d.map(a=>{var v;return((v=n.find(k=>k.type===a))==null?void 0:v.label)??a}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:i.lastSuccess?O(i.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:i.deliveries>0?e.jsxs("span",{className:"quiet",children:[N(i.deliveries)," attempts, ",N(i.failures)," failed"]}):null})]}),i.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[O(i.lastFailure),i.lastError?` — ${i.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Attempted"}),e.jsx("th",{children:"Event"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:s.deliveries.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(a.attemptedAt),children:be(a.attemptedAt)}),e.jsx("td",{className:"muted",children:a.eventType}),e.jsx("td",{children:a.success?e.jsx(C,{tone:"ok",children:a.statusCode||"ok"}):e.jsx(C,{tone:"bad",children:a.error||a.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ke(a.durationMs)})]},a.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(te,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Jn({draft:s,catalogue:n,busy:r,onChange:t,onSave:l,onCancel:c}){const i=[...new Set(n.map(d=>d.group))],o=(d,a)=>t({...s,events:a?[...s.events,d]:s.events.filter(v=>v!==d)});return e.jsxs(I,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"primary",busy:r,onClick:l,children:s.id?"Save":"Add"}),e.jsx(A,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx("span",{className:"spacer"}),s.events.length===0?e.jsx("span",{className:"quiet",children:"Nothing selected — this destination would never post."}):e.jsxs("span",{className:"quiet",children:[s.events.length," events selected"]})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>t({...s,name:d.target.value})})}),e.jsx(q,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>t({...s,url:d.target.value})})})]}),e.jsx(W,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:d=>t({...s,enabled:d})}),i.map(d=>e.jsxs("div",{children:[e.jsx("div",{className:"card-head",style:void 0,children:e.jsx("div",{className:"card-head-text",children:e.jsx("h2",{children:d})})}),n.filter(a=>a.group===d).map(a=>e.jsx(W,{label:a.label,hint:a.description,checked:s.events.includes(a.type),onChange:v=>o(a.type,v)},a.type))]},d))]})}function Yn(){var u;const{status:s,error:n,loading:r,reload:t}=ie(),{wrap:l}=se(),{busy:c,run:i}=Y(),[o,d]=p.useState(""),[a,v]=p.useState(!1),[k,h]=p.useState(!1),m=!!((u=s==null?void 0:s.maintenance)!=null&&u.enabled);p.useEffect(()=>{var b;!k&&s&&d(((b=s.maintenance)==null?void 0:b.message)??"")},[s,k]);const g=b=>i(b?"on":"off",async()=>{await l(()=>F.post("/admin/api/maintenance",{enabled:b,message:o}),b?"Memby is offline for every television.":"Memby is back online."),v(!1),h(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsx(I,{title:"Gateway availability",intro:"Takes Memby offline for every television, independently of Emby. Sign-in and all content calls answer 503 with the message below, and the television shows it in place of the launcher rows. This console keeps working.",icon:"power",tone:m?"bad":"warn",actions:m?e.jsx(C,{tone:"bad",children:"offline"}):e.jsx(C,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(A,{variant:"danger",disabled:m,onClick:()=>v(!0),children:"Go offline"}),e.jsx(A,{disabled:!m,busy:c==="off",onClick:()=>void g(!1),children:"Bring back online"})]}),children:e.jsx(q,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:o,placeholder:"Back shortly — upgrading the server",onChange:b=>{d(b.target.value),h(!0)}})})}),a?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately and show your message in place of the launcher. This console keeps working.",confirmLabel:"Go offline",destructive:!0,busy:c==="on",onConfirm:()=>void g(!0),onCancel:()=>v(!1)}):null]})}function Xn(){const{status:s,error:n,loading:r}=ie(),t=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(B,{message:n}),r?e.jsx(_,{rows:1}):e.jsx(I,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:8,children:"Nothing has been imported yet."}):t.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(l.startedAt)}),e.jsx("td",{children:l.kind}),e.jsx("td",{className:"muted",children:l.trigger}),e.jsx("td",{children:e.jsx(C,{tone:l.status==="success"?"ok":l.status==="running"?"warn":"bad",children:l.status})}),e.jsx("td",{className:"num",children:N(l.itemsSeen)}),e.jsx("td",{className:"num",children:N(l.itemsUpserted)}),e.jsx("td",{className:"num",children:N(l.itemsRemoved)}),e.jsx("td",{className:"muted",children:l.error||""})]},l.id||l.startedAt))})]})})})]})}const Qe={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},es=2e4,Qn=5e3,ye=48,et=31,ss=8,st=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],ns=new Map(st.map((s,n)=>[s,n])),nt=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ts=new WeakMap;function we(s){const n=ts.get(s);if(n)return n;const r=Object.entries(s.attributes??{}).sort((l,c)=>{const i=ns.get(l[0])??(l[0]==="error"?1e3:100),o=ns.get(c[0])??(c[0]==="error"?1e3:100);return i-o||l[0].localeCompare(c[0])}),t={fields:r,summary:r.map(([l,c])=>`${l}=${String(c)}`).join(" "),haystack:[s.message,...r.flat()].join(" ").toLowerCase(),occurred:nt.format(new Date(s.occurredAt))};return ts.set(s,t),t}const tt=s=>s.replace(/_/g," "),it=p.memo(function({event:n,index:r,onInspect:t}){const l=we(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${r*ye}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[l.occurred,e.jsxs("small",{children:["#",n.sequence]})]}),e.jsx("span",{className:"lvl",children:n.level}),e.jsx("span",{className:"msg",title:n.message,children:n.message}),e.jsx("button",{type:"button",className:"logattrs-button",title:l.summary||"No structured details",onClick:()=>t(n.sequence),children:l.summary||"View record"})]})});function at(){var D;const[s,n]=p.useState([]),[r,t]=p.useState(0),[l,c]=p.useState(!1),[i,o]=p.useState("INFO"),[d,a]=p.useState(""),[v,k]=p.useState(""),[h,m]=p.useState({top:0,height:600}),[g,u]=p.useState(null),b=p.useDeferredValue(d.trim().toLowerCase()),j=p.useRef(0),S=p.useRef(!1),x=p.useRef(0),y=p.useRef(null),R=p.useRef(!0),M=p.useRef(void 0),H=p.useCallback(async()=>{if(l||S.current||document.hidden)return;S.current=!0;const $=x.current,P=[];let oe=0;try{let ce=0,le;do le=await F.get(`/admin/api/events?after=${j.current}&limit=1000`),j.current=le.next||j.current,oe+=le.dropped||0,P.push(...le.events??[]),ce+=1;while(le.hasMore&&ce<20);k("")}catch(ce){k(ce instanceof Error?ce.message:String(ce))}finally{P.length>0&&$===x.current&&n(ce=>{const le=ce.concat(P);return le.length>es?le.slice(le.length-es):le}),oe>0&&$===x.current&&t(ce=>ce+oe),S.current=!1}},[l]);p.useEffect(()=>{if(l)return;let $;const P=()=>{window.clearInterval($),$=document.hidden?void 0:window.setInterval(()=>void H(),Qn)},oe=()=>{P(),document.hidden||H()};return H(),P(),document.addEventListener("visibilitychange",oe),()=>{window.clearInterval($),document.removeEventListener("visibilitychange",oe)}},[H,l]);const w=p.useMemo(()=>{const $=Qe[i]??20;return s.filter(P=>(Qe[P.level]??0)>=$&&(!b||we(P).haystack.includes(b)))},[s,i,b]),L=((D=w.at(-1))==null?void 0:D.sequence)??0,X=Math.max(0,h.top-et),re=Math.ceil(h.height/ye)+ss*2,K=Math.min(Math.max(0,Math.floor(X/ye)-ss),Math.max(0,w.length-re)),E=w.slice(K,K+re),T=p.useMemo(()=>s.find($=>$.sequence===g),[s,g]);p.useLayoutEffect(()=>{const $=y.current;!$||!R.current||($.scrollTop=$.scrollHeight,m({top:$.scrollTop,height:$.clientHeight}))},[L,b,i]),p.useEffect(()=>()=>window.cancelAnimationFrame(M.current??0),[]);const z=()=>{const $=y.current;$&&(R.current=$.scrollHeight-$.scrollTop-$.clientHeight{m({top:$.scrollTop,height:$.clientHeight})}))},f=()=>{const $=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),P=document.createElement("a");P.href=URL.createObjectURL($),P.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,P.click(),window.setTimeout(()=>URL.revokeObjectURL(P.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(B,{message:v}),e.jsxs(I,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Level",children:e.jsxs("select",{value:i,onChange:$=>o($.target.value),children:[e.jsx("option",{value:"DEBUG",children:"Debug and above"}),e.jsx("option",{value:"INFO",children:"Info and above"}),e.jsx("option",{value:"WARN",children:"Warnings and errors"}),e.jsx("option",{value:"ERROR",children:"Errors only"})]})}),e.jsx(q,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:d,placeholder:"Person, television, title, component, path…",onChange:$=>a($.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(A,{onClick:()=>c($=>!$),icon:l?"play":"clock",children:l?"Resume":"Pause"}),e.jsx(A,{onClick:()=>{x.current+=1,n([]),t(0),u(null)},children:"Clear view"}),e.jsx(A,{onClick:f,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:y,onScroll:z,role:"log","aria-label":"Server events",children:[e.jsxs("div",{className:"loghead","aria-hidden":"true",children:[e.jsx("span",{children:"Time"}),e.jsx("span",{children:"Level"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Details"})]}),w.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match this filter."}):e.jsx("div",{className:"logbody",style:{height:`${w.length*ye}px`},children:E.map(($,P)=>e.jsx(it,{event:$,index:K+P,onInspect:u},$.sequence))})]}),e.jsxs("p",{className:"hint",children:[N(s.length)," retained · ",N(w.length)," matching",w.length?` · ${N(E.length)} rows mounted`:"",r?` · ${N(r)} overwritten before delivery`:"",l?" · paused":""]}),T?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${T.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:T.message}),e.jsxs("span",{children:[we(T).occurred," · ",T.level," · record #",T.sequence]})]}),e.jsx(A,{size:"sm",variant:"quiet",onClick:()=>u(null),children:"Close"})]}),we(T).fields.length?e.jsx("dl",{children:we(T).fields.map(([$,P])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:tt($)}),e.jsx("dd",{children:String(P)})]},$))}):e.jsx(me,{children:"No structured details were attached to this record."})]}):null]})]})}const is=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","notifications","profiles","settings"];function pe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function rt(){const[s,n]=p.useState(30),[r,t]=p.useState(""),l=qe(),c=p.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:r})}`,[s,r]),{data:i,error:o,loading:d}=J(c),a=i==null?void 0:i.stats,v=(i==null?void 0:i.users)??[],k=(i==null?void 0:i.actions)??[],h=(i==null?void 0:i.paths)??[],m=h[0],g=p.useMemo(()=>{const u=new Map(((i==null?void 0:i.features)??[]).map(j=>[j.feature,j])),b=new Map(is.map((j,S)=>[j,S]));return[...new Set([...is,...u.keys()])].map(j=>({name:j,stat:u.get(j)})).sort((j,S)=>{var y,R;const x=(((y=S.stat)==null?void 0:y.uses)??0)-(((R=j.stat)==null?void 0:R.uses)??0);return x||(b.get(j.name)??Number.MAX_SAFE_INTEGER)-(b.get(S.name)??Number.MAX_SAFE_INTEGER)})},[i==null?void 0:i.features]);return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(B,{message:o}),e.jsx(I,{title:"Journey health",intro:"Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored.",icon:"people",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:u=>n(Number(u.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(q,{label:"User",children:e.jsxs("select",{value:r,onChange:u=>{const b=u.target.value;t(b),b&&l(`/admin/journeys/${encodeURIComponent(b)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(u=>e.jsx("option",{value:u.userId,children:u.username||u.userId},u.userId))]})})]}),children:d?e.jsx(_,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:"journeys",value:N(a==null?void 0:a.journeys),icon:"list",tone:"data"},{label:"viewers",value:N(a==null?void 0:a.viewers),icon:"people",tone:"info"},{label:"completion",value:Le(a==null?void 0:a.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:N(a==null?void 0:a.abandoned),icon:"alert",tone:"note"},{label:"active now",value:N(a==null?void 0:a.active),icon:"pulse",tone:"info"},{label:"average steps",value:((a==null?void 0:a.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ke(a==null?void 0:a.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(i==null?void 0:i.retentionDays)??90} days`,small:!0,icon:"clock"}]}),e.jsxs("div",{className:"summary-grid",children:[e.jsxs("div",{className:"summary",children:[e.jsxs("div",{className:"summary-head",children:[e.jsx("b",{children:"Visit completion"}),e.jsx("strong",{children:Le(a==null?void 0:a.completionRate)})]}),e.jsx(Ys,{value:(a==null?void 0:a.completed)??0,total:(a==null?void 0:a.journeys)??0}),e.jsxs("p",{children:[N(a==null?void 0:a.completed)," completed · ",N(a==null?void 0:a.abandoned)," abandoned ·"," ",N(a==null?void 0:a.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:m?`${pe(m.from)} → ${pe(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${N(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(de,{cols:"2",children:[e.jsx(I,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Action"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{className:"num",children:"Visits"})]})}),e.jsx("tbody",{children:k.length===0?e.jsx(te,{columns:3,children:"No significant actions in this window."}):k.map(u=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:pe(u.action)}),e.jsx("span",{className:"table-sub",children:pe(u.category)})]}),e.jsx("td",{className:"num",children:N(u.events)}),e.jsx("td",{className:"num",children:N(u.journeys)})]},`${u.category}:${u.action}`))})]})})}),e.jsx(I,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:h.length===0?e.jsx(te,{columns:2,children:"No repeated paths in this window."}):h.map((u,b)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[pe(u.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",pe(u.to)]}),e.jsx("td",{className:"num",children:N(u.count)})]},`${u.from}:${u.to}:${b}`))})]})})})]}),e.jsx(I,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Feature"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{children:"Last used"}),e.jsx("th",{children:"Status"})]})}),e.jsx("tbody",{children:g.map(({name:u,stat:b})=>{const j=(b==null?void 0:b.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:pe(u)}),e.jsx("td",{className:"num",children:N(j)}),e.jsx("td",{className:"muted nowrap",children:b?O(b.lastUsedAt):"—"}),e.jsx("td",{children:j===0?e.jsx(C,{tone:"warn",children:"not used"}):j<3?e.jsx(C,{tone:"note",children:"rare"}):e.jsx(C,{tone:"ok",children:"used"})})]},u)})})]})})}),r?null:e.jsx(I,{title:"Inspect a viewer",intro:"Choose a person above to open their dedicated session and viewing-journey timeline.",icon:"journey",tone:"info",children:e.jsx("p",{className:"empty",children:"A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys."})})]})}const je=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},ws=s=>je((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),as=s=>s.itemName?`${je(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${je(s.source)} → ${je(s.target)}`:ws(s),lt=s=>({journey_start:"Opened Memby",home_open:"Opened Memby",journey_end:"Finished session",screen_view:"Viewed",select:"Selected",open:"Opened",close:"Closed",request:s.category==="playback"?"Started watching":"Requested",stop:"Stopped watching",start:"Started",complete:"Completed"})[s.action]??je(s.action);function ot(s){var r;const n=(r=[...s].reverse().find(t=>t.outcome))==null?void 0:r.outcome;return n==="success"||n==="completed"?{label:je(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:je(n),tone:"note"}:s.some(t=>t.action==="stop"&&t.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function ct(s){const n=s.reduce((r,t,l)=>(t.category==="playback"&&t.action==="request"&&r.push(l),r),[]);return n.length===0?[s]:n.map((r,t)=>s.slice(t===0?0:r,n[t+1]??s.length))}function dt(){var d,a;const{userId:s=""}=Re(),n=p.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:r,error:t,loading:l}=J(n),c=((a=(d=r==null?void 0:r.users)==null?void 0:d.find(v=>v.userId===s))==null?void 0:a.username)||s,i=p.useMemo(()=>{const v=new Map;for(const k of(r==null?void 0:r.events)??[])v.set(k.journeyId,[...v.get(k.journeyId)??[],k]);return[...v.values()].map(k=>k.sort((h,m)=>h.sequence-m.sequence)).sort((k,h)=>{var m,g;return(((m=h[0])==null?void 0:m.occurredAt)??"").localeCompare(((g=k[0])==null?void 0:g.occurredAt)??"")})},[r==null?void 0:r.events]),o=i.flatMap(v=>ct(v).map((k,h)=>{var m;return{events:k,key:`${(m=v[0])==null?void 0:m.journeyId}:${h}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:`${c}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(ee,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(B,{message:t}),l?e.jsx(_,{}):e.jsx(I,{title:"Viewing journeys",intro:`${i.length} app session${i.length===1?"":"s"} · ${o.length} viewing journey${o.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:o.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):o.map((v,k)=>{const h=v.events,m=h[0],g=[...h].reverse().find(b=>b.itemName||b.action==="select"||b.category==="playback"&&b.action==="request"),u=ot(h);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:O(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",k+1," · ",h.length," recorded steps"]})]}),e.jsx(C,{tone:u.tone,children:u.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(ne,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:ws(m)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(ne,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:g?as(g):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(ne,{name:u.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:u.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:h.map(b=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":b.action}),e.jsxs("div",{children:[e.jsx("b",{children:lt(b)}),e.jsx("span",{children:as(b)})]}),e.jsx("time",{children:O(b.occurredAt)})]},`${b.journeyId}:${b.sequence}`))})]},v.key)})})})]})}function rs(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function ht(){const[s,n]=p.useState(30),{data:r,error:t,loading:l}=J(`/admin/api/analytics?days=${s}`),c=(r==null?void 0:r.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(B,{message:t}),e.jsx(I,{title:"Launcher rows",intro:"Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth.",icon:"chart",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:i=>n(Number(i.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:l?e.jsx(_,{rows:1}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Row"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{className:"num",children:"Dwell"}),e.jsx("th",{className:"num",children:"Impressions"}),e.jsx("th",{className:"num",children:"Focuses"}),e.jsx("th",{className:"num",children:"Opened"}),e.jsx("th",{className:"num",children:"Open rate"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:8,children:"No events in this window."}):c.map(i=>e.jsxs("tr",{children:[e.jsx("td",{children:rs(i.rowId)}),e.jsx("td",{className:"muted",children:rs(i.rowKind)}),e.jsx("td",{className:"num",children:ke(i.dwellMs)}),e.jsx("td",{className:"num",children:N(i.impressions)}),e.jsx("td",{className:"num",children:N(i.focuses)}),e.jsx("td",{className:"num",children:N(i.selects)}),e.jsx("td",{className:"num",children:Le(i.selectRate)}),e.jsx("td",{className:"num",children:N(i.viewers)})]},`${i.rowId}:${i.rowKind}`))})]})})})]})}function ut(){const[s,n]=p.useState(7),{data:r,error:t,loading:l}=J(`/admin/api/searches?days=${s}`),c=(r==null?void 0:r.terms)??[],i=(r==null?void 0:r.recent)??[],o=r==null?void 0:r.totals;return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(B,{message:t}),e.jsx(ae,{tiles:[{label:"searches",value:N((o==null?void 0:o.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:N((o==null?void 0:o.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:N((o==null?void 0:o.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(r==null?void 0:r.retentionDays)??30} days`,small:!0,icon:"clock"}]}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(I,{title:"What the house looks for",intro:"Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too.",icon:"search",tone:"info",actions:e.jsx(q,{label:"Window",children:e.jsxs("select",{value:s,onChange:d=>n(Number(d.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Query"}),e.jsx("th",{className:"num",children:"Searches"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Last searched"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(te,{columns:4,children:"Nothing searched in this window."}):c.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.query}),e.jsx("td",{className:"num",children:N(d.searches)}),e.jsx("td",{className:"num",children:N(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:O(d.lastAt)})]},d.query))})]})})}),e.jsx(I,{title:"As it happened",intro:"The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(te,{columns:3,children:"No searches in this window."}):i.map((d,a)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:O(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(C,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${a}`))})]})})})]})]})}function ls(s,n){if(n===0)return s>0?"new this week":"no change";const r=Math.round((s-n)/n*100);return`${r>0?"+":""}${r}% vs last week`}function mt(){const{data:s,error:n,loading:r}=J("/admin/api/views",{pollMs:6e4}),t=(s==null?void 0:s.daily)??[],l=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(B,{message:n}),r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(ae,{tiles:[{label:ls((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:N(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:ls((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:N(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(I,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet."}):t.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})}),e.jsx(I,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:l.length===0?e.jsx(te,{columns:3,children:"No home-screen visits yet today."}):l.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})})]})]})}const pt=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function xt(){var l;const s=J("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:r}=Y(),t=(c,i)=>r(`${c.id}-${i}`,async()=>{await F.post(`/admin/api/media-reports/${c.id}/status`,{status:i}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(V,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(_,{rows:4}):e.jsx(I,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(l=s.data)!=null&&l.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(c=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:pt(c)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.title})]}),e.jsxs("td",{children:[c.reason.replaceAll("_"," "),c.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.comment})]}):null]}),e.jsxs("td",{children:[c.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(C,{tone:c.replacementRequested?"note":void 0,children:c.replacementRequested?c.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(C,{tone:c.status==="resolved"?"ok":c.status==="dismissed"?void 0:"warn",children:c.status})}),e.jsx("td",{className:"nowrap muted",children:O(c.createdAt)}),e.jsxs("td",{children:[e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-acknowledged`,onClick:()=>void t(c,"acknowledged"),children:"Acknowledge"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-resolved`,onClick:()=>void t(c,"resolved"),children:"Resolve"})," ",e.jsx(A,{size:"sm",variant:"quiet",busy:n===`${c.id}-dismissed`,onClick:()=>void t(c,"dismissed"),children:"Dismiss"})]})]},c.id))})]})}):e.jsx(G,{children:"No media problems have been reported."})})]})}function jt(){return e.jsx(V,{title:"No such page",intro:"That address is not part of the console. Use the search in the bar above, or the sections on the left."})}function vt(){return e.jsx(Rs,{children:e.jsx(Ws,{children:e.jsx(Zs,{children:e.jsx(sn,{children:e.jsx(Es,{children:e.jsxs(U,{path:"/admin",element:e.jsx(Qs,{}),children:[e.jsx(U,{index:!0,element:e.jsx(nn,{})}),e.jsx(U,{path:"activity",element:e.jsx(an,{})}),e.jsx(U,{path:"accounts",element:e.jsx(rn,{})}),e.jsx(U,{path:"accounts/:userId",element:e.jsx(ln,{})}),e.jsx(U,{path:"accounts/:userId/settings",element:e.jsx(mn,{})}),e.jsx(U,{path:"clients",element:e.jsx(xn,{})}),e.jsx(U,{path:"logins",element:e.jsx(vn,{})}),e.jsx(U,{path:"devices/:deviceId",element:e.jsx(yn,{})}),e.jsx(U,{path:"library",element:e.jsx(wn,{})}),e.jsx(U,{path:"ratings",element:e.jsx(Nn,{})}),e.jsx(U,{path:"requests",element:e.jsx(Sn,{})}),e.jsx(U,{path:"recommendations",element:e.jsx(Cn,{})}),e.jsx(U,{path:"inspector",element:e.jsx(En,{})}),e.jsx(U,{path:"hero",element:e.jsx(Tn,{})}),e.jsx(U,{path:"features",element:e.jsx(Dn,{})}),e.jsx(U,{path:"playback",element:e.jsx(qn,{})}),e.jsx(U,{path:"subtitles",element:e.jsx(Fn,{})}),e.jsx(U,{path:"updates",element:e.jsx(Pn,{})}),e.jsx(U,{path:"tasks",element:e.jsx(Wn,{})}),e.jsx(U,{path:"integrations",element:e.jsx(zn,{})}),e.jsx(U,{path:"maintenance",element:e.jsx(Yn,{})}),e.jsx(U,{path:"imports",element:e.jsx(Xn,{})}),e.jsx(U,{path:"logs",element:e.jsx(at,{})}),e.jsx(U,{path:"journeys",element:e.jsx(rt,{})}),e.jsx(U,{path:"journeys/:userId",element:e.jsx(dt,{})}),e.jsx(U,{path:"views",element:e.jsx(mt,{})}),e.jsx(U,{path:"engagement",element:e.jsx(ht,{})}),e.jsx(U,{path:"searches",element:e.jsx(ut,{})}),e.jsx(U,{path:"media-reports",element:e.jsx(xt,{})}),e.jsx(U,{path:"overview",element:e.jsx(As,{to:"/admin",replace:!0})}),e.jsx(U,{path:"*",element:e.jsx(jt,{})})]})})})})})})}const ks=document.getElementById("root");if(!ks)throw new Error("the console has no root element to render into");ds(ks).render(e.jsx(p.StrictMode,{children:e.jsx(vt,{})})); diff --git a/admin-ui/dist/assets/index-C4RyKtIU.js b/admin-ui/dist/assets/index-C4RyKtIU.js new file mode 100644 index 0000000..36c2bad --- /dev/null +++ b/admin-ui/dist/assets/index-C4RyKtIU.js @@ -0,0 +1,12 @@ +import{r as x,a as Ms,u as qe,L as ne,b as Fe,m as Te,O as Es,N as Rs,c as Re,B as As,R as Is,d as V,e as $s}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))t(a);new MutationObserver(a=>{for(const c of a)if(c.type==="childList")for(const i of c.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&t(i)}).observe(document,{childList:!0,subtree:!0});function l(a){const c={};return a.integrity&&(c.integrity=a.integrity),a.referrerPolicy&&(c.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?c.credentials="include":a.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function t(a){if(a.ep)return;a.ep=!0;const c=l(a);fetch(a.href,c)}})();var ds={exports:{}},Ae={};/** + * @license React + * react-jsx-runtime.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Ts=x,Ls=Symbol.for("react.element"),Ds=Symbol.for("react.fragment"),qs=Object.prototype.hasOwnProperty,Fs=Ts.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,Ps={key:!0,ref:!0,__self:!0,__source:!0};function hs(s,n,l){var t,a={},c=null,i=null;l!==void 0&&(c=""+l),n.key!==void 0&&(c=""+n.key),n.ref!==void 0&&(i=n.ref);for(t in n)qs.call(n,t)&&!Ps.hasOwnProperty(t)&&(a[t]=n[t]);if(s&&s.defaultProps)for(t in n=s.defaultProps,n)a[t]===void 0&&(a[t]=n[t]);return{$$typeof:Ls,type:s,key:c,ref:i,props:a,_owner:Fs.current}}Ae.Fragment=Ds;Ae.jsx=hs;Ae.jsxs=hs;ds.exports=Ae;var e=ds.exports,us,Be=Ms;us=Be.createRoot,Be.hydrateRoot;const ms={overview:"M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",library:"M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4",people:"M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2",person:"M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",tv:"M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5",sliders:"M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",clock:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8",pulse:"M3 12h3.5L9 19l5-14 2.5 7H21",chart:"M4 19V9m5 10V5m5 14v-7m5 7V3",chip:"M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22",database:"M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6",download:"M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15",upload:"M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",sync:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",search:"M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",star:"m12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z",sparkle:"m10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z",bell:"M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0",shield:"m12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6",wrench:"m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",play:"M8 5.2v13.6L19 12 8 5.2ZM4 5v14",list:"M4 7h16M4 12h16M4 17h10",inbox:"M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",history:"M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8",check:"m5 12.5 4.5 4.5L19 7.5",alert:"M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z",power:"M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0",key:"M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z",captions:"M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",journey:"M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",plug:"M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3",calendar:"M4 6h16v15H4zM8 3v5M16 3v5M4 11h16",trash:"M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5",plus:"M12 5v14M5 12h14",close:"M6 6l12 12M18 6 6 18",caret:"m6 9 6 6 6-6",external:"M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10",refresh:"M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5",filter:"M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z",menu:"M4 7h16M4 12h16M4 17h16",globe:"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z",logout:"M15 17l5-5-5-5M20 12H9M12 4H5v16h7"};function te({name:s,className:n}){const l=ms[s];return l?e.jsx("svg",{className:n??"ico",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",preserveAspectRatio:"xMidYMid meet","aria-hidden":"true",children:e.jsx("path",{d:l})}):null}function Ie({name:s,tone:n}){return ms[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(te,{name:s})}):null}const fe=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"},{id:"credits",path:"/admin/credits",label:"Credits detection",title:"Credits detection",intro:"Control predictive scanning and review every completed credits scan.",icon:"clock"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline for every television.",icon:"wrench"},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],Os=fe.flatMap(s=>s.items),Bs=fe.flatMap(s=>s.items.filter(n=>!n.hidden).map(n=>({...n,group:s.label??""})));class Ue extends Error{constructor(n,l){super(n),this.status=l,this.name="ApiError"}}const Us=5*60*1e3;let ps=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{ps=Date.now()},{passive:!0});const Vs=()=>Date.now()-ps({}));throw new Ue(t.error??`Request failed (${l.status})`,l.status)}if(l.status!==204)return await l.json()}function Se(s){const n=new URLSearchParams;for(const[t,a]of Object.entries(s))a==null||a===""||a===!1||n.set(t,String(a));const l=n.toString();return l?`?${l}`:""}const F={get:s=>Ce(s),post:(s,n)=>Ce(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Ce(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Ce(s,{method:"DELETE"})},Ws=3e4,xs=x.createContext(null);function zs({children:s}){var m;const[n,l]=x.useState(),[t,a]=x.useState(!1),[c,i]=x.useState(""),[o,d]=x.useState(""),[r,v]=x.useState(!0),w=x.useRef(0),p=x.useCallback(async()=>{const j=++w.current;try{const b=await F.get("/admin/api/status");if(j!==w.current)return;l(b),a(!0),d("")}catch(b){if(j!==w.current)return;a(!1),d(b instanceof Error?b.message:String(b))}finally{j===w.current&&(i(new Date().toISOString()),v(!1))}},[]),u=x.useCallback(async j=>{var b;await F.post("/admin/api/maintenance",{enabled:j,message:((b=n==null?void 0:n.maintenance)==null?void 0:b.message)??""}),await p()},[p,(m=n==null?void 0:n.maintenance)==null?void 0:m.message]);x.useEffect(()=>{p();let j;const b=()=>{window.clearInterval(j),j=document.hidden?void 0:window.setInterval(()=>void p(),Ws)},S=()=>{b(),document.hidden||p()};return b(),document.addEventListener("visibilitychange",S),()=>{window.clearInterval(j),document.removeEventListener("visibilitychange",S)}},[p]);const f=x.useMemo(()=>{var j;return{status:n,version:(n==null?void 0:n.serverVersion)??"",currentUser:((j=n==null?void 0:n.currentUser)==null?void 0:j.trim())||"Administrator",online:t,checkedAt:c,error:o,loading:r,reload:p,setMaintenance:u}},[n,t,c,o,r,p,u]);return e.jsx(xs.Provider,{value:f,children:s})}function ae(){const s=x.useContext(xs);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Hs(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 Ks(){const s=qe(),{status:n}=ae(),[l,t]=x.useState(!1),[a,c]=x.useState(""),[i,o]=x.useState(0),d=x.useRef(null),r=x.useRef(null),v=x.useMemo(()=>{const p=a.trim().toLowerCase();return[...Bs,...((n==null?void 0:n.requestUsers)??[]).map(f=>({id:`user-${f.id}`,path:`/admin/accounts/${encodeURIComponent(f.id)}`,label:f.username||"Unnamed user",title:`User: ${f.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(f=>({id:`device-${f.deviceId}`,path:`/admin/devices/${encodeURIComponent(f.deviceId)}`,label:f.deviceName||"Unnamed device",title:`Device: ${f.deviceName||"Unnamed device"}`,intro:`${f.username||"Unknown user"} · ${f.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((f,m)=>({item:f,rank:p?Hs(f,p):1,index:m})).filter(f=>f.rank>0).sort((f,m)=>m.rank-f.rank||f.index-m.index).map(({item:f,rank:m})=>({item:f,rank:m}))},[a]);x.useEffect(()=>o(0),[a]),x.useEffect(()=>{const p=u=>{var f;(f=d.current)!=null&&f.contains(u.target)||t(!1)};return document.addEventListener("pointerdown",p),()=>document.removeEventListener("pointerdown",p)},[]),x.useEffect(()=>{const p=u=>{var m,j;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(),(m=r.current)==null||m.focus(),(j=r.current)==null||j.select())};return window.addEventListener("keydown",p),()=>window.removeEventListener("keydown",p)},[]);const w=p=>{var u;t(!1),c(""),(u=r.current)==null||u.blur(),s(p)};return e.jsxs("div",{className:"omni",ref:d,"data-open":l||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(te,{name:"search"}),e.jsx("input",{ref:r,type:"search",value:a,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":l,onFocus:()=>t(!0),onChange:p=>{c(p.target.value),t(!0)},onKeyDown:p=>{var u;if(p.key==="Escape")c(""),t(!1),(u=r.current)==null||u.blur();else if(p.key==="ArrowDown")p.preventDefault(),o(f=>Math.min(f+1,v.length-1));else if(p.key==="ArrowUp")p.preventDefault(),o(f=>Math.max(f-1,0));else if(p.key==="Enter"){const f=v[i];if(!f)return;p.preventDefault(),w(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),l?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((p,u)=>e.jsxs("a",{className:u===i?"omni-item on":"omni-item",href:p.item.path,role:"option","aria-selected":u===i,onPointerEnter:()=>o(u),onClick:f=>{f.preventDefault(),w(p.item.path)},children:[p.item.icon?e.jsx(te,{name:p.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:p.item.label}),e.jsx("small",{children:p.item.intro})]}),e.jsx("span",{className:"omni-group",children:p.item.group})]},p.item.id))}):null]})}const N=s=>(s??0).toLocaleString(),O=s=>s?new Date(s).toLocaleString():"—",Gs=s=>s?new Date(s).toLocaleTimeString():"—";function be(s){if(!s)return"0s";if(s<1e3)return`${Math.round(s)}ms`;const n=Math.round(s/1e3);if(n<60)return`${n}s`;const l=Math.floor(n/60);return l<60?`${l}m ${n%60}s`:`${Math.floor(l/60)}h ${l%60}m`}function Le(s){if(!s||s<=0)return"on request only";if(s<60)return`every ${s}s`;const n=Math.round(s/60);if(n<60)return`every ${n} min`;const l=Math.round(n/60);return l<48?l===1?"hourly":`every ${l} hours`:`every ${Math.round(l/24)} days`}function ye(s){const n=["B","KB","MB","GB"];let l=Number(s??0),t=0;for(;l>=1024&&tString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Me=s=>`${Math.round((s??0)*100)}%`;function pe(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const l=Math.floor(n/1e3);if(l<45)return"just now";const t=Math.floor(l/60);if(t<60)return`${t} min ago`;const a=Math.floor(t/60);if(a<24)return`${a}h ago`;const c=Math.floor(a/24);return c<30?`${c}d ago`:new Date(s).toLocaleDateString()}const vs=15*60*1e3,Zs=3*60*60*1e3,Ne=s=>!!s&&Date.now()-new Date(s).getTime(){var b;try{const S=await F.get(`/admin/api/notifications?limit=${_e}`);l(S.events),a(S.unread),i(S.types),w.current=Math.max(w.current,((b=S.events[0])==null?void 0:b.id)??0),v("")}catch(S){v(S instanceof Error?S.message:String(S))}},[]),u=x.useCallback(b=>{w.current=Math.max(w.current,b.id),l(S=>S.some(h=>h.id===b.id)?S:[b,...S].sort((h,y)=>y.id-h.id).slice(0,_e)),b.readAt||a(S=>S+1),i(S=>S.some(h=>h.type===b.type)?S.map(h=>h.type===b.type?{...h,count:h.count+1}:h):[...S,{type:b.type,count:1}])},[]);x.useEffect(()=>{p()},[p]),x.useEffect(()=>{let b=null,S,h=!1;return(()=>{h||(b=new EventSource(`/admin/api/notifications/stream?after=${w.current}`),b.addEventListener("open",()=>{d(!0),window.clearInterval(S),S=void 0}),b.addEventListener("admin",E=>{try{u(JSON.parse(E.data))}catch{}}),b.addEventListener("error",()=>{d(!1),S===void 0&&(S=window.setInterval(()=>void p(),Js))}))})(),()=>{h=!0,b==null||b.close(),window.clearInterval(S)}},[u,p]);const f=x.useCallback(async b=>{const S=b.filter(h=>h>0);if(S.length!==0){l(h=>h.map(y=>S.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const h=await F.post("/admin/api/notifications/read",{ids:S});a(h.unread)}catch{p()}}},[p]),m=x.useCallback(async()=>{a(0),l(b=>b.map(S=>S.readAt?S:{...S,readAt:new Date().toISOString()}));try{const b=await F.post("/admin/api/notifications/read",{all:!0});a(b.unread)}catch{p()}},[p]),j=x.useMemo(()=>({events:n,unread:t,types:c,connected:o,error:r,markRead:f,markAllRead:m,reload:p}),[n,t,c,o,r,f,m,p]);return e.jsx(bs.Provider,{value:j,children:s})}function Oe(){const s=x.useContext(bs);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function fs(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function gs(s){return s.startsWith("auth.")?"key":s.startsWith("device.")?"tv":s.startsWith("admin.")?"shield":s.startsWith("task.")?"clock":s.startsWith("integration.")?"plug":s.startsWith("library.")?"library":s.startsWith("emby.")?"globe":s.startsWith("server.")?"power":"bell"}function We(s){return{"auth.login":"Signed in","auth.login_failed":"Sign-in refused","auth.logout":"Signed out","device.registered":"New device","device.removed":"Device removed","device.renamed":"Device renamed","admin.sign_in":"Admin sign-in","server.started":"Server started","server.maintenance":"Maintenance","task.completed":"Task finished","task.failed":"Task failed","integration.failed":"Integration failed","integration.test":"Integration test","library.sync":"Library sync","emby.unreachable":"Emby unreachable","emby.recovered":"Emby recovered"}[s]??s.replace(/[._]/g," ")}const ze=99;function Xs(){const{events:s,unread:n,connected:l,markRead:t,markAllRead:a}=Oe(),[c,i]=x.useState(!1),o=x.useRef(null);return x.useEffect(()=>{const d=v=>{var w;(w=o.current)!=null&&w.contains(v.target)||i(!1)},r=v=>{v.key==="Escape"&&i(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",r),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",r)}},[]),x.useEffect(()=>{if(!c)return;const d=s.filter(r=>!r.readAt).map(r=>r.id);d.length>0&&t(d)},[c]),e.jsxs("div",{className:"bell",ref:o,"data-open":c||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":c,onClick:()=>i(d=>!d),children:[e.jsx(te,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>ze?`${ze}+`:n}):null]}),c?e.jsxs("div",{className:"bell-panel",children:[e.jsxs("div",{className:"bell-head",children:[e.jsx("b",{children:"Activity"}),e.jsxs("div",{className:"row tight",children:[l?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void a(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(d=>{const r=e.jsxs(e.Fragment,{children:[e.jsx(Ie,{name:gs(d.type),tone:fs(d)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:d.title||d.type}),d.summary?e.jsx("p",{children:d.summary}):null,e.jsx("time",{dateTime:d.occurredAt,children:pe(d.occurredAt)})]})]});return d.link?e.jsx(ne,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>i(!1),children:r},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:r},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(ne,{to:"/admin/activity",onClick:()=>i(!1),children:"All activity"})})]}):null]})}function U({title:s,intro:n,actions:l,crumbs:t,icon:a}){var o;const c=Fe(),i=a??((o=Os.find(d=>Te({path:d.path,end:!0},c.pathname)))==null?void 0:o.icon);return e.jsxs("header",{className:"page-head",children:[t?e.jsx("nav",{className:"crumbs",children:t}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[i?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(te,{name:i})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),l?e.jsx("div",{className:"page-head-actions",children:l}):null]})]})}function A({title:s,intro:n,icon:l,tone:t,actions:a,footer:c,children:i}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[l?e.jsx(Ie,{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]}),a?e.jsx("div",{className:"card-head-actions",children:a}):null]}):null,i,c?e.jsx("div",{className:"card-foot",children:c}):null]})}function oe({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function re({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(Ie,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function C({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function se({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function J({children:s}){return e.jsx("p",{className:"empty",children:s})}function Q({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 R({children:s,onClick:n,variant:l,size:t,disabled:a,busy:c,icon:i,type:o="button",title:d}){return e.jsxs("button",{type:o,className:"","data-variant":l,"data-size":t,disabled:a||c,onClick:n,title:d,children:[c?e.jsx("span",{className:"spinner"}):i?e.jsx(te,{name:i}):null,s]})}function D({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 z({label:s,hint:n,checked:l,onChange:t,disabled:a}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:l,disabled:a,onChange:c=>t(c.target.checked)}),e.jsx("span",{className:"switch"}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:s}),n?e.jsx("p",{children:n}):null]})]})}function Ee({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 Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function ys({data:s,labelOf:n,valueOf:l,toneOf:t,title:a}){if(s.length===0)return e.jsx(J,{children:"Nothing in this window."});const c=s.map(o=>l(o)),i=Math.max(1,...c);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((o,d)=>{const r=l(o);return e.jsx("div",{className:"bar","data-tone":t==null?void 0:t(o),"data-empty":r===0||void 0,style:{height:`${Math.max(2,r/i*100)}%`},title:a?a(o):`${n(o,d)}: ${r}`},d)})}),e.jsxs("div",{className:"bars-axis",children:[e.jsx("span",{children:n(s[0],0)}),e.jsx("span",{children:n(s[s.length-1],s.length-1)})]})]})}function Qs({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 B({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(te,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(te,{name:"close"})}):null]}):null}function _({rows:s=3}){return e.jsxs("div",{className:"loading-page","aria-busy":"true","aria-label":"Loading",children:[e.jsxs("div",{className:"loading-heading",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]}),e.jsx("div",{className:"loading-tiles",children:Array.from({length:4},(n,l)=>e.jsx("span",{className:"skeleton"},l))}),Array.from({length:s},(n,l)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},l))]})}function he({title:s,body:n,confirmLabel:l="Confirm",destructive:t,busy:a,onConfirm:c,onCancel:i}){const o=x.useId(),d=x.useRef(null);return x.useEffect(()=>{var v;(v=d.current)==null||v.focus();const r=w=>{w.key==="Escape"&&i()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[i]),e.jsx("div",{className:"scrim",onPointerDown:r=>r.target===r.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":o,ref:d,tabIndex:-1,children:[e.jsx("h2",{id:o,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(R,{onClick:i,variant:"quiet",children:"Cancel"}),e.jsx(R,{onClick:c,variant:t?"danger":"primary",busy:a,children:l})]})]})})}function He({rows:s}){return e.jsx("div",{className:"kv",children:s.map(n=>e.jsxs("div",{className:"kv-row",children:[e.jsx("span",{children:n.label}),e.jsx("span",{children:n.value})]},n.label))})}function ws({tiles:s}){return e.jsx("div",{className:"tiles plain",children:s.map(n=>e.jsxs("div",{className:"tile",children:[e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function en({open:s,onNavigate:n}){const{unread:l}=Oe(),t=Fe(),[a,c]=x.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),i=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};x.useEffect(()=>{const d=fe.find(r=>r.items.some(v=>Te({path:v.path,end:!0},t.pathname)));d&&c(r=>{const v={...r};return fe.forEach(w=>{w.collapsible!==!1&&(v[w.id]=w.id!==d.id)}),i(v),v})},[t.pathname]);const o=(d,r=!1)=>{c(v=>{const w={...v},p=!(v[d]??r);return fe.forEach(u=>{u.collapsible!==!1&&(w[u.id]=u.id===d?p:!0)}),i(w),w})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:fe.map(d=>{const r=d.items.filter(u=>!u.hidden);if(r.length===0)return null;const v=r.some(u=>Te({path:u.path,end:!0},t.pathname)),w=d.collapsible!==!1,p=v||!w||!(a[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&w?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":p,onClick:()=>o(d.id,d.defaultCollapsed),children:[d.label,e.jsx(te,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,p?r.map(u=>e.jsx(Rs,{to:u.path,end:u.path==="/admin",onClick:n,className:({isActive:f})=>f?"on":"","aria-current":void 0,children:({isActive:f})=>e.jsxs("span",{style:{display:"contents"},ref:m=>{const j=m==null?void 0:m.parentElement;j&&(f?j.setAttribute("aria-current","page"):j.removeAttribute("aria-current"))},children:[u.icon?e.jsx(te,{name:u.icon}):null,u.label,u.badge==="notifications"&&l>0?e.jsx("span",{className:"rail-badge",children:l>99?"99+":l}):null]})},u.id)):null]},d.id)})})}function sn(){var M,W;const{version:s,currentUser:n,online:l,checkedAt:t,loading:a,status:c,setMaintenance:i}=ae(),[o,d]=x.useState(!1),[r,v]=x.useState(!1),[w,p]=x.useState(!1),[u,f]=x.useState(!1),m=Fe(),j=!!((M=c==null?void 0:c.maintenance)!=null&&M.enabled),b=x.useRef(null),S=((W=Array.from(n.trim())[0])==null?void 0:W.toLocaleUpperCase("en-NZ"))||"A",h=c&&l&&!j?"ok":c||!a?"bad":"checking",y=j?"offline":c&&l?"online":a?"checking":"not responding",E=async()=>{if(!(w||!c)){p(!0);try{await i(!j)}finally{p(!1)}}};return x.useEffect(()=>d(!1),[m.pathname]),x.useEffect(()=>{if(!r)return;const g=K=>{var ie;(ie=b.current)!=null&&ie.contains(K.target)||v(!1)},T=K=>{K.key==="Escape"&&v(!1)};return document.addEventListener("mousedown",g),window.addEventListener("keydown",T),()=>{document.removeEventListener("mousedown",g),window.removeEventListener("keydown",T)}},[r]),x.useEffect(()=>{if(!o)return;const g=document.body.style.overflow;document.body.style.overflow="hidden";const T=K=>{K.key==="Escape"&&d(!1)};return window.addEventListener("keydown",T),()=>{document.body.style.overflow=g,window.removeEventListener("keydown",T)}},[o]),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":o,"aria-controls":"rail",onClick:()=>d(g=>!g),children:e.jsx(te,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Ks,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsxs("button",{type:"button",className:"topbar-status","data-tone":h,"aria-pressed":j,disabled:!c||!l||w,title:c?j?"Bring Memby back online":l?"Take Memby offline":"Memby is not responding":"Checking Memby status","aria-label":j?"Memby is offline. Bring it online":c&&l?"Memby is online. Take it offline":a?"Checking Memby status":"Memby is not responding",onClick:()=>j?void E():f(!0),children:[e.jsx("span",{className:"dot"}),e.jsx("b",{children:y}),e.jsx("span",{children:j?"click to bring back online":t?`updated ${Gs(t)}`:""})]}),e.jsx(Xs,{}),e.jsxs("div",{className:"account-menu","data-open":r||void 0,ref:b,children:[e.jsxs("button",{type:"button",className:"account-trigger","aria-haspopup":"menu","aria-expanded":r,"aria-label":`Signed in as ${n}`,onClick:()=>v(g=>!g),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:S}),e.jsx("span",{className:"account-name",children:n}),e.jsx(te,{name:"caret",className:"ico account-caret"})]}),r?e.jsxs("div",{className:"account-panel",role:"menu",children:[e.jsxs("div",{className:"account-identity",children:[e.jsx("span",{className:"account-avatar account-avatar-large","aria-hidden":"true",children:S}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsxs("button",{type:"submit",role:"menuitem",children:[e.jsx(te,{name:"logout"}),"Log out"]})})]}):null]})]})]}),o?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>d(!1)}):null,e.jsx(en,{open:o,onNavigate:()=>d(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(Es,{})}),u?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available.",confirmLabel:"Go offline",destructive:!0,busy:w,onConfirm:()=>{f(!1),E()},onCancel:()=>f(!1)}):null]})}const ks=x.createContext(null),nn=5e3;function tn({children:s}){const[n,l]=x.useState([]),t=x.useRef(1),a=x.useCallback(d=>{l(r=>r.filter(v=>v.id!==d))},[]),c=x.useCallback((d,r="ok")=>{const v=t.current++;l(w=>[...w,{id:v,message:d,tone:r}]),window.setTimeout(()=>a(v),nn)},[a]),i=x.useCallback(async(d,r)=>{try{const v=await d();return r&&c(r,"ok"),v}catch(v){c(v instanceof Error?v.message:String(v),"bad");return}},[c]),o=x.useMemo(()=>({show:c,wrap:i}),[c,i]);return e.jsxs(ks.Provider,{value:o,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(d=>e.jsxs("div",{className:"toast","data-tone":d.tone,children:[e.jsx(te,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>a(d.id),"aria-label":"Dismiss",children:e.jsx(te,{name:"close"})})]},d.id))})]})}function ee(){const s=x.useContext(ks);if(!s)throw new Error("useToast used outside ToastProvider");return s}function Y(s,n={}){const{pollMs:l,enabled:t=!0}=n,[a,c]=x.useState(),[i,o]=x.useState(""),[d,r]=x.useState(t),[v,w]=x.useState(!1),p=x.useRef(0),u=x.useRef(!1),f=x.useCallback(async()=>{if(!t)return;const m=++p.current;u.current&&w(!0);try{const j=await F.get(s);if(m!==p.current)return;c(j),o(""),u.current=!0}catch(j){if(m!==p.current)return;o(j instanceof Error?j.message:String(j))}finally{m===p.current&&(r(!1),w(!1))}},[s,t]);return x.useEffect(()=>(u.current=!1,r(!0),f(),()=>{p.current+=1}),[f]),x.useEffect(()=>{if(!l||!t)return;let m;const j=()=>{window.clearInterval(m),m=document.hidden?void 0:window.setInterval(()=>void f(),l)},b=()=>{j(),document.hidden||f()};return j(),document.addEventListener("visibilitychange",b),()=>{window.clearInterval(m),document.removeEventListener("visibilitychange",b)}},[l,t,f]),{data:a,error:i,loading:d,refreshing:v,reload:f,set:c}}function X(){const[s,n]=x.useState(null),l=x.useRef(!0);x.useEffect(()=>()=>{l.current=!1},[]);const t=x.useCallback(async(a,c)=>{n(a);try{return await c(),!0}finally{l.current&&n(null)}},[]);return{busy:s,run:t}}function an(){var j,b,S,h,y;const{status:s,error:n,loading:l}=ae(),t=Y("/admin/api/runtime",{pollMs:3e4}),a=Y("/admin/api/views",{pollMs:6e4});if(l||!s)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(_,{})]});const c=s.features??{features:[],revision:0,safeMode:!1},i=c.features??[],o=s.clients??[],d=o.filter(E=>Ne(E.lastSeen)).length,r=s.updatePolicy??{},v=!!r.minimumVersion&&r.minimumVersion===r.latestVersion,w=s.playbackPolicy,p=s.mdblist,u=s.forYou,f=(s.runs??[]).slice(0,5),m=t.data;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(B,{message:n}),e.jsx(re,{tiles:[{label:"items in the library",value:N(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:N((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:N(o.length),icon:"tv",tone:"info"},{label:`visits today · ${((j=a.data)==null?void 0:j.lastWeek.visits)??0} this time last week`,value:N((b=a.data)==null?void 0:b.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((S=a.data)==null?void 0:S.lastWeek.viewers)??0} this time last week`,value:N((h=a.data)==null?void 0:h.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${i.filter(E=>E.enabled).length} / ${i.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx(A,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(He,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(C,{tone:"bad",children:"offline for maintenance"}):e.jsx(C,{tone:"ok",children:"online"})},{label:"Feature control plane",value:c.safeMode?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["revision r",N(c.revision)]})},{label:"App update prompt",value:r.enabled?e.jsxs(C,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",r.latestVersion]}):e.jsx(C,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(C,{tone:"warn",children:"running"}):e.jsxs(C,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(w==null?void 0:w.prerollEnabled)===!1?e.jsx(C,{children:"off"}):e.jsxs(C,{tone:"ok",children:[((w==null?void 0:w.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(A,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(He,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(C,{tone:"ok",children:"ready"}):e.jsx(C,{children:"not configured"})},{label:"MDBList ratings",value:p!=null&&p.enabled?e.jsxs(C,{tone:"ok",children:[N(p.cachedTitles)," titles stored"]}):e.jsx(C,{children:p!=null&&p.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(C,{tone:"warn",children:"rebuilding"}):e.jsxs(C,{children:[N((u==null?void 0:u.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:N((u==null?void 0:u.profiles)??0)})}]})})]}),e.jsxs(oe,{cols:"2",children:[e.jsx(A,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(ne,{to:"/admin/imports",children:"All imports"}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Written"})]})}),e.jsx("tbody",{children:f.length===0?e.jsx(Q,{columns:4,children:"No imports have run yet."}):f.map(E=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(E.startedAt)}),e.jsx("td",{children:E.kind}),e.jsx("td",{children:e.jsx(C,{tone:E.status==="success"?"ok":E.status==="running"?"warn":"bad",children:E.status})}),e.jsx("td",{className:"num",children:N(E.itemsUpserted)})]},E.id||E.startedAt))})]})})}),e.jsx(A,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:m?e.jsxs(e.Fragment,{children:[e.jsx(ws,{tiles:[{label:"goroutines",value:N(m.goroutines)},{label:"heap in use",value:ye(m.heapInuse)},{label:"reserved",value:ye(m.sys)},{label:"collections",value:N(m.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",ye(m.nextGc)," · memory limit"," ",m.memoryLimit>0&&m.memoryLimit`/admin/api/notifications${Se({days:a,type:i,severity:d,unread:v,limit:f,offset:p*f})}`,[a,i,d,v,p]),{data:j,error:b,loading:S,reload:h}=Y(m),y=async()=>{await l(),await h()};return e.jsxs(e.Fragment,{children:[e.jsx(U,{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(R,{onClick:()=>void y(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(B,{message:b}),e.jsx(re,{tiles:[{label:"Events in window",value:N((j==null?void 0:j.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:N(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:N((j==null?void 0:j.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(D,{label:"Window",children:e.jsx(Ee,{value:a,options:rn.map(E=>({value:E.value,label:E.label})),onChange:E=>{c(E),u(0)}})}),e.jsx(D,{label:"Kind",children:e.jsxs("select",{value:i,onChange:E=>{o(E.target.value),u(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((j==null?void 0:j.types)??[]).map(E=>e.jsxs("option",{value:E.type,children:[We(E.type)," (",E.count,")"]},E.type))]})}),e.jsx(D,{label:"Severity",children:e.jsxs("select",{value:d,onChange:E=>{r(E.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(D,{label:"Read state",children:e.jsxs("select",{value:v?"unread":"",onChange:E=>{w(E.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(R,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{h(),t()},children:"Refresh"})})]}),S?e.jsx(_,{}):e.jsx(A,{title:"Events",icon:"bell",tone:"info",footer:((j==null?void 0:j.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:"sm",disabled:p===0,onClick:()=>u(p-1),children:"Newer"}),e.jsx(R,{size:"sm",disabled:(p+1)*f>=((j==null?void 0:j.total)??0),onClick:()=>u(p+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((j==null?void 0:j.events.length)??0)===0?e.jsx(Q,{columns:6,children:"Nothing has happened in this window."}):j==null?void 0:j.events.map(E=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(E.occurredAt),children:pe(E.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Ie,{name:gs(E.type),tone:fs(E)}),We(E.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:E.title}),E.summary?e.jsx("div",{className:"muted",children:E.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:E.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:E.target||"—"}),e.jsxs("td",{className:"nowrap",children:[E.readAt?null:e.jsx(C,{tone:"ok",children:"new"}),E.link?e.jsx(ne,{className:"table-row-link",to:E.link,children:"Open"}):null]})]},E.id))})]})})})]})}function on(){const{data:s,error:n,loading:l}=Y("/admin/api/accounts",{pollMs:6e4}),t=(s==null?void 0:s.accounts)??[],a=t.flatMap(o=>o.devices??[]),c=t.filter(o=>{var d;return(d=o.recommendations)==null?void 0:d.completed}).length,i=t.filter(o=>{var d,r;return((d=o.recommendations)==null?void 0:d.prompted)&&!((r=o.recommendations)!=null&&r.completed)}).length;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(B,{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(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Memby users",value:N(t.length),icon:"people",tone:"note"},{label:"signed-in devices",value:N(a.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(a.filter(o=>Ne(o.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:N(c),icon:"check",tone:"ok"},{label:"setup prompts queued",value:N(i),icon:"sparkle",tone:"note"}]}),e.jsx("section",{className:"card flush",children:t.length===0?e.jsx(J,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):t.map(o=>{var p,u;const d=o.devices??[],r=d.filter(f=>Ne(f.lastSeen)).length,v=(p=o.recommendations)!=null&&p.completed?{label:"personalised",tone:"ok"}:(u=o.recommendations)!=null&&u.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},w=Pe(o.lastSeen);return e.jsxs(ne,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(o.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:js(o.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[o.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":w.tone,title:w.label})]}),e.jsxs("span",{className:"list-meta",children:[N(d.length)," device",d.length===1?"":"s",r?` · ${r} active now`:""," · last seen ",O(o.lastSeen)]})]})]}),e.jsxs("span",{className:"list-actions",children:[e.jsx(C,{tone:v.tone,children:v.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},o.id)})})]})]})}function $e(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function cn(){const{userId:s=""}=Re(),n=qe(),{wrap:l}=ee(),{busy:t,run:a}=X(),c=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:i,error:o,loading:d,reload:r}=Y("/admin/api/accounts",{pollMs:3e4}),[v,w]=x.useState(null),[p,u]=x.useState(null),[f,m]=x.useState(null),[j,b]=x.useState(null),[S,h]=x.useState(null),y=((i==null?void 0:i.accounts)??[]).find(I=>I.id===s),E=(i==null?void 0:i.catalogue)??[],M=(i==null?void 0:i.themes)??[];x.useEffect(()=>{var I;v===null&&y&&w({...((I=y.settings)==null?void 0:I.preferences)??{}})},[y,v]),x.useEffect(()=>{f===null&&y&&m({...y.notifications})},[y,f]),x.useEffect(()=>{if(p!==null||!y)return;const I=y.themes??[];u(I.length===0?M.map(L=>L.id):I)},[y,p,M]);const W=x.useMemo(()=>{const I=[];for(const L of E){let H=I.find(k=>k.name===L.area);H||I.push(H={name:L.area,definitions:[]}),H.definitions.push(L)}return I},[E]),g=(I,L,H,k)=>a(I,async()=>{const q=await l(L,H);b(null),q!==void 0&&(k==null||k()),await r()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"User",crumbs:e.jsx(ne,{to:"/admin/accounts",children:"← All users"})}),e.jsx(_,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"User",crumbs:e.jsx(ne,{to:"/admin/accounts",children:"← All users"})}),e.jsx(B,{message:o}),e.jsx(A,{children:e.jsx(J,{children:"This user is no longer signed in to Memby."})})]});const T=y.devices??[],K=T.filter(I=>Ne(I.lastSeen)).length,ie=y.settings??{},G=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:y.username||"Unnamed user",intro:`Memby user · ${N(T.length)} device${T.length===1?"":"s"} · last seen ${O(y.lastSeen)}`,crumbs:e.jsx(ne,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:js(y.username)}),K?e.jsxs(C,{tone:"ok",children:[K," active now"]}):e.jsx(C,{children:"idle"}),e.jsx(se,{children:y.id})]})}),e.jsx(B,{message:o}),e.jsxs(oe,{cols:"wide",children:[e.jsx(A,{title:"Devices",intro:"Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed.",icon:"tv",tone:"info",children:T.length===0?e.jsx(J,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:T.map(I=>{const L=Pe(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":L.tone,title:L.label})," ",e.jsx(ne,{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"," · ",L.label," · last seen ",O(I.lastSeen)," · signed in ",O(I.signedInAt)]}),(I.versions??[]).length>0?e.jsx("div",{className:"chips",children:(I.versions??[]).map(H=>e.jsxs(se,{tone:H.version===I.version?"ok":void 0,children:[H.version,H.version===I.version?" · now":""]},H.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(R,{size:"sm",disabled:!I.id,onClick:()=>h({id:I.id,name:I.name}),children:"Rename"}),e.jsx(R,{size:"sm",variant:"danger",disabled:!I.id,onClick:()=>b({kind:"remove-device",deviceId:I.id,name:I.name}),children:"Sign out"})]})]},I.id||I.name)})})}),e.jsxs(A,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:G.completed?e.jsx(R,{busy:t==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):G.prompted?e.jsx(R,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(R,{variant:"primary",busy:t==="prompt",onClick:()=>void g("prompt",()=>F.put(`${c}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:G.completed?e.jsx(C,{tone:"ok",children:"completed"}):G.prompted?e.jsx(C,{tone:"warn",children:"prompt queued"}):e.jsx(C,{children:"not invited"})}),e.jsx(dn,{prompt:G})]})]}),e.jsx(A,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:f!=null&&f.enabled?e.jsx(C,{tone:"ok",children:"enabled"}):e.jsx(C,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:t==="notifications",onClick:()=>void g("notifications",()=>F.put(`${c}/notifications`,f),"Notification settings saved.",()=>m(null)),children:"Save notifications"}),e.jsx(R,{onClick:()=>{m(null),r()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(z,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:I=>m(L=>L&&{...L,enabled:I})}),e.jsx(z,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:f.showReturnAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,showReturnAlerts:I})}),e.jsx(z,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,sonarrAlerts:I})}),e.jsx(z,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,radarrAlerts:I})}),e.jsx(z,{label:"Optional app updates",hint:"Offer new app versions to this person. Mandatory compatibility updates are always enforced.",checked:f.updateAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,updateAlerts:I})}),e.jsx(z,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,libraryAlerts:I})}),e.jsx(z,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:f.systemAlerts,disabled:!f.enabled,onChange:I=>m(L=>L&&{...L,systemAlerts:I})})]}):null}),e.jsx(A,{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:ie.saved?e.jsxs(C,{tone:ie.source==="admin"?"warn":"ok",children:["r",N(ie.revision)," · ",ie.source||"device"," · ",O(ie.updatedAt)]}):e.jsx(C,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:t==="push",onClick:()=>void g("push",()=>F.put(`${c}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>w(null)),children:"Push to their televisions"}),e.jsx(R,{onClick:()=>{w(null),r()},children:"Discard changes"}),e.jsx(R,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(ne,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:W.map(I=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:I.name}),I.definitions.map(L=>e.jsx(hn,{definition:L,value:v==null?void 0:v[L.key],onChange:H=>w(k=>({...k??{},[L.key]:H}))},L.key))]},I.name))}),e.jsx(A,{title:"Colour schemes",intro:"Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight.",icon:"sparkle",tone:"note",actions:(y.themes??[]).length===0?e.jsx(C,{children:"all schemes"}):e.jsxs(C,{tone:"note",children:[N((y.themes??[]).length)," of ",N(M.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:t==="themes",onClick:()=>(p??[]).length===0?b({kind:"no-themes"}):void g("themes",()=>F.put(`${c}/themes`,{themes:p??[]}),"Colour schemes saved.",()=>u(null)),children:"Save colour schemes"}),e.jsx(R,{onClick:()=>u(M.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:M.map(I=>{var L,H,k;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(p??[]).includes(I.id),onChange:q=>u($=>q.target.checked?[...$??[],I.id]:($??[]).filter(P=>P!==I.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":$e((L=I.palette)==null?void 0:L.surface),"--swatch-accent":$e((H=I.palette)==null?void 0:H.accent),"--swatch-hairline":$e((k=I.palette)==null?void 0:k.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(A,{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(R,{variant:"danger",onClick:()=>b({kind:"remove-account"}),children:"Remove Memby access"})}),S?e.jsx(un,{initial:S.name,busy:t==="rename",onCancel:()=>h(null),onConfirm:I=>void g("rename",()=>F.put(`${c}/devices/${encodeURIComponent(S.id)}`,{deviceName:I}),"Device renamed.",()=>h(null))}):null,j?e.jsx(mn,{pending:j,busy:t,username:y.username,onCancel:()=>b(null),onConfirm:()=>{switch(j.kind){case"remove-device":return void g("remove-device",()=>F.del(`${c}/devices/${encodeURIComponent(j.deviceId)}`),"Device signed out.");case"remove-account":return void g("remove-account",()=>F.del(`${c}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void g("reset-rec",()=>F.del(`${c}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void g("reset-prefs",()=>F.del(`${c}/preferences`),"Defaults restored.",()=>w(null));case"no-themes":return void g("themes",()=>F.put(`${c}/themes`,{themes:[]}),"Colour schemes saved.",()=>u(null))}}}):null]})}function dn({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(a=>e.jsxs(se,{tone:"warn",children:[a.title," · ",N(a.rating)," ★"]},`r:${a.title}`)),...l.flatMap(([a,c])=>(c??[]).map(i=>e.jsxs(se,{children:[a,": ",i]},`${a}:${i}`)))];return t.length===0?e.jsx(J,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:t})}function hn({definition:s,value:n,onChange:l}){if(s.kind==="toggle")return e.jsx(z,{label:s.name,hint:s.description,checked:!!n,onChange:l});if(s.kind==="choice"||s.kind==="number"){const a=s.unit??"",c=s.kind==="number"?(s.numbers??[]).map(i=>({value:String(i),label:i===0?"No limit":a?`${i} ${a}`:String(i)})):s.options??[];return e.jsx(D,{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:c.map(i=>e.jsx("option",{value:i.value,children:i.label},i.value))})})}if(s.kind==="multi"){const a=Array.isArray(n)?n:[],c=[...a,...(s.options??[]).map(i=>i.value).filter(i=>!a.includes(i))];return e.jsxs("div",{className:"field",children:[e.jsx("span",{children:s.name}),e.jsx("small",{children:s.description}),e.jsx("div",{className:"checks",children:c.map(i=>{const o=(s.options??[]).find(d=>d.value===i);return o?e.jsx(z,{label:o.label,checked:a.includes(i),onChange:d=>l(d?[...a,i]:a.filter(r=>r!==i))},i):null})})]})}const t=Array.isArray(n)?n:[];return e.jsx(D,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:t.join(` +`),onChange:a=>l(a.target.value.split(` +`).map(c=>c.trim()).filter(Boolean))})})}function un({initial:s,busy:n,onConfirm:l,onCancel:t}){const[a,c]=x.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(D,{label:"Device name",children:e.jsx("input",{type:"text",value:a,autoFocus:!0,maxLength:80,onChange:i=>c(i.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(R,{variant:"quiet",onClick:t,children:"Cancel"}),e.jsx(R,{variant:"primary",busy:n,disabled:!a.trim(),onClick:()=>l(a.trim()),children:"Rename"})]})]})})}function mn({pending:s,busy:n,username:l,onConfirm:t,onCancel:a}){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(he,{title:i.title,body:i.body,confirmLabel:i.label,destructive:i.destructive,busy:!!n,onConfirm:t,onCancel:a})}function pn(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const t=(s.options??[]).find(a=>a.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 a;return((a=(s.options??[]).find(c=>c.value===t))==null?void 0:a.label)??t}).join(", ")}function xn(){const{userId:s=""}=Re(),{wrap:n}=ee(),{busy:l,run:t}=X(),a=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:c,error:i,loading:o,reload:d}=Y(`${a}/preferences/history`,{pollMs:3e4}),[r,v]=x.useState(new Set),[w,p]=x.useState(null),u=(c==null?void 0:c.username)||"this account",f=(c==null?void 0:c.devices)??[],m=(c==null?void 0:c.revisions)??[],j=(c==null?void 0:c.catalogue)??[],b=h=>v(y=>{const E=new Set(y);return E.has(h)?E.delete(h):E.add(h),E}),S=h=>t("restore",async()=>{await n(()=>F.post(`${a}/preferences/revisions/${h}/restore`),`Restored r${h}.`),p(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Settings history",intro:`Every change to ${u}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(ne,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",u]})}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(A,{title:"Where each device has got to",intro:"A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway.",icon:"tv",tone:"info",actions:c!=null&&c.saved?e.jsxs(C,{tone:c.currentSource==="admin"?"warn":"ok",children:["now on r",N(c.currentRevision)," · ",c.currentSource||"device"]}):e.jsx(C,{children:"defaults · never synced"}),children:f.length===0?e.jsx(J,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(h=>{const y=h.never?void 0:h.behind?"bad":"ok";return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":y})," ",h.name||"Memby TV"," ",h.signedOut?e.jsx(C,{children:"signed out"}):null]}),e.jsxs("p",{children:[h.never?"Has not fetched these settings yet":`Holding r${N(h.revision)} · taken ${O(h.ackedAt)}`,h.clientVersion?` · Memby ${h.clientVersion}`:"",h.signedOut?"":` · last seen ${O(h.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:h.never?e.jsx(C,{children:"never taken one"}):h.behind?e.jsxs(C,{tone:"bad",children:[N(h.behind)," behind"]}):e.jsx(C,{tone:"ok",children:"up to date"})})]},h.deviceId||h.name)})})}),e.jsx(A,{title:"Change history",intro:"Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{className:"num",children:"Rev"}),e.jsx("th",{children:"Changed by"}),e.jsx("th",{children:"What changed"}),e.jsx("th",{className:"num",children:"Taken by"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:m.length===0?e.jsx(Q,{columns:6,children:"Nothing has been changed on this account yet."}):m.flatMap(h=>{const y=r.has(h.revision),E=h.acks??[],M=h.changes??[],W=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(h.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",N(h.revision)," ",h.current?e.jsx(C,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(C,{tone:h.source==="admin"?"warn":"ok",children:h.author}),h.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",N(h.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:h.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):M.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:M.map((g,T)=>e.jsxs(se,{children:[g.name,": ",g.before," → ",g.after]},`${g.name}:${T}`))})}),e.jsx("td",{className:"num",children:E.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:E.map(g=>g.deviceName||g.deviceId).join(", "),children:N(E.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(R,{size:"sm",onClick:()=>b(h.revision),children:y?"Hide":"Show"}),h.current?null:e.jsx(R,{size:"sm",onClick:()=>p(h.revision),children:"Restore"})]})})]},h.revision)];return y&&W.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:j.map(g=>{var T;return e.jsxs(se,{children:[g.name,":"," ",pn(g,(T=h.preferences)==null?void 0:T[g.key])]},g.key)})})})},`${h.revision}:detail`)),W})})]})})})]}),w!==null?e.jsx(he,{title:`Restore revision ${w}?`,body:"It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to.",confirmLabel:"Restore",busy:l==="restore",onConfirm:()=>void S(w),onCancel:()=>p(null)}):null]})}function jn({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(se,{tone:l.version===s.version?"ok":void 0,children:l.version},l.version))})}function vn(){const{status:s,error:n,loading:l}=ae(),t=(s==null?void 0:s.clients)??[],a=t.filter(i=>(i.capabilities??[]).includes("server_features_v1")),c=new Set(t.map(i=>i.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(B,{message:n}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"devices known",value:N(t.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:N(t.filter(i=>Ne(i.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:N(a.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:N(c.size),icon:"download",tone:"note"}]}),e.jsx(A,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(Q,{columns:6,children:"No devices have signed in yet."}):t.map(i=>{const o=(i.capabilities??[]).includes("server_features_v1"),d=Pe(i.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":d.tone,title:d.label}),e.jsx(ne,{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(jn,{client:i})}),e.jsx("td",{children:e.jsx(C,{tone:o?"ok":"warn",children:o?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:O(i.lastSeen)})]},`${i.deviceId}:${i.username}`)})})]})})})]})]})}const Ke={user:"",q:"",ip:"",outcome:"",from:"",to:""},bn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function fn(){var h,y,E,M,W,g;const[s,n]=x.useState("log"),[l,t]=x.useState(7),[a,c]=x.useState(Ke),[i,o]=x.useState(0),d=100,r=x.useMemo(()=>Se({...a,days:a.from?void 0:l||void 0,limit:d,offset:i*d}),[a,l,i]),v=Y(`/admin/api/logins${r}`,{enabled:s==="log"}),w=Y(`/admin/api/logins/devices${r}`,{enabled:s==="devices"}),p=((h=v.data)==null?void 0:h.users)??((y=w.data)==null?void 0:y.users)??[],u=((E=v.data)==null?void 0:E.totals)??((M=w.data)==null?void 0:M.totals),f=((W=v.data)==null?void 0:W.retentionDays)??((g=w.data)==null?void 0:g.retentionDays)??90,m=s==="log"?v.loading:w.loading,j=s==="log"?v.error:w.error,b=T=>{c(K=>({...K,...T})),o(0)},S=Object.entries(a).some(([,T])=>T!=="")||!!a.from;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Sign-in history",intro:"Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected.",actions:e.jsx(Ee,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(B,{message:j}),u?e.jsx(re,{tiles:[{label:"Successful sign-ins",value:N(u.logins),icon:"key",tone:"ok"},{label:"Refused",value:N(u.failures),icon:"shield",tone:u.failures>0?"warn":void 0},{label:"Televisions",value:N(u.devices),icon:"tv",tone:"info"},{label:"People",value:N(u.users),icon:"people",tone:"note"},{label:"Addresses",value:N(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(D,{label:"Window",children:e.jsx(Ee,{value:a.from?-1:l,options:bn.map(T=>({value:T.value,label:T.label})),onChange:T=>{t(T),b({from:"",to:""})}})}),e.jsx(D,{label:"Person",children:e.jsxs("select",{value:a.user,onChange:T=>b({user:T.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),p.map(T=>e.jsx("option",{value:T.id,children:T.username||T.id},T.id))]})}),e.jsx(D,{label:"Outcome",children:e.jsxs("select",{value:a.outcome,onChange:T=>b({outcome:T.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(D,{label:"Address",children:e.jsx("input",{type:"text",value:a.ip,placeholder:"10.0.0.4",onChange:T=>b({ip:T.target.value})})}),e.jsx(D,{label:"From",children:e.jsx("input",{type:"date",value:a.from,onChange:T=>b({from:T.target.value})})}),e.jsx(D,{label:"To",children:e.jsx("input",{type:"date",value:a.to,onChange:T=>b({to:T.target.value})})}),e.jsx(D,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:a.q,placeholder:"Name, device or address",onChange:T=>b({q:T.target.value})})}),e.jsx("div",{className:"filter-actions",children:S?e.jsx(R,{variant:"quiet",size:"sm",onClick:()=>{c(Ke),o(0)},children:"Clear"}):null})]}),m?e.jsx(_,{}):s==="log"?e.jsx(gn,{data:v.data,page:i,limit:d,onPage:o}):e.jsx(yn,{data:w.data})]})}function gn({data:s,page:n,limit:l,onPage:t}){if(!s)return null;const a=s.events.length,c=s.total===0?0:n*l+1;return e.jsxs(e.Fragment,{children:[e.jsxs(oe,{cols:"wide",children:[e.jsx(A,{title:"Attempts per day",intro:"Grouped in the household's own timezone, so an evening sign-in stays on the day it happened.",icon:"chart",tone:"info",children:e.jsx(ys,{data:s.days,labelOf: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(A,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(J,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(i=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:i.ipAddress}),e.jsxs("p",{children:[N(i.logins)," in",i.failures>0?` · ${N(i.failures)} refused`:""]})]}),i.failures>0&&i.logins===0?e.jsx(C,{tone:"bad",children:"only refused"}):null]},i.ipAddress))})})]}),e.jsx(A,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${N(c)}–${N(c+a-1)} of ${N(s.total)}`}),footer:s.total>l?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:"sm",disabled:n===0,onClick:()=>t(n-1),children:"Newer"}),e.jsx(R,{size:"sm",disabled:(n+1)*l>=s.total,onClick:()=>t(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(Q,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(i=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(i.occurredAt)}),e.jsx("td",{children:i.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:i.deviceId?e.jsx(ne,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(i.deviceId)}`,children:i.deviceName||i.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:i.ipAddress||"—"}),e.jsx("td",{className:"mono",children:i.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:i.success?i.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:i.failureReason||"refused"})})]},i.id))})]})})})]})}function yn({data:s}){return s?e.jsx(A,{title:"Televisions",intro:"Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Television"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Today"}),e.jsx("th",{className:"num",children:"Sign-ins"}),e.jsx("th",{className:"num",children:"Refused"}),e.jsx("th",{className:"num",children:"Addresses"}),e.jsx("th",{className:"nowrap",children:"Last address"}),e.jsx("th",{className:"nowrap",children:"Last sign-in"}),e.jsx("th",{children:"Build"})]})}),e.jsx("tbody",{children:s.devices.length===0?e.jsx(Q,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(ne,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?N(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:N(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:N(n.failures)}):"—"}),e.jsx("td",{className:"num",children:N(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?O(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const wn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function kn(){const{deviceId:s=""}=Re(),[n,l]=x.useState(7),t=x.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Se({days:n||void 0,limit:200})}`,[s,n]),{data:a,error:c,loading:i}=Y(t,{enabled:!!s}),o=a==null?void 0:a.summary,d=(o==null?void 0:o.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:d,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(ne,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(ne,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Ee,{value:n,options:wn.map(r=>({value:r.value,label:r.label})),onChange:l})}),e.jsx(B,{message:c}),i?e.jsx(_,{}):a?e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Sign-ins today",value:N((o==null?void 0:o.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:N((o==null?void 0:o.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:N((o==null?void 0:o.failures)??0),icon:"shield",tone:((o==null?void 0:o.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:N((o==null?void 0:o.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:o!=null&&o.firstLogin?O(o.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:o!=null&&o.lastLogin?O(o.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(oe,{cols:"wide",children:[e.jsx(A,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(ys,{data:a.days,labelOf:r=>r.day,valueOf:r=>r.logins+r.failures,toneOf:r=>r.failures>r.logins?"bad":void 0,title:r=>`${r.day}: ${r.logins} in${r.failures?`, ${r.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(A,{title:"Identity",icon:"tv",tone:"info",children:e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Person"}),e.jsx("p",{children:(o==null?void 0:o.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:a.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(o==null?void 0:o.clientVersion)||"unknown"})]})})]})}),e.jsx(A,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:a.versions.length===0?e.jsx(J,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:a.versions.map(r=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:r.version}),e.jsxs("p",{children:[O(r.firstSeen)," → ",O(r.lastSeen)]})]})},r.version))})})]})]}),e.jsx(A,{title:"Addresses",icon:"globe",tone:"data",children:a.addresses.length===0?e.jsx(J,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:a.addresses.map(r=>e.jsxs(se,{tone:r.failures>0?"warn":"data",children:[r.ipAddress," · ",N(r.logins),r.failures>0?` (+${N(r.failures)} refused)`:""]},r.ipAddress))})}),e.jsx(A,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[N(a.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:a.events.length===0?e.jsx(Q,{columns:6,children:"This television has not connected in the selected window."}):a.events.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(r.occurredAt)}),e.jsx("td",{children:r.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:r.ipAddress||"—"}),e.jsx("td",{className:"mono",children:r.clientVersion||"—"}),e.jsx("td",{className:"muted",children:r.method}),e.jsx("td",{className:"nowrap",children:r.success?r.newDevice?e.jsx(C,{tone:"info",children:"first sign-in"}):e.jsx(C,{tone:"ok",children:"got in"}):e.jsx(C,{tone:"bad",children:r.failureReason||"refused"})})]},r.id))})]})})})]}):null]})}function Nn(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(!1),r=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),w=p=>i(p,async()=>{await a(()=>F.post("/admin/api/sync",{kind:p}),p==="full"?"Full re-import started.":"Import started."),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(B,{message:n}),l||!s?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"items",value:N(s.library.total),icon:"library",tone:"data"},...Object.keys(r).sort().map(p=>({label:p,value:N(r[p]),icon:"list"})),{label:"last import",value:O(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(A,{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(R,{variant:"primary",icon:"sync",disabled:v,busy:c==="incremental",onClick:()=>void w("incremental"),children:"Sync new items"}),e.jsx(R,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full re-import"})]})})]}),o?e.jsx(he,{title:"Re-import the entire library?",body:"A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout.",confirmLabel:"Re-import",busy:c==="full",onConfirm:()=>void w("full"),onCancel:()=>d(!1)}):null]})}const Sn={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 Cn(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(!1),[r,v]=x.useState(""),[w,p]=x.useState(!1),[u,f]=x.useState([]),[m,j]=x.useState(!1),b=s==null?void 0:s.mdblist;x.useEffect(()=>{m||!b||(d(b.enabled),f(b.sources??[]))},[b,m]);const S=()=>i("save",async()=>{await a(()=>F.post("/admin/api/mdblist-settings",{enabled:o,apiKey:r.trim(),clearApiKey:w,sources:u}),"Ratings settings saved."),v(""),p(!1),j(!1),await t()}),h=(b==null?void 0:b.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(B,{message:n}),l||!b?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"titles stored",value:N(h),icon:"database",tone:"data"},{label:"due to be re-checked",value:N(b.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:N((b.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:b.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:b.apiKeyConfigured?"ok":void 0}]}),e.jsxs(oe,{cols:"2",children:[e.jsxs(A,{title:"MDBList connection",intro:"The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title.",icon:"star",tone:"note",actions:o?e.jsxs(C,{tone:"ok",children:["on · ",u.length," sources"]}):e.jsx(C,{children:b.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(z,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:o,onChange:y=>{d(y),j(!0)}}),e.jsx(D,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:r,placeholder:b.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>v(y.target.value)})}),e.jsx(z,{label:"Remove the saved key",checked:w,onChange:p})]}),e.jsx(A,{title:"Sources shown on televisions",intro:"A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched.",icon:"list",tone:"data",children:(b.availableSources??[]).length===0?e.jsx(J,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(b.availableSources??[]).map(y=>e.jsx(z,{label:Sn[y]??y,checked:u.includes(y),onChange:E=>{j(!0),f(M=>E?[...M,y]:M.filter(W=>W!==y))}},y))})})]}),e.jsx(A,{children:e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",busy:c==="save",onClick:()=>void S(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:h?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function Mn(){var w;const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),o=(s==null?void 0:s.requestUsers)??[],d=((w=s==null?void 0:s.requestPolicy)==null?void 0:w.allowedUserIds)??[],r=x.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(p=>[p.userId,p])),[s==null?void 0:s.requestUsage]),v=p=>i(`access-${p}`,async()=>{const u=d.includes(p)?d.filter(f=>f!==p):[...d,p];await a(()=>F.post("/admin/api/request-policy",{allowedUserIds:u}),u.includes(p)?"Request access granted.":"Request access removed."),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(A,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(C,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(C,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(J,{children:"Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The button stays hidden on every television until one of them is."}):null}),e.jsx(A,{title:"Request access and activity",intro:"One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state.",icon:"people",tone:"note",children:o.length===0?e.jsx(J,{children:"No one has signed in yet."}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"User"}),e.jsx("th",{children:"Last seen"}),e.jsx("th",{className:"num",children:"Sent to services"}),e.jsx("th",{children:"Last request"}),e.jsx("th",{children:"Access"})]})}),e.jsx("tbody",{children:o.map(p=>{const u=r.get(p.id),f=d.includes(p.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:p.username})}),e.jsx("td",{className:"muted nowrap",children:O(p.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?O(u.lastRequest):"—"}),e.jsx("td",{children:e.jsx(R,{size:"sm",variant:f?"quiet":"primary",busy:c===`access-${p.id}`,onClick:()=>void v(p.id),children:f?"Remove access":"Give access"})})]},p.id)})})]})})})]})]})}function En(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(!1),r=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),w=(p,u,f)=>i(u,async()=>{await a(()=>F.post("/admin/api/for-you",{action:p}),f),d(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tracearr sessions",value:N((r==null?void 0:r.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:N((r==null?void 0:r.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:N((r==null?void 0:r.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:O(r==null?void 0:r.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(A,{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(R,{variant:"primary",icon:"download",disabled:v,busy:c==="import",onClick:()=>void w("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(R,{icon:"database",disabled:v,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(R,{icon:"sync",disabled:v,busy:c==="rebuild",onClick:()=>void w("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(A,{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(ne,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),o?e.jsx(he,{title:"Backfill all Tracearr history?",body:"Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while.",confirmLabel:"Backfill",busy:c==="full",onConfirm:()=>void w("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const Rn=[["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 An(s){return s?Rn.flatMap(([n,l])=>Object.entries(s[l]??{}).map(([t,a])=>({dimension:n,name:t,weight:a.weight??0,evidence:a.evidence??0}))).sort((n,l)=>Math.abs(l.weight)-Math.abs(n.weight)):[]}const Ge=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function In(){const{status:s}=ae(),{wrap:n}=ee(),{busy:l,run:t}=X(),[a,c]=x.useState(""),[i,o]=x.useState("default"),[d,r]=x.useState("0"),[v,w]=x.useState(""),[p,u]=x.useState(null),[f,m]=x.useState("Choose a person to inspect their recommendations."),[j,b]=x.useState(""),S=(s==null?void 0:s.requestUsers)??[],h=()=>t("run",async()=>{if(!a){b("Choose a person to pressure-test.");return}b(""),m("Running the permission check and the scorer…");const g=new URLSearchParams({userId:a,context:i,minutes:d||"0",limit:"100"});v&&g.set("at",new Date(v).toISOString());const T=await n(()=>F.get(`/admin/api/recommendations?${g.toString()}`));T?(u(T),m(`Scored at ${new Date().toLocaleTimeString()}.`)):m("Pressure test failed.")}),y=An((p==null?void 0:p.profile)??null).slice(0,24),E=(p==null?void 0:p.actions)??[],M=(p==null?void 0:p.items)??[],W=(p==null?void 0:p.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(B,{message:j}),e.jsx(A,{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(R,{variant:"primary",busy:l==="run",onClick:()=>void h(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Person",children:e.jsxs("select",{value:a,onChange:g=>c(g.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),S.map(g=>e.jsx("option",{value:g.id,children:g.username},g.id))]})}),e.jsx(D,{label:"Context",children:e.jsxs("select",{value:i,onChange:g=>o(g.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(D,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:g=>r(g.target.value)})}),e.jsx(D,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:g=>w(g.target.value)})})]})}),p?e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"prepared pool",value:N(p.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:N(p.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:N(M.length),icon:"sparkle",tone:"note"},{label:"source events",value:N(W.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:W.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:O(W.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(A,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[y.length===0?e.jsx(J,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(g=>e.jsxs(se,{tone:g.weight<0?"bad":void 0,children:[g.dimension,": ",g.name," ",Ge(g.weight)," · n=",N(g.evidence)]},`${g.dimension}:${g.name}`))}),E.length===0?e.jsx(J,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:E.map((g,T)=>e.jsxs(se,{tone:"ok",children:[g.action,": ",g.title||g.itemId]},`${g.action}:${T}`))})]}),M.length===0?e.jsx(A,{children:e.jsx(J,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(oe,{children:M.map((g,T)=>{const K=g.explanation??{},ie=Object.entries(K.components??{}).sort((L,H)=>Math.abs(H[1])-Math.abs(L[1])),G=g.exposure??{},I=[g.type,g.year,g.runtimeMinutes?`${g.runtimeMinutes} min`:null,...g.genres??[]].filter(Boolean).join(" · ");return e.jsxs(A,{title:`#${T+1} · ${g.title}`,intro:I,actions:e.jsx(se,{tone:"ok",children:Number(K.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[g.preparedReason||"No legacy prepared explanation",g.compatibilityLabel?` · ${g.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(K.reasonCodes??[]).map(L=>e.jsx(se,{tone:"ok",children:L},L)),ie.map(([L,H])=>e.jsxs(se,{tone:H<0?"bad":void 0,children:[L,"=",Ge(H)]},L))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",N(g.baseRank??0)," · base ",Number(g.baseScore??0).toFixed(3)," · affinity ",Number(g.affinityScore??0).toFixed(3)," · compatibility"," ",Number(g.compatibilityScore??0).toFixed(3)," · impressions"," ",N(G.impressions??0)," · focuses ",N(G.focuses??0)," · selects"," ",N(G.selects??0)]}),e.jsx("div",{className:"chips",children:(g.eligibleRows??[]).map(L=>e.jsx(se,{tone:"ok",children:L},L))}),g.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",g.preparedEvidenceTitle]}):null]})]},`${T}:${g.title}`)})})]}):null]})}const $n=4,je=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],ge=()=>({pinnedItems:[],primeSubtitle:""}),De=[{value:1,short:"Mon",label:"Monday"},{value:2,short:"Tue",label:"Tuesday"},{value:3,short:"Wed",label:"Wednesday"},{value:4,short:"Thu",label:"Thursday"},{value:5,short:"Fri",label:"Friday"},{value:6,short:"Sat",label:"Saturday"},{value:0,short:"Sun",label:"Sunday"}];function Ze(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Je(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function Tn(s){return s.frequency??"once"}function Ln(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=De.filter(t=>(s.weekdays??[]).includes(t.value));return`${(n.length===7?"Every day":n.map(t=>t.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function Ye(s,n){const l=new Date;l.setMinutes(Math.ceil(l.getMinutes()/30)*30,0,0);const t=new Date(l.getTime()+2*60*60*1e3),a=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:l.toISOString(),endAt:t.toISOString(),priority:0,enabled:!0,placements:[a?n:"home"]}}function Dn(){var I,L,H;const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a,show:c}=ee(),{busy:i,run:o}=X(),[d,r]=x.useState("home"),[v,w]=x.useState({home:ge(),movies:ge(),tv_shows:ge()}),[p,u]=x.useState(!1),[f,m]=x.useState(""),[j,b]=x.useState(null),[S,h]=x.useState([]),[y,E]=x.useState(null),M=s==null?void 0:s.heroPolicy;x.useEffect(()=>{var k,q,$;p||!M||(w({home:((k=M.placements)==null?void 0:k.home)??{pinnedItems:M.pinnedItems??[],primeSubtitle:M.primeSubtitle??""},movies:((q=M.placements)==null?void 0:q.movies)??ge(),tv_shows:(($=M.placements)==null?void 0:$.tv_shows)??ge()}),h(M.schedules??[]))},[M,p]);const W=()=>o("search",async()=>{const k=f.trim();if(!k)return;const q=await a(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(k)}`));q&&b(q.items??[])}),g=v[d],T=g.pinnedItems??[],K=k=>{w(q=>({...q,[d]:{...q[d],...k}})),u(!0)},ie=k=>{if(!T.some(q=>q.id===k.id)){if(T.length>=$n){c("Remove a pinned title before adding another.","bad");return}K({pinnedItems:[...T,k]})}},G=()=>o("save",async()=>{await a(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([k,q])=>[k,{pinnedItemIds:(q.pinnedItems??[]).map($=>$.id),primeSubtitle:q.primeSubtitle.trim()}])),schedules:S}),"Hero saved."),u(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(oe,{children:je.map(k=>{var le;const q=v[k.id],$=S.filter(me=>me.enabled&&(me.placements??["home"]).includes(k.id)).sort((me,Cs)=>Cs.priority-me.priority)[0],P=(q.pinnedItems??[]).length?"Manual":$?"Schedule ready":"Automatic",ce=(le=M==null?void 0:M.items)==null?void 0:le.find(me=>me.id===($==null?void 0:$.itemId)),de=(q.pinnedItems??[]).map(me=>me.name).join(", ")||(ce==null?void 0:ce.name)||($==null?void 0:$.itemId)||"Resolved for each viewer";return e.jsx(A,{title:k.label,intro:`${P} · ${de}`,tone:k.id===d?"info":void 0,children:e.jsxs(R,{size:"sm",variant:"quiet",onClick:()=>r(k.id),children:["Manage ",k.label]})},k.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:je.map(k=>e.jsx(R,{variant:d===k.id?"primary":"quiet",onClick:()=>r(k.id),children:k.label},k.id))}),e.jsxs(A,{title:`${(I=je.find(k=>k.id===d))==null?void 0:I.label} hero`,intro:`Pinned ${(L=je.find(k=>k.id===d))==null?void 0:L.type} lead this section only. Empty places use this placement’s automatic selection.`,icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:i==="save",onClick:()=>void G(),children:"Save hero"}),e.jsx(R,{onClick:()=>{K({pinnedItems:[]})},children:"Clear pins"}),p?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[T.length===0?e.jsx(J,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:T.map((k,q)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:q+1}),e.jsxs("span",{children:[e.jsx("b",{children:k.name}),e.jsxs("small",{children:[k.type,k.year?` · ${k.year}`:""]})]}),e.jsx(R,{size:"sm",icon:"clock",onClick:()=>E(Ye(k,d)),children:"Schedule"}),e.jsx(R,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${k.name}`,onClick:()=>K({pinnedItems:T.filter($=>$.id!==k.id)})})]},k.id))}),e.jsx(D,{label:"Prime-card subtitle",hint:"Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason.",children:e.jsx("input",{type:"text",maxLength:160,value:g.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:k=>{K({primeSubtitle:k.target.value})}})})]}),e.jsx(A,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(C,{tone:"info",children:(M==null?void 0:M.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:i==="save",onClick:()=>void G(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:S.length===0?e.jsx(J,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...S].sort((k,q)=>Number(q.enabled)-Number(k.enabled)||q.priority-k.priority).map(k=>{const q=[...(M==null?void 0:M.items)??[],...T,...j??[]].find($=>$.id===k.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":k.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:k.frequency==="weekly"?"Weekly":k.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:k.frequency?k.startTime:k.startAt?new Date(k.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(q==null?void 0:q.name)??k.itemId}),e.jsx(C,{tone:k.enabled?"ok":void 0,children:k.enabled?"enabled":"paused"})]}),e.jsx("p",{children:Ln(k)}),e.jsxs("div",{className:"chips",children:[(k.placements??["home"]).map($=>{var P;return e.jsx("span",{className:"chip",children:(P=je.find(ce=>ce.id===$))==null?void 0:P.label},$)}),k.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",k.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(R,{size:"sm",onClick:()=>E({...k}),children:"Edit"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>{h($=>$.map(P=>P.id===k.id?{...P,enabled:!P.enabled}:P)),u(!0)},children:k.enabled?"Pause":"Enable"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>{h($=>$.filter(P=>P.id!==k.id)),u(!0)},children:"Remove"})]})]},k.id)})})}),e.jsxs(A,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(H=je.find(k=>k.id===d))==null?void 0:H.label} placement; switch tabs to show it in more than one section.`,icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(D,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:k=>m(k.target.value),onKeyDown:k=>{k.key==="Enter"&&W()}})}),e.jsx(R,{busy:i==="search",icon:"search",onClick:()=>void W(),children:"Search"})]}),j===null?null:j.length===0?e.jsx(J,{children:"No playable films or series matched that search."}):e.jsx(oe,{children:j.map(k=>e.jsx(A,{title:k.name,intro:`${k.type||"Title"} · ${k.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(R,{size:"sm",icon:"plus",disabled:T.some(q=>q.id===k.id)||d==="movies"&&k.type!=="Movie"||d==="tv_shows"&&k.type!=="Series",onClick:()=>ie(k),children:T.some(q=>q.id===k.id)?"Pinned":"Add to hero"}),e.jsx(R,{size:"sm",icon:"clock",onClick:()=>E(Ye(k,d)),children:"Schedule"})]})},k.id))})]})]}),y?e.jsx(qn,{schedule:y,item:[...(M==null?void 0:M.items)??[],...T,...j??[]].find(k=>k.id===y.itemId),timeZone:(M==null?void 0:M.timeZone)||"server local time",isNew:!S.some(k=>k.id===y.id),onCancel:()=>E(null),onSave:k=>{h(q=>q.some($=>$.id===k.id)?q.map($=>$.id===k.id?k:$):[...q,k]),E(null),u(!0)}}):null]})}function qn({schedule:s,item:n,timeZone:l,isNew:t,onSave:a,onCancel:c}){const[i,o]=x.useState({...s,weekdays:[...s.weekdays??[]]}),d=Tn(i),r=m=>{const j=new Date,b=new Date(j.getTime()+2*60*60*1e3);o(S=>{var h;return m==="once"?{...S,frequency:void 0,startAt:Je(S.startAt)?S.startAt:j.toISOString(),endAt:Je(S.endAt)?S.endAt:b.toISOString()}:{...S,frequency:m,startTime:S.startTime||"18:00",endTime:S.endTime||"22:00",weekdays:m==="weekly"?(h=S.weekdays)!=null&&h.length?S.weekdays:[1,2,3,4,5]:[]}})},v=i.placements??["home"],w=m=>m==="home"||m==="movies"&&(n==null?void 0:n.type)==="Movie"||m==="tv_shows"&&(n==null?void 0:n.type)==="Series",p=!!(i.startAt&&i.endAt&&new Date(i.endAt)>new Date(i.startAt)),u=!!(i.startTime&&i.endTime&&i.startTime!==i.endTime&&(d!=="weekly"||(i.weekdays??[]).length>0)),f=d==="once"?p:u;return e.jsx("div",{className:"scrim",onPointerDown:m=>m.target===m.currentTarget&&c(),children:e.jsxs("div",{className:"dialog hero-schedule-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"hero-schedule-title",children:[e.jsxs("div",{className:"hero-schedule-dialog-head",children:[e.jsx("span",{className:"hero-schedule-kicker",children:"Hero schedule"}),e.jsx("h2",{id:"hero-schedule-title",children:(n==null?void 0:n.name)??i.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",l,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(m=>e.jsxs("button",{type:"button","aria-pressed":d===m,onClick:()=>r(m),children:[e.jsx("b",{children:m==="once"?"One time":m==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:m==="once"?"A date range":m==="daily"?"Same time daily":"Choose days"})]},m))}),d==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:i.startAt?Ze(new Date(i.startAt)):"",onChange:m=>o(j=>({...j,startAt:m.target.value?new Date(m.target.value).toISOString():void 0}))})}),e.jsx(D,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:i.endAt?Ze(new Date(i.endAt)):"",onChange:m=>o(j=>({...j,endAt:m.target.value?new Date(m.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Starts each time",children:e.jsx("input",{type:"time",value:i.startTime??"",onChange:m=>o(j=>({...j,startTime:m.target.value}))})}),e.jsx(D,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value:i.endTime??"",onChange:m=>o(j=>({...j,endTime:m.target.value}))})})]}),d==="weekly"?e.jsxs("div",{className:"schedule-days",children:[e.jsxs("div",{className:"schedule-days-head",children:[e.jsx("b",{children:"Days"}),e.jsxs("div",{children:[e.jsx("button",{type:"button",onClick:()=>o(m=>({...m,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>o(m=>({...m,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>o(m=>({...m,weekdays:De.map(j=>j.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:De.map(m=>{const j=(i.weekdays??[]).includes(m.value);return e.jsx("button",{type:"button","aria-pressed":j,title:m.label,onClick:()=>o(b=>({...b,weekdays:j?(b.weekdays??[]).filter(S=>S!==m.value):[...b.weekdays??[],m.value]})),children:m.short},m.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:je.map(m=>e.jsx(z,{label:m.label,checked:v.includes(m.id),disabled:!w(m.id),onChange:j=>o(b=>{const S=b.placements??["home"],h=j?[...S,m.id]:S.filter(y=>y!==m.id);return{...b,placements:h.length?[...new Set(h)]:S}})},m.id))})]}),e.jsx(D,{label:"Priority",hint:"Higher rules win when schedules overlap.",children:e.jsx("input",{type:"number",min:-1e3,max:1e3,step:10,value:i.priority,onChange:m=>o(j=>({...j,priority:Number(m.target.value)}))})})]}),e.jsx(z,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:i.enabled,onChange:m=>o(j=>({...j,enabled:m}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx(R,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx(R,{variant:"primary",disabled:!f,onClick:()=>a(i),children:t?"Add rule":"Save rule"})]})]})})}function Fn(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(null),r=s==null?void 0:s.features,v=(r==null?void 0:r.features)??[],w=(s==null?void 0:s.clients)??[],p=(r==null?void 0:r.revision)??0,u=(b,S,h,y)=>i(S,async()=>{await a(()=>F.post("/admin/api/features",{action:b,expectedRevision:p,overrides:y??{}}),h),d(null),await t()}),f=w.filter(b=>(b.capabilities??[]).includes("server_features_v1")).length,m=!!(r!=null&&r.safeMode),j=(b,S)=>({...Object.fromEntries(v.filter(h=>h.source==="override").map(h=>[h.key,h.enabled])),[b]:S});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(B,{message:n}),l||!r?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(A,{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(R,{variant:m?void 0:"danger",busy:c==="safe",onClick:()=>m?void u("leave-safe-mode","safe","Safe mode ended."):d({action:"safe-mode",title:"Enable safe mode?",body:"Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.",label:"Enable safe mode"}),children:m?"Leave safe mode":"Enable safe mode"}),children:e.jsx(ws,{tiles:[{label:"features active",value:`${v.filter(b=>b.enabled).length} / ${v.length}`},{label:"explicit overrides",value:N(v.filter(b=>b.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${w.length}`},{label:"published revision",value:`r${N(p)}`}]})}),e.jsx(oe,{cols:"2",children:v.length===0?e.jsx(A,{title:"Nothing registered",icon:"sliders",children:e.jsx(J,{children:"No server features are registered."})}):v.map(b=>e.jsxs(A,{title:b.name,intro:b.description,actions:e.jsx(C,{tone:b.enabled?"ok":void 0,children:b.enabled?"active":"off"}),footer:e.jsxs("span",{className:"hint",children:["↳ ",b.recovery]}),children:[e.jsx(z,{label:b.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:b.enabled,disabled:c===b.key,onChange:S=>d({action:"feature",key:b.key,enabled:S,title:`${S?"Turn on":"Turn off"} ${b.name}?`,body:`${S?"Enable":"Disable"} this feature for every compatible television. ${b.recovery}`,label:S?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(se,{children:b.key}),e.jsxs(se,{children:["protocol ",N(b.minimumProtocol),"+"]}),e.jsx(se,{tone:b.compatible?"ok":"warn",children:b.compatible?"server compatible":"compatibility blocked"}),e.jsx(se,{tone:"note",children:b.area})]})]},b.key))}),e.jsx(A,{children:e.jsxs("div",{className:"row",children:[e.jsx(R,{disabled:!r.canRollback,onClick:()=>d({action:"rollback",title:"Roll back one revision?",body:"The previous published feature revision is restored on every television.",label:"Roll back"}),children:"Roll back one revision"}),e.jsx(R,{onClick:()=>d({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),m?e.jsx(C,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(C,{tone:"ok",children:["live · revision r",N(p)]})]})})]}),o?e.jsx(he,{title:o.title,body:o.body,confirmLabel:o.label,destructive:o.action!=="rollback",busy:c===o.action,onConfirm:()=>void u(o.action==="feature"?"save":o.action,o.action==="feature"?o.key??"feature":o.action,`${o.label} done.`,o.action==="feature"&&o.key?j(o.key,!!o.enabled):void 0),onCancel:()=>d(null)}):null]})}function Pn(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a,show:c}=ee(),{busy:i,run:o}=X(),[d,r]=x.useState(!0),[v,w]=x.useState("6.5"),[p,u]=x.useState(!1);x.useEffect(()=>{var m,j;p||!s||(r(((m=s.playbackPolicy)==null?void 0:m.prerollEnabled)!==!1),w(String((((j=s.playbackPolicy)==null?void 0:j.prerollDurationMs)??6500)/1e3)))},[s,p]);const f=()=>o("save",async()=>{const m=Number(v);if(!Number.isFinite(m)||m<1||m>30){c("The preroll duration must be between 1 and 30 seconds.","bad");return}await a(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(m*1e3)}),"Playback policy saved."),u(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:1}):e.jsxs(A,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:d?e.jsxs(C,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(C,{children:"off"}),footer:e.jsx(R,{variant:"primary",busy:i==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(z,{label:"Show the preroll before a title starts",checked:d,onChange:m=>{r(m),u(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(D,{label:"Duration",hint:"Between 1 and 30 seconds. The stream is already playing behind it.",children:e.jsx("input",{type:"number",min:1,max:30,step:.5,value:v,onChange:m=>{w(m.target.value),u(!0)}})})})]})]})}function On(){const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(null),[r,v]=x.useState(null),[w,p]=x.useState(!1),u=s==null?void 0:s.subtitles,f=u==null?void 0:u.stored;x.useEffect(()=>{o||!u||d({bazarr:u.bazarrEnabled,openSubtitles:u.openSubtitlesEnabled,key:"",clearKey:!1,username:u.openSubtitlesUsername??"",password:"",clearLogin:!1})},[u,o]);const m=h=>d(y=>y&&{...y,...h}),j=()=>i("save",async()=>{o&&(await a(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:o.bazarr,openSubtitlesEnabled:o.openSubtitles,openSubtitlesApiKey:o.key.trim(),clearOpenSubtitlesApiKey:o.clearKey,openSubtitlesUsername:o.username.trim(),openSubtitlesPassword:o.password,clearOpenSubtitlesLogin:o.clearLogin}),"Subtitle settings saved."),d(null),await t())}),b=()=>i("test",async()=>{v(null);const h=await a(()=>F.post("/admin/api/subtitle-test"));v((h==null?void 0:h.results)??[])}),S=()=>i("clear",async()=>{await a(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),p(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(B,{message:n}),l||!u||!o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"offered on televisions",value:u.available?"yes":"no",small:!0,icon:"captions",tone:u.available?"ok":void 0},{label:"providers on",value:N((u.bazarrEnabled&&u.bazarrConfigured?1:0)+(u.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:N((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:O(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(oe,{cols:"2",children:[e.jsx(A,{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?o.bazarr?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{children:"off"}):e.jsx(C,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:u.bazarrConfigured?`Configured at ${u.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(z,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:o.bazarr,disabled:!u.bazarrConfigured,onChange:h=>m({bazarr:h})})}),e.jsxs(A,{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(C,{tone:u.openSubtitlesAccount?"ok":"warn",children:u.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(C,{children:u.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(z,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:o.openSubtitles,onChange:h=>m({openSubtitles:h})}),e.jsx(D,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.key,placeholder:u.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:h=>m({key:h.target.value})})}),e.jsx(z,{label:"Remove the saved key",checked:o.clearKey,onChange:h=>m({clearKey:h})}),e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:o.username,placeholder:"Not signed in",onChange:h=>m({username:h.target.value})})}),e.jsx(D,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:o.password,onChange:h=>m({password:h.target.value})})})]}),e.jsx(z,{label:"Sign out and forget the account",checked:o.clearLogin,onChange:h=>m({clearLogin:h})})]})]}),e.jsxs(A,{children:[e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",busy:c==="save",onClick:()=>void j(),children:"Save subtitle settings"}),e.jsx(R,{busy:c==="test",icon:"pulse",onClick:()=>void b(),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."})]}),r===null?null:r.length===0?e.jsx(J,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:r.map(h=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:h.provider}),e.jsx("p",{children:h.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(C,{tone:h.ok?"ok":"bad",children:h.ok?"reachable":"not reachable"})})]},h.provider))})]}),e.jsx(A,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:f!=null&&f.count?e.jsxs(C,{tone:"data",children:[N(f.count)," files · ",ye(f.bytes)]}):e.jsx(C,{children:"nothing held"}),footer:e.jsx(R,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>p(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),w?e.jsx(he,{title:"Delete every stored subtitle?",body:"Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby.",confirmLabel:"Delete",destructive:!0,busy:c==="clear",onConfirm:()=>void S(),onCancel:()=>p(!1)}):null]})}function Bn(){var H;const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(null),[r,v]=x.useState(!1),[w,p]=x.useState(!1),[u,f]=x.useState(""),[m,j]=x.useState(""),[b,S]=x.useState(!1),[h,y]=x.useState(null),[E,M]=x.useState(""),W=x.useRef(""),g=s==null?void 0:s.updatePolicy;x.useEffect(()=>{o||!g||d({version:g.latestVersion??"",url:g.downloadUrl??"",notes:g.notes??"",retireBelow:g.retireBelowVersion??"",required:!!g.minimumVersion&&g.minimumVersion===g.latestVersion,destructive:!!g.retireBelowVersion&&g.retireBelowVersion===g.latestVersion})},[g,o]),x.useEffect(()=>{let k=!0;const q=async()=>{try{const P=await F.get("/admin/api/release-builder");if(!k)return;y(P),M(""),P.state==="succeeded"&&P.finishedAt&&W.current!==P.finishedAt&&(W.current=P.finishedAt,d(null),await t())}catch(P){if(!k)return;M(P instanceof Error?P.message:"The release builder is unavailable.")}};q();const $=window.setInterval(()=>void q(),3e3);return()=>{k=!1,window.clearInterval($)}},[t]);const T=k=>i(k?"save":"off",async()=>{o&&(await a(()=>F.post("/admin/api/update-policy",{enabled:k,latestVersion:o.version.trim(),downloadUrl:o.url.trim(),notes:o.notes.trim(),required:o.required,destructive:o.destructive,retireBelowVersion:o.retireBelow.trim()}),k?"Update policy saved.":"Update prompts turned off."),v(!1),d(null),await t())}),K=!!(g!=null&&g.minimumVersion)&&(g==null?void 0:g.minimumVersion)===(g==null?void 0:g.latestVersion),ie=!!(g!=null&&g.retireBelowVersion)&&(g==null?void 0:g.retireBelowVersion)===(g==null?void 0:g.latestVersion),G=k=>d(q=>q&&{...q,...k}),I=()=>i("release",async()=>{const k=await a(()=>F.post("/admin/api/release-builder",{tag:u.trim(),notes:m.trim(),mandatory:b}),"Memby release started.");k&&(y(k),M(""),p(!1))}),L=(h==null?void 0:h.state)==="succeeded"?"ok":(h==null?void 0:h.state)==="failed"?"bad":(h==null?void 0:h.state)==="running"?"warn":void 0;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(B,{message:n}),e.jsx(B,{message:E}),e.jsxs(A,{title:"Build and publish",intro:"Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway.",icon:"download",tone:"data",actions:e.jsx(C,{tone:L,children:(h==null?void 0:h.state)??"checking"}),footer:e.jsx(R,{variant:"primary",busy:c==="release"||(h==null?void 0:h.state)==="running",disabled:!!E,onClick:()=>b?p(!0):void I(),children:u.trim()?`Build ${u.trim()}`:"Build latest release"}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"GitHub tag override",hint:"Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades.",children:e.jsx("input",{type:"text",value:u,disabled:(h==null?void 0:h.state)==="running",placeholder:"v0.2.64 (blank uses latest)",onChange:k=>f(k.target.value)})}),e.jsx(D,{label:"Release notes",hint:"Leave blank to use this version's CHANGELOG entry.",children:e.jsx("input",{type:"text",value:m,disabled:(h==null?void 0:h.state)==="running",placeholder:"What's new on the television",onChange:k=>j(k.target.value)})})]}),e.jsx(z,{label:"Make this update required",hint:"Older televisions cannot dismiss the update prompt. You will confirm before the build starts.",checked:b,disabled:(h==null?void 0:h.state)==="running",onChange:S}),h!=null&&h.message?e.jsx(ue,{tone:L,children:h.message}):null,(H=h==null?void 0:h.logs)!=null&&H.length?e.jsx("pre",{className:"code",children:h.logs.join(` +`)}):null,e.jsxs(ue,{children:["Command-line fallback: ",e.jsx("code",{children:(h==null?void 0:h.fallback)??"docker compose run --rm --build memby-builder release"})]})]}),l||!o?e.jsx(_,{rows:1}):e.jsxs(A,{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:g!=null&&g.enabled?e.jsxs(C,{tone:K?"warn":"ok",children:[ie?"sign-out · ":K?"required · ":"optional · ",g.latestVersion]}):e.jsx(C,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:c==="save",onClick:()=>o.required?v(!0):void T(!0),children:"Save policy"}),e.jsx(R,{busy:c==="off",onClick:()=>void T(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Latest version",children:e.jsx("input",{type:"text",value:o.version,placeholder:"0.2.63",onChange:k=>G({version:k.target.value})})}),e.jsx(D,{label:"APK URL",children:e.jsx("input",{type:"text",value:o.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:k=>G({url:k.target.value})})})]}),e.jsx(D,{label:"What's new",hint:"Shown on the television above the update button.",children:e.jsx("input",{type:"text",value:o.notes,placeholder:"One line the viewer reads",onChange:k=>G({notes:k.target.value})})}),e.jsx(D,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:o.retireBelow,placeholder:"0.2.44",onChange:k=>G({retireBelow:k.target.value})})}),e.jsx(z,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:o.required,onChange:k=>G({required:k})}),e.jsx(z,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:o.destructive,onChange:k=>G(k?{destructive:!0,required:!0,retireBelow:o.version.trim()}:{destructive:!1,retireBelow:o.retireBelow.trim()===o.version.trim()?"":o.retireBelow})})]}),r&&o?e.jsx(he,{title:o.destructive?"Sign every older television out?":"Require this update?",body:o.destructive?"This deletes sessions on every older television and forces viewers to sign in again after updating.":"Required updates block the home screen on every television below this version until they update.",confirmLabel:"Publish",destructive:o.destructive,busy:c==="save",onConfirm:()=>void T(!0),onCancel:()=>v(!1)}):null,w?e.jsx(he,{title:"Build a required update?",body:"When this signed APK is published, every older television will be blocked until it installs the update.",confirmLabel:"Build and publish",busy:c==="release",onConfirm:()=>void I(),onCancel:()=>p(!1)}):null]})}const Un=2e4,Vn=3e3,_n=[{value:60,label:"Every minute"},{value:300,label:"Every 5 minutes"},{value:600,label:"Every 10 minutes"},{value:900,label:"Every 15 minutes"},{value:1800,label:"Every 30 minutes"},{value:3600,label:"Hourly"},{value:10800,label:"Every 3 hours"},{value:21600,label:"Every 6 hours"},{value:43200,label:"Every 12 hours"},{value:86400,label:"Daily"},{value:604800,label:"Weekly"}];function Wn(s){const n=[..._n];for(const l of[s.defaultIntervalSeconds,s.intervalSeconds])l>0&&!n.some(t=>t.value===l)&&n.push({value:l,label:Le(l).replace(/^every /,"Every ")});return n.sort((l,t)=>l.value-t.value)}function Xe(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function zn(){const{wrap:s}=ee(),{busy:n,run:l}=X(),[t,a]=x.useState(!1),{data:c,error:i,loading:o,reload:d}=Y("/admin/api/tasks?limit=60",{pollMs:t?Vn:Un}),r=(c==null?void 0:c.tasks)??[],v=r.some(y=>y.running);v!==t&&a(v);const w=y=>l(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await d()}),p=(y,E)=>l(`${y.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{enabled:E}),E?`${y.name} switched on.`:`${y.name} switched off.`),await d()}),u=(y,E)=>l(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:E}),`${y.name} now runs ${Le(E)}.`),await d()}),f=y=>l(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:0}),`${y.name} back to its default cadence.`),await d()}),m=r.filter(y=>{var E;return((E=y.lastRun)==null?void 0:E.status)==="failed"}).length,j=r.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,b=r.filter(y=>!y.enabled).length,S=(c==null?void 0:c.groups)??[],h=r.filter(y=>!y.group);return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Scheduled tasks",intro:"The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."}),e.jsx(B,{message:i}),o?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"Tasks",value:N(r.length),icon:"clock",tone:"info"},{label:"Running now",value:N(r.filter(y=>y.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:N(m),icon:"alert",tone:m>0?"bad":void 0},{label:"Switched off",value:N(b),icon:"power",tone:b>0?"warn":void 0},{label:"Retimed",value:N(j),icon:"clock",tone:j>0?"note":void 0}]}),m>0?e.jsx(ue,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...S,...h.length>0?[""]:[]].map(y=>{const E=r.filter(M=>M.group===y);return E.length===0?null:e.jsx(A,{title:y||"Other",icon:y==="System"?"chip":y==="Analytics"?"chart":"wrench",tone:y==="System"?"info":y==="Analytics"?"data":"note",children:e.jsx("div",{className:"list",children:E.map(M=>{var W;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[M.name," ",M.running?e.jsx(C,{tone:"info",children:"running"}):null,M.enabled?null:e.jsx(C,{tone:"warn",children:"off"}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(C,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:M.description}),e.jsxs("p",{className:"quiet",children:[Le(M.intervalSeconds),M.enabled&&M.nextRun?` · next ${pe(M.nextRun).replace(" ago","")}`:"",M.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:O(M.lastRun.startedAt),children:pe(M.lastRun.startedAt)}),` in ${be(M.lastRun.durationMs)}`,M.lastRun.detail?` — ${M.lastRun.detail}`:""]}):" · never run"]}),(W=M.lastRun)!=null&&W.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(C,{tone:"bad",children:M.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[M.lastRun?e.jsx(C,{tone:Xe(M.lastRun.status),children:M.lastRun.status}):e.jsx(C,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${M.name} runs`,value:M.intervalSeconds,disabled:n===`${M.id}:interval`||M.running,onChange:g=>void u(M,Number(g.target.value)),children:Wn(M).map(g=>e.jsxs("option",{value:g.value,children:[g.label,g.value===M.defaultIntervalSeconds?" (default)":""]},g.value))}),M.defaultIntervalSeconds>0&&M.intervalSeconds!==M.defaultIntervalSeconds?e.jsx(R,{size:"sm",icon:"refresh",busy:n===`${M.id}:interval`,onClick:()=>void f(M),children:"Default"}):null,e.jsx(z,{label:"",checked:M.enabled,disabled:n===`${M.id}:enabled`,onChange:g=>void p(M,g)}),e.jsx(R,{size:"sm",icon:"play",busy:n===M.id,disabled:M.running,onClick:()=>void w(M),children:"Run now"})]})]},M.id)})})},y||"other")}),e.jsx(A,{title:"Recent runs",intro:"Every task together and in order, which is what shows two jobs interfering with each other.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Started"}),e.jsx("th",{children:"Task"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"}),e.jsx("th",{children:"Detail"})]})}),e.jsx("tbody",{children:((c==null?void 0:c.runs.length)??0)===0?e.jsx(Q,{columns:6,children:"No task has run yet."}):c==null?void 0:c.runs.map(y=>{var E;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(y.startedAt),children:pe(y.startedAt)}),e.jsx("td",{children:((E=r.find(M=>M.id===y.taskId))==null?void 0:E.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(C,{tone:Xe(y.status),children:y.status})}),e.jsx("td",{className:"num muted",children:be(y.durationMs)}),e.jsx("td",{className:"muted",children:y.error||y.detail||"—"})]},y.id)})})]})})})]})]})}const Hn={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Kn(){const{wrap:s,show:n}=ee(),{busy:l,run:t}=X(),{data:a,error:c,loading:i,reload:o}=Y("/admin/api/integrations",{pollMs:6e4}),[d,r]=x.useState(null),[v,w]=x.useState(null),p=(a==null?void 0:a.catalogue)??[],u=(a==null?void 0:a.integrations)??[],f=S=>r({id:S.id,name:S.name,url:"",enabled:S.enabled,events:S.events??[]}),m=()=>t("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(r(null),await o())}),j=S=>t("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(S.id)}`),`${S.name} removed.`),w(null),await o()}),b=S=>t(`test:${S.id}`,async()=>{const h=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(S.id)}/test`));h&&n(h.message,h.ok?"ok":"bad"),await o()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{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(R,{variant:"primary",icon:"plus",onClick:()=>r(Hn),children:"Add a webhook"})}),e.jsx(B,{message:c}),e.jsx(Gn,{}),e.jsx(Zn,{}),e.jsx(Jn,{}),((a==null?void 0:a.dropped)??0)>0?e.jsxs(ue,{tone:"warn",children:[N((a==null?void 0:a.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,i?e.jsx(_,{}):u.length===0&&!d?e.jsx(A,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(J,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):u.map(S=>e.jsx(Yn,{integration:S,catalogue:p,busy:l,onEdit:()=>f(S),onTest:()=>void b(S),onRemove:()=>w(S)},S.id)),d?e.jsx(Xn,{draft:d,catalogue:p,busy:l==="save",onChange:r,onSave:()=>void m(),onCancel:()=>r(null)}):null,v?e.jsx(he,{title:`Remove ${v.name}?`,body:"The webhook address and its delivery history go with it. Events already published stay in the activity feed.",confirmLabel:"Remove",destructive:!0,busy:l==="remove",onConfirm:()=>void j(v),onCancel:()=>w(null)}):null]})}function Gn(){const{wrap:s}=ee(),{busy:n,run:l}=X(),{data:t,error:a,loading:c,reload:i}=Y("/admin/api/arr-integrations"),o=d=>l("arr-integrations",async()=>{t&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:d.sonarrEnabled??t.sonarrEnabled,radarrEnabled:d.radarrEnabled??t.radarrEnabled}),"Integration settings saved."),await i())});return e.jsxs(A,{title:"Sonarr and Radarr",intro:"Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests.",icon:"plug",tone:"info",children:[e.jsx(B,{message:a??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(z,{label:"Sonarr enabled",hint:t!=null&&t.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(t!=null&&t.sonarrEnabled),disabled:!(t!=null&&t.sonarrConfigured)||n==="arr-integrations",onChange:d=>void o({sonarrEnabled:d})}),e.jsx(z,{label:"Radarr enabled",hint:t!=null&&t.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(t!=null&&t.radarrEnabled),disabled:!(t!=null&&t.radarrConfigured)||n==="arr-integrations",onChange:d=>void o({radarrEnabled:d})})]})]})}function Zn(){const{wrap:s}=ee(),{busy:n,run:l}=X(),{data:t,error:a,loading:c,reload:i}=Y("/admin/api/sonarr-request-policy"),[o,d]=x.useState(0),[r,v]=x.useState(!1);x.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const w=()=>l("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:o,searchImmediately:r}),"Sonarr TV request policy saved."),await i()}),p=t==null?void 0:t.profiles.find(u=>u.id===o);return e.jsxs(A,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(R,{variant:"primary",busy:n==="sonarr-request-policy",disabled:c||o<=0,onClick:()=>void w(),children:"Save Sonarr policy"}),children:[e.jsx(B,{message:a??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(D,{label:"Request quality profile",hint:"Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:u=>d(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(z,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:r,onChange:v}),p?e.jsxs(ue,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:p.name})," (profile ID ",p.id,"), be monitored using Memby’s existing all-episodes strategy, and ",r?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Jn(){const{wrap:s}=ee(),{busy:n,run:l}=X(),{data:t,error:a,loading:c,reload:i}=Y("/admin/api/radarr-request-policy"),[o,d]=x.useState(0),[r,v]=x.useState(!1);x.useEffect(()=>{t&&(d(t.qualityProfileId),v(t.searchImmediately))},[t]);const w=()=>l("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:o,searchImmediately:r}),"Radarr movie request policy saved."),await i()}),p=t==null?void 0:t.profiles.find(u=>u.id===o);return e.jsxs(A,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:t!=null&&t.configured?"ok":"warn",actions:t!=null&&t.configured?e.jsx(C,{tone:"ok",children:"configured"}):e.jsx(C,{tone:"warn",children:"needs attention"}),footer:e.jsx(R,{variant:"primary",busy:n==="radarr-request-policy",disabled:c||o<=0,onClick:()=>void w(),children:"Save Radarr policy"}),children:[e.jsx(B,{message:a??(t==null?void 0:t.error)??""}),c?e.jsx(_,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"fields",children:e.jsx(D,{label:"Request quality profile",hint:"Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:o,onChange:u=>d(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(z,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:r,onChange:v}),p?e.jsxs(ue,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:p.name})," (profile ID ",p.id,"), remain monitored, and ",r?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Yn({integration:s,catalogue:n,busy:l,onEdit:t,onTest:a,onRemove:c}){const i=s.health,o=!i.lastFailure||i.lastSuccess&&i.lastSuccess>i.lastFailure,d=s.events??[];return e.jsxs(A,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(C,{tone:"ok",children:"on"}):e.jsx(C,{tone:"warn",children:"off"}),i.deliveries>0?e.jsx(C,{tone:o?"ok":"bad",children:o?"delivering":"failing"}):e.jsx(C,{children:"never used"}),e.jsx(R,{size:"sm",icon:"pulse",busy:l===`test:${s.id}`,onClick:a,children:"Test"}),e.jsx(R,{size:"sm",onClick:t,children:"Edit"}),e.jsx(R,{size:"sm",variant:"danger",icon:"trash",onClick:c,title:"Remove"})]}),children:[e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Events sent"}),e.jsx("p",{children:d.length===0?"None selected — this destination is configured but will never post anything.":d.map(r=>{var v;return((v=n.find(w=>w.type===r))==null?void 0:v.label)??r}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:i.lastSuccess?O(i.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:i.deliveries>0?e.jsxs("span",{className:"quiet",children:[N(i.deliveries)," attempts, ",N(i.failures)," failed"]}):null})]}),i.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[O(i.lastFailure),i.lastError?` — ${i.lastError}`:""]})]})}):null]}),s.deliveries.length>0?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Attempted"}),e.jsx("th",{children:"Event"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:s.deliveries.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(r.attemptedAt),children:pe(r.attemptedAt)}),e.jsx("td",{className:"muted",children:r.eventType}),e.jsx("td",{children:r.success?e.jsx(C,{tone:"ok",children:r.statusCode||"ok"}):e.jsx(C,{tone:"bad",children:r.error||r.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:be(r.durationMs)})]},r.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(Q,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function Xn({draft:s,catalogue:n,busy:l,onChange:t,onSave:a,onCancel:c}){const i=[...new Set(n.map(d=>d.group))],o=(d,r)=>t({...s,events:r?[...s.events,d]:s.events.filter(v=>v!==d)});return e.jsxs(A,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:l,onClick:a,children:s.id?"Save":"Add"}),e.jsx(R,{variant:"quiet",onClick:c,children:"Cancel"}),e.jsx("span",{className:"spacer"}),s.events.length===0?e.jsx("span",{className:"quiet",children:"Nothing selected — this destination would never post."}):e.jsxs("span",{className:"quiet",children:[s.events.length," events selected"]})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>t({...s,name:d.target.value})})}),e.jsx(D,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>t({...s,url:d.target.value})})})]}),e.jsx(z,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:d=>t({...s,enabled:d})}),i.map(d=>e.jsxs("div",{children:[e.jsx("div",{className:"card-head",style:void 0,children:e.jsx("div",{className:"card-head-text",children:e.jsx("h2",{children:d})})}),n.filter(r=>r.group===d).map(r=>e.jsx(z,{label:r.label,hint:r.description,checked:s.events.includes(r.type),onChange:v=>o(r.type,v)},r.type))]},d))]})}function Qn(){var m;const{status:s,error:n,loading:l,reload:t}=ae(),{wrap:a}=ee(),{busy:c,run:i}=X(),[o,d]=x.useState(""),[r,v]=x.useState(!1),[w,p]=x.useState(!1),u=!!((m=s==null?void 0:s.maintenance)!=null&&m.enabled);x.useEffect(()=>{var j;!w&&s&&d(((j=s.maintenance)==null?void 0:j.message)??"")},[s,w]);const f=j=>i(j?"on":"off",async()=>{await a(()=>F.post("/admin/api/maintenance",{enabled:j,message:o}),j?"Memby is offline for every television.":"Memby is back online."),v(!1),p(!1),await t()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Maintenance",intro:"Take Memby offline for every television."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:1}):e.jsx(A,{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(C,{tone:"bad",children:"offline"}):e.jsx(C,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"danger",disabled:u,onClick:()=>v(!0),children:"Go offline"}),e.jsx(R,{disabled:!u,busy:c==="off",onClick:()=>void f(!1),children:"Bring back online"})]}),children:e.jsx(D,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:o,placeholder:"Back shortly — upgrading the server",onChange:j=>{d(j.target.value),p(!0)}})})}),r?e.jsx(he,{title:"Take Memby offline?",body:"Every television will stop working immediately and show your message in place of the launcher. This console keeps working.",confirmLabel:"Go offline",destructive:!0,busy:c==="on",onConfirm:()=>void f(!0),onCancel:()=>v(!1)}):null]})}function et(){const{status:s,error:n,loading:l}=ae(),t=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(B,{message:n}),l?e.jsx(_,{rows:1}):e.jsx(A,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(Q,{columns:8,children:"Nothing has been imported yet."}):t.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:O(a.startedAt)}),e.jsx("td",{children:a.kind}),e.jsx("td",{className:"muted",children:a.trigger}),e.jsx("td",{children:e.jsx(C,{tone:a.status==="success"?"ok":a.status==="running"?"warn":"bad",children:a.status})}),e.jsx("td",{className:"num",children:N(a.itemsSeen)}),e.jsx("td",{className:"num",children:N(a.itemsUpserted)}),e.jsx("td",{className:"num",children:N(a.itemsRemoved)}),e.jsx("td",{className:"muted",children:a.error||""})]},a.id||a.startedAt))})]})})})]})}const Qe={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},es=2e4,st=5e3,we=48,nt=31,ss=8,tt=["component","user","device","client","protocol","method","path","status","duration","version","gateway_version"],ns=new Map(tt.map((s,n)=>[s,n])),it=new Intl.DateTimeFormat(void 0,{dateStyle:"short",timeStyle:"medium"}),ts=new WeakMap;function ke(s){const n=ts.get(s);if(n)return n;const l=Object.entries(s.attributes??{}).sort((a,c)=>{const i=ns.get(a[0])??(a[0]==="error"?1e3:100),o=ns.get(c[0])??(c[0]==="error"?1e3:100);return i-o||a[0].localeCompare(c[0])}),t={fields:l,summary:l.map(([a,c])=>`${a}=${String(c)}`).join(" "),haystack:[s.message,...l.flat()].join(" ").toLowerCase(),occurred:it.format(new Date(s.occurredAt))};return ts.set(s,t),t}const at=s=>s.replace(/_/g," "),rt=x.memo(function({event:n,index:l,onInspect:t}){const a=ke(n);return e.jsxs("div",{className:"logline","data-level":n.level,"data-virtual":"true",style:{transform:`translateY(${l*we}px)`},children:[e.jsxs("time",{title:n.occurredAt,children:[a.occurred,e.jsxs("small",{children:["#",n.sequence]})]}),e.jsx("span",{className:"lvl",children:n.level}),e.jsx("span",{className:"msg",title:n.message,children:n.message}),e.jsx("button",{type:"button",className:"logattrs-button",title:a.summary||"No structured details",onClick:()=>t(n.sequence),children:a.summary||"View record"})]})});function lt(){var q;const[s,n]=x.useState([]),[l,t]=x.useState(0),[a,c]=x.useState(!1),[i,o]=x.useState("INFO"),[d,r]=x.useState(""),[v,w]=x.useState(""),[p,u]=x.useState({top:0,height:600}),[f,m]=x.useState(null),j=x.useDeferredValue(d.trim().toLowerCase()),b=x.useRef(0),S=x.useRef(!1),h=x.useRef(0),y=x.useRef(null),E=x.useRef(!0),M=x.useRef(void 0),W=x.useCallback(async()=>{if(a||S.current||document.hidden)return;S.current=!0;const $=h.current,P=[];let ce=0;try{let de=0,le;do le=await F.get(`/admin/api/events?after=${b.current}&limit=1000`),b.current=le.next||b.current,ce+=le.dropped||0,P.push(...le.events??[]),de+=1;while(le.hasMore&&de<20);w("")}catch(de){w(de instanceof Error?de.message:String(de))}finally{P.length>0&&$===h.current&&n(de=>{const le=de.concat(P);return le.length>es?le.slice(le.length-es):le}),ce>0&&$===h.current&&t(de=>de+ce),S.current=!1}},[a]);x.useEffect(()=>{if(a)return;let $;const P=()=>{window.clearInterval($),$=document.hidden?void 0:window.setInterval(()=>void W(),st)},ce=()=>{P(),document.hidden||W()};return W(),P(),document.addEventListener("visibilitychange",ce),()=>{window.clearInterval($),document.removeEventListener("visibilitychange",ce)}},[W,a]);const g=x.useMemo(()=>{const $=Qe[i]??20;return s.filter(P=>(Qe[P.level]??0)>=$&&(!j||ke(P).haystack.includes(j)))},[s,i,j]),T=((q=g.at(-1))==null?void 0:q.sequence)??0,K=Math.max(0,p.top-nt),ie=Math.ceil(p.height/we)+ss*2,G=Math.min(Math.max(0,Math.floor(K/we)-ss),Math.max(0,g.length-ie)),I=g.slice(G,G+ie),L=x.useMemo(()=>s.find($=>$.sequence===f),[s,f]);x.useLayoutEffect(()=>{const $=y.current;!$||!E.current||($.scrollTop=$.scrollHeight,u({top:$.scrollTop,height:$.clientHeight}))},[T,j,i]),x.useEffect(()=>()=>window.cancelAnimationFrame(M.current??0),[]);const H=()=>{const $=y.current;$&&(E.current=$.scrollHeight-$.scrollTop-$.clientHeight{u({top:$.scrollTop,height:$.clientHeight})}))},k=()=>{const $=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),P=document.createElement("a");P.href=URL.createObjectURL($),P.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,P.click(),window.setTimeout(()=>URL.revokeObjectURL(P.href),1e3)};return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(B,{message:v}),e.jsxs(A,{children:[e.jsxs("div",{className:"filters",children:[e.jsx(D,{label:"Level",children:e.jsxs("select",{value:i,onChange:$=>o($.target.value),children:[e.jsx("option",{value:"DEBUG",children:"Debug and above"}),e.jsx("option",{value:"INFO",children:"Info and above"}),e.jsx("option",{value:"WARN",children:"Warnings and errors"}),e.jsx("option",{value:"ERROR",children:"Errors only"})]})}),e.jsx(D,{label:"Filter",grow:!0,children:e.jsx("input",{type:"search",value:d,placeholder:"Person, television, title, component, path…",onChange:$=>r($.target.value)})}),e.jsxs("div",{className:"filter-actions",children:[e.jsx(R,{onClick:()=>c($=>!$),icon:a?"play":"clock",children:a?"Resume":"Pause"}),e.jsx(R,{onClick:()=>{h.current+=1,n([]),t(0),m(null)},children:"Clear view"}),e.jsx(R,{onClick:k,icon:"download",children:"Export JSON"})]})]}),e.jsxs("div",{className:"logview",ref:y,onScroll:H,role:"log","aria-label":"Server events",children:[e.jsxs("div",{className:"loghead","aria-hidden":"true",children:[e.jsx("span",{children:"Time"}),e.jsx("span",{children:"Level"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Details"})]}),g.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match this filter."}):e.jsx("div",{className:"logbody",style:{height:`${g.length*we}px`},children:I.map(($,P)=>e.jsx(rt,{event:$,index:G+P,onInspect:m},$.sequence))})]}),e.jsxs("p",{className:"hint",children:[N(s.length)," retained · ",N(g.length)," matching",g.length?` · ${N(I.length)} rows mounted`:"",l?` · ${N(l)} overwritten before delivery`:"",a?" · paused":""]}),L?e.jsxs("section",{className:"log-inspector","aria-label":`Log record ${L.sequence}`,children:[e.jsxs("div",{className:"log-inspector-head",children:[e.jsxs("div",{children:[e.jsx("b",{children:L.message}),e.jsxs("span",{children:[ke(L).occurred," · ",L.level," · record #",L.sequence]})]}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>m(null),children:"Close"})]}),ke(L).fields.length?e.jsx("dl",{children:ke(L).fields.map(([$,P])=>e.jsxs(x.Fragment,{children:[e.jsx("dt",{children:at($)}),e.jsx("dd",{children:String(P)})]},$))}):e.jsx(ue,{children:"No structured details were attached to this record."})]}):null]})]})}const is=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","notifications","profiles","settings"];function xe(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function ot(){const[s,n]=x.useState(30),[l,t]=x.useState(""),a=qe(),c=x.useMemo(()=>`/admin/api/journeys${Se({days:s,userId:l})}`,[s,l]),{data:i,error:o,loading:d}=Y(c),r=i==null?void 0:i.stats,v=(i==null?void 0:i.users)??[],w=(i==null?void 0:i.actions)??[],p=(i==null?void 0:i.paths)??[],u=p[0],f=x.useMemo(()=>{const m=new Map(((i==null?void 0:i.features)??[]).map(b=>[b.feature,b])),j=new Map(is.map((b,S)=>[b,S]));return[...new Set([...is,...m.keys()])].map(b=>({name:b,stat:m.get(b)})).sort((b,S)=>{var y,E;const h=(((y=S.stat)==null?void 0:y.uses)??0)-(((E=b.stat)==null?void 0:E.uses)??0);return h||(j.get(b.name)??Number.MAX_SAFE_INTEGER)-(j.get(S.name)??Number.MAX_SAFE_INTEGER)})},[i==null?void 0:i.features]);return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(B,{message:o}),e.jsx(A,{title:"Journey health",intro:"Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored.",icon:"people",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsx(D,{label:"Window",children:e.jsxs("select",{value:s,onChange:m=>n(Number(m.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(D,{label:"User",children:e.jsxs("select",{value:l,onChange:m=>{const j=m.target.value;t(j),j&&a(`/admin/journeys/${encodeURIComponent(j)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(m=>e.jsx("option",{value:m.userId,children:m.username||m.userId},m.userId))]})})]}),children:d?e.jsx(_,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:"journeys",value:N(r==null?void 0:r.journeys),icon:"list",tone:"data"},{label:"viewers",value:N(r==null?void 0:r.viewers),icon:"people",tone:"info"},{label:"completion",value:Me(r==null?void 0:r.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:N(r==null?void 0:r.abandoned),icon:"alert",tone:"note"},{label:"active now",value:N(r==null?void 0:r.active),icon:"pulse",tone:"info"},{label:"average steps",value:((r==null?void 0:r.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:be(r==null?void 0:r.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(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:Me(r==null?void 0:r.completionRate)})]}),e.jsx(Qs,{value:(r==null?void 0:r.completed)??0,total:(r==null?void 0:r.journeys)??0}),e.jsxs("p",{children:[N(r==null?void 0:r.completed)," completed · ",N(r==null?void 0:r.abandoned)," abandoned ·"," ",N(r==null?void 0:r.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:u?`${xe(u.from)} → ${xe(u.to)}`:"Not enough data"}),e.jsx("p",{children:u?`${N(u.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(oe,{cols:"2",children:[e.jsx(A,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Action"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{className:"num",children:"Visits"})]})}),e.jsx("tbody",{children:w.length===0?e.jsx(Q,{columns:3,children:"No significant actions in this window."}):w.map(m=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:xe(m.action)}),e.jsx("span",{className:"table-sub",children:xe(m.category)})]}),e.jsx("td",{className:"num",children:N(m.events)}),e.jsx("td",{className:"num",children:N(m.journeys)})]},`${m.category}:${m.action}`))})]})})}),e.jsx(A,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:p.length===0?e.jsx(Q,{columns:2,children:"No repeated paths in this window."}):p.map((m,j)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[xe(m.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",xe(m.to)]}),e.jsx("td",{className:"num",children:N(m.count)})]},`${m.from}:${m.to}:${j}`))})]})})})]}),e.jsx(A,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Feature"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{children:"Last used"}),e.jsx("th",{children:"Status"})]})}),e.jsx("tbody",{children:f.map(({name:m,stat:j})=>{const b=(j==null?void 0:j.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:xe(m)}),e.jsx("td",{className:"num",children:N(b)}),e.jsx("td",{className:"muted nowrap",children:j?O(j.lastUsedAt):"—"}),e.jsx("td",{children:b===0?e.jsx(C,{tone:"warn",children:"not used"}):b<3?e.jsx(C,{tone:"note",children:"rare"}):e.jsx(C,{tone:"ok",children:"used"})})]},m)})})]})})}),l?null:e.jsx(A,{title:"Inspect a viewer",intro:"Choose a person above to open their dedicated session and viewing-journey timeline.",icon:"journey",tone:"info",children:e.jsx("p",{className:"empty",children:"A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys."})})]})}const ve=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},Ns=s=>ve((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),as=s=>s.itemName?`${ve(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${ve(s.source)} → ${ve(s.target)}`:Ns(s),ct=s=>({journey_start:"Opened Memby",home_open:"Opened Memby",journey_end:"Finished session",screen_view:"Viewed",select:"Selected",open:"Opened",close:"Closed",request:s.category==="playback"?"Started watching":"Requested",stop:"Stopped watching",start:"Started",complete:"Completed"})[s.action]??ve(s.action);function dt(s){var l;const n=(l=[...s].reverse().find(t=>t.outcome))==null?void 0:l.outcome;return n==="success"||n==="completed"?{label:ve(n),tone:"ok"}:n==="failure"||n==="cancelled"||n==="abandoned"?{label:ve(n),tone:"note"}:s.some(t=>t.action==="stop"&&t.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function ht(s){const n=s.reduce((l,t,a)=>(t.category==="playback"&&t.action==="request"&&l.push(a),l),[]);return n.length===0?[s]:n.map((l,t)=>s.slice(t===0?0:l,n[t+1]??s.length))}function ut(){var d,r;const{userId:s=""}=Re(),n=x.useMemo(()=>`/admin/api/journeys${Se({days:90,userId:s})}`,[s]),{data:l,error:t,loading:a}=Y(n),c=((r=(d=l==null?void 0:l.users)==null?void 0:d.find(v=>v.userId===s))==null?void 0:r.username)||s,i=x.useMemo(()=>{const v=new Map;for(const w of(l==null?void 0:l.events)??[])v.set(w.journeyId,[...v.get(w.journeyId)??[],w]);return[...v.values()].map(w=>w.sort((p,u)=>p.sequence-u.sequence)).sort((w,p)=>{var u,f;return(((u=p[0])==null?void 0:u.occurredAt)??"").localeCompare(((f=w[0])==null?void 0:f.occurredAt)??"")})},[l==null?void 0:l.events]),o=i.flatMap(v=>ht(v).map((w,p)=>{var u;return{events:w,key:`${(u=v[0])==null?void 0:u.journeyId}:${p}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:`${c}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(ne,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(B,{message:t}),a?e.jsx(_,{}):e.jsx(A,{title:"Viewing journeys",intro:`${i.length} app session${i.length===1?"":"s"} · ${o.length} viewing journey${o.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:o.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):o.map((v,w)=>{const p=v.events,u=p[0],f=[...p].reverse().find(j=>j.itemName||j.action==="select"||j.category==="playback"&&j.action==="request"),m=dt(p);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:O(u==null?void 0:u.occurredAt)}),e.jsxs("span",{children:["Journey ",w+1," · ",p.length," recorded steps"]})]}),e.jsx(C,{tone:m.tone,children:m.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(te,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:Ns(u)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(te,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?as(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(te,{name:m.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:m.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:p.map(j=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":j.action}),e.jsxs("div",{children:[e.jsx("b",{children:ct(j)}),e.jsx("span",{children:as(j)})]}),e.jsx("time",{children:O(j.occurredAt)})]},`${j.journeyId}:${j.sequence}`))})]},v.key)})})})]})}function rs(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function mt(){const[s,n]=x.useState(30),{data:l,error:t,loading:a}=Y(`/admin/api/analytics?days=${s}`),c=(l==null?void 0:l.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(B,{message:t}),e.jsx(A,{title:"Launcher rows",intro:"Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth.",icon:"chart",tone:"info",actions:e.jsx(D,{label:"Window",children:e.jsxs("select",{value:s,onChange: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:a?e.jsx(_,{rows:1}):e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Row"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{className:"num",children:"Dwell"}),e.jsx("th",{className:"num",children:"Impressions"}),e.jsx("th",{className:"num",children:"Focuses"}),e.jsx("th",{className:"num",children:"Opened"}),e.jsx("th",{className:"num",children:"Open rate"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(Q,{columns:8,children:"No events in this window."}):c.map(i=>e.jsxs("tr",{children:[e.jsx("td",{children:rs(i.rowId)}),e.jsx("td",{className:"muted",children:rs(i.rowKind)}),e.jsx("td",{className:"num",children:be(i.dwellMs)}),e.jsx("td",{className:"num",children:N(i.impressions)}),e.jsx("td",{className:"num",children:N(i.focuses)}),e.jsx("td",{className:"num",children:N(i.selects)}),e.jsx("td",{className:"num",children:Me(i.selectRate)}),e.jsx("td",{className:"num",children:N(i.viewers)})]},`${i.rowId}:${i.rowKind}`))})]})})})]})}function pt(){const[s,n]=x.useState(7),{data:l,error:t,loading:a}=Y(`/admin/api/searches?days=${s}`),c=(l==null?void 0:l.terms)??[],i=(l==null?void 0:l.recent)??[],o=l==null?void 0:l.totals;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(B,{message:t}),e.jsx(re,{tiles:[{label:"searches",value:N((o==null?void 0:o.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:N((o==null?void 0:o.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:N((o==null?void 0:o.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(l==null?void 0:l.retentionDays)??30} days`,small:!0,icon:"clock"}]}),a?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(A,{title:"What the house looks for",intro:"Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too.",icon:"search",tone:"info",actions:e.jsx(D,{label:"Window",children:e.jsxs("select",{value:s,onChange:d=>n(Number(d.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Query"}),e.jsx("th",{className:"num",children:"Searches"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Last searched"})]})}),e.jsx("tbody",{children:c.length===0?e.jsx(Q,{columns:4,children:"Nothing searched in this window."}):c.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.query}),e.jsx("td",{className:"num",children:N(d.searches)}),e.jsx("td",{className:"num",children:N(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:O(d.lastAt)})]},d.query))})]})})}),e.jsx(A,{title:"As it happened",intro:"The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(Q,{columns:3,children:"No searches in this window."}):i.map((d,r)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:O(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(C,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${r}`))})]})})})]})]})}function ls(s,n){if(n===0)return s>0?"new this week":"no change";const l=Math.round((s-n)/n*100);return`${l>0?"+":""}${l}% vs last week`}function xt(){const{data:s,error:n,loading:l}=Y("/admin/api/views",{pollMs:6e4}),t=(s==null?void 0:s.daily)??[],a=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(B,{message:n}),l?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[e.jsx(re,{tiles:[{label:ls((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:N(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:ls((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:N(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(A,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:t.length===0?e.jsx(Q,{columns:3,children:"No home-screen visits yet."}):t.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})}),e.jsx(A,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:a.length===0?e.jsx(Q,{columns:3,children:"No home-screen visits yet today."}):a.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.label}),e.jsx("td",{className:"num",children:N(c.visits)}),e.jsx("td",{className:"num",children:N(c.viewers)})]},c.label))})]})})})]})]})}const jt=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function vt(){var a;const s=Y("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:l}=X(),t=(c,i)=>l(`${c.id}-${i}`,async()=>{await F.post(`/admin/api/media-reports/${c.id}/status`,{status:i}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(_,{rows:4}):e.jsx(A,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(a=s.data)!=null&&a.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(c=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:jt(c)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.title})]}),e.jsxs("td",{children:[c.reason.replaceAll("_"," "),c.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.comment})]}):null]}),e.jsxs("td",{children:[c.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:c.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(C,{tone:c.replacementRequested?"note":void 0,children:c.replacementRequested?c.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(C,{tone:c.status==="resolved"?"ok":c.status==="dismissed"?void 0:"warn",children:c.status})}),e.jsx("td",{className:"nowrap muted",children:O(c.createdAt)}),e.jsxs("td",{children:[e.jsx(R,{size:"sm",variant:"quiet",busy:n===`${c.id}-acknowledged`,onClick:()=>void t(c,"acknowledged"),children:"Acknowledge"})," ",e.jsx(R,{size:"sm",variant:"quiet",busy:n===`${c.id}-resolved`,onClick:()=>void t(c,"resolved"),children:"Resolve"})," ",e.jsx(R,{size:"sm",variant:"quiet",busy:n===`${c.id}-dismissed`,onClick:()=>void t(c,"dismissed"),children:"Dismiss"})]})]},c.id))})]})}):e.jsx(J,{children:"No media problems have been reported."})})]})}const bt=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",ft=s=>s==="no_match"?"no match":s,os=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",cs=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function gt(){var m,j,b,S;const s=Y("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=ee(),{busy:l,run:t}=X(),[a,c]=x.useState(),[i,o]=x.useState(!1);x.useEffect(()=>{!i&&s.data&&c(s.data.settings)},[s.data,i]);const d=h=>{c(y=>y&&{...y,...h}),o(!0)},r=()=>{a&&t("save",async()=>{const h=await n(()=>F.put("/admin/api/credits",a),"Credits scanning settings saved.");h&&(s.set(h),c(h.settings),o(!1))})},v=((m=s.data)==null?void 0:m.history)??[],w=((j=s.data)==null?void 0:j.pending)??[],p=v.filter(h=>h.outcome==="detected").length,u=v.filter(h=>h.outcome==="no_match").length,f=v.filter(h=>h.outcome==="failed").length;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Credits detection",intro:"Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."}),e.jsx(B,{message:s.error}),s.loading||!a?e.jsx(_,{}):e.jsxs(e.Fragment,{children:[(b=s.data)!=null&&b.enabled?null:e.jsx(ue,{tone:"warn",children:"Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled."}),e.jsx(re,{tiles:[{label:"Waiting candidates",value:N((S=s.data)==null?void 0:S.queueDepth),icon:"clock",tone:w.length?"info":void 0},{label:"Detected in this history",value:N(p),icon:"check",tone:"ok"},{label:"No match",value:N(u),icon:"search",tone:u?"warn":void 0},{label:"Failed",value:N(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(oe,{cols:"wide",children:[e.jsx(A,{title:"Candidate controls",intro:"The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead.",icon:"sliders",tone:"note",footer:e.jsx(R,{variant:"primary",icon:"check",busy:l==="save",disabled:!i,onClick:r,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(D,{label:"Candidate limit",hint:"Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.",children:e.jsx("input",{type:"number",min:1,max:100,value:a.candidateLimit,onChange:h=>d({candidateLimit:Number(h.target.value)})})}),e.jsx(D,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:a.prefetchEpisodes,onChange:h=>d({prefetchEpisodes:Number(h.target.value)})})}),e.jsx(D,{label:"Maximum look-ahead",hint:"Upper bound for fast binge viewing; must not be below the ordinary look-ahead.",children:e.jsx("input",{type:"number",min:a.prefetchEpisodes,max:20,value:a.maxPrefetch,onChange:h=>d({maxPrefetch:Number(h.target.value)})})}),e.jsx(D,{label:"Retry delay (hours)",hint:"After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.",children:e.jsx("input",{type:"number",min:0,max:720,value:a.retryHours,onChange:h=>d({retryHours:Number(h.target.value)})})})]})}),e.jsxs(A,{title:"How selection works",icon:"sparkle",tone:"data",children:[e.jsx("p",{className:"muted",children:"Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching."}),e.jsx("p",{className:"muted",children:"A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it."})]})]}),e.jsx(A,{title:"Waiting candidates",intro:"The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes.",icon:"list",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Episode"}),e.jsx("th",{children:"Reason"}),e.jsx("th",{className:"num",children:"Priority"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Demand seen"}),e.jsx("th",{children:"Item ID"})]})}),e.jsx("tbody",{children:w.length===0?e.jsx(Q,{columns:6,children:"No episodes are waiting to be scanned."}):w.map(h=>e.jsxs("tr",{children:[e.jsx("td",{children:cs(h.season,h.episode)}),e.jsx("td",{children:e.jsx(C,{tone:"info",children:os(h.reason)})}),e.jsx("td",{className:"num",children:N(h.priority)}),e.jsx("td",{className:"num",children:N(h.userCount)}),e.jsx("td",{className:"nowrap muted",title:O(h.lastViewed),children:pe(h.lastViewed)}),e.jsx("td",{className:"mono muted",children:h.itemId})]},h.itemId))})]})})}),e.jsx(A,{title:"Scan history",intro:"Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Finished"}),e.jsx("th",{children:"Programme"}),e.jsx("th",{children:"Selected because"}),e.jsx("th",{children:"Result"}),e.jsx("th",{children:"Marker"}),e.jsx("th",{children:"Evidence"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:v.length===0?e.jsx(Q,{columns:7,children:"No credits scans have completed yet."}):v.map(h=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:O(h.finishedAt),children:pe(h.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:h.seriesName||h.itemName||h.itemId}),e.jsxs("span",{className:"table-sub",children:[cs(h.season,h.episode),h.itemName&&h.seriesName?` · ${h.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(C,{tone:"info",children:os(h.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",h.priority]})]}),e.jsxs("td",{children:[e.jsx(C,{tone:bt(h.outcome),children:ft(h.outcome)}),h.error?e.jsx("span",{className:"table-sub",children:h.error}):null]}),e.jsx("td",{className:"nowrap",children:h.markerMs>0?be(h.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[h.method||"visual",h.confidence>0?` · ${Me(h.confidence)}`:"",h.frames>0?` · ${h.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:be(h.durationMs)})]},h.id))})]})})})]})]})}function yt(){return e.jsx(U,{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 wt(){return e.jsx(As,{children:e.jsx(zs,{children:e.jsx(Ys,{children:e.jsx(tn,{children:e.jsx(Is,{children:e.jsxs(V,{path:"/admin",element:e.jsx(sn,{}),children:[e.jsx(V,{index:!0,element:e.jsx(an,{})}),e.jsx(V,{path:"activity",element:e.jsx(ln,{})}),e.jsx(V,{path:"accounts",element:e.jsx(on,{})}),e.jsx(V,{path:"accounts/:userId",element:e.jsx(cn,{})}),e.jsx(V,{path:"accounts/:userId/settings",element:e.jsx(xn,{})}),e.jsx(V,{path:"clients",element:e.jsx(vn,{})}),e.jsx(V,{path:"logins",element:e.jsx(fn,{})}),e.jsx(V,{path:"devices/:deviceId",element:e.jsx(kn,{})}),e.jsx(V,{path:"library",element:e.jsx(Nn,{})}),e.jsx(V,{path:"ratings",element:e.jsx(Cn,{})}),e.jsx(V,{path:"requests",element:e.jsx(Mn,{})}),e.jsx(V,{path:"recommendations",element:e.jsx(En,{})}),e.jsx(V,{path:"inspector",element:e.jsx(In,{})}),e.jsx(V,{path:"hero",element:e.jsx(Dn,{})}),e.jsx(V,{path:"features",element:e.jsx(Fn,{})}),e.jsx(V,{path:"playback",element:e.jsx(Pn,{})}),e.jsx(V,{path:"subtitles",element:e.jsx(On,{})}),e.jsx(V,{path:"credits",element:e.jsx(gt,{})}),e.jsx(V,{path:"updates",element:e.jsx(Bn,{})}),e.jsx(V,{path:"tasks",element:e.jsx(zn,{})}),e.jsx(V,{path:"integrations",element:e.jsx(Kn,{})}),e.jsx(V,{path:"maintenance",element:e.jsx(Qn,{})}),e.jsx(V,{path:"imports",element:e.jsx(et,{})}),e.jsx(V,{path:"logs",element:e.jsx(lt,{})}),e.jsx(V,{path:"journeys",element:e.jsx(ot,{})}),e.jsx(V,{path:"journeys/:userId",element:e.jsx(ut,{})}),e.jsx(V,{path:"views",element:e.jsx(xt,{})}),e.jsx(V,{path:"engagement",element:e.jsx(mt,{})}),e.jsx(V,{path:"searches",element:e.jsx(pt,{})}),e.jsx(V,{path:"media-reports",element:e.jsx(vt,{})}),e.jsx(V,{path:"overview",element:e.jsx($s,{to:"/admin",replace:!0})}),e.jsx(V,{path:"*",element:e.jsx(yt,{})})]})})})})})})}const Ss=document.getElementById("root");if(!Ss)throw new Error("the console has no root element to render into");us(Ss).render(e.jsx(x.StrictMode,{children:e.jsx(wt,{})})); diff --git a/admin-ui/dist/assets/index-DSlxKU2t.css b/admin-ui/dist/assets/index-DSlxKU2t.css deleted file mode 100644 index 35a24c4..0000000 --- a/admin-ui/dist/assets/index-DSlxKU2t.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display: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[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right: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 max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-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 span{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{min-width:40px;height:40px;padding:0 10px;border:1px solid var(--line);background:var(--surface)}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 820px){.brand-word,.topbar-status b{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono);contain:layout paint style}.loghead{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);gap:12px;padding:7px 12px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:760px}.logbody{position:relative;min-width:760px}.logline{display:flex;gap:12px;padding:3px 12px;border-bottom:1px solid rgba(255,255,255,.03);white-space:pre-wrap;overflow-wrap:anywhere}.logline[data-virtual=true]{position:absolute;top:0;right:0;left:0;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);align-items:center;height:48px;padding-top:4px;padding-bottom:4px;white-space:nowrap;contain:strict}.logline:hover{background:#ffffff08}.logline time{flex:0 0 auto;color:var(--quiet)}.logline time small{display:block;color:var(--muted);font-size:10px}.logline .lvl{flex:0 0 46px;font-weight:700}.logline[data-level=ERROR] .lvl{color:var(--danger-ink)}.logline[data-level=WARN] .lvl{color:var(--warn-ink)}.logline[data-level=INFO] .lvl{color:var(--info-ink)}.logline[data-level=DEBUG] .lvl{color:var(--quiet)}.logline .msg{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}.logline .attrs{color:var(--quiet)}@media (min-width: 900px){.logline{display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr)}}.logline .attrs b{color:var(--muted);font-weight:400}.logattrs-button{display:block;width:100%;min-width:0;height:28px;padding:0;overflow:hidden;border:0;background:transparent;color:var(--quiet);font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logattrs-button:hover:not(:disabled){border:0;background:transparent;color:var(--text)}.logattrs-button:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.log-inspector{margin-top:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.log-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.log-inspector-head>div{min-width:0}.log-inspector-head b,.log-inspector-head span{display:block;overflow-wrap:anywhere}.log-inspector-head span{margin-top:3px;color:var(--quiet);font:11px/1.5 var(--mono)}.log-inspector dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:5px 14px;margin:12px 0 0;padding-top:10px;border-top:1px solid var(--line);font:12px/1.5 var(--mono)}.log-inspector dt{color:var(--muted)}.log-inspector dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.loghead,.logbody{min-width:680px}.loghead,.logline[data-virtual=true]{grid-template-columns:128px 44px minmax(160px,1fr) minmax(220px,1.4fr);gap:10px}.logview{height:min(66dvh,720px)}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:41px}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar form button{width:44px;height:44px;padding:0}.topbar-status b{margin-left:1px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}} diff --git a/admin-ui/dist/assets/index-Dyz7s7hT.css b/admin-ui/dist/assets/index-Dyz7s7hT.css new file mode 100644 index 0000000..4a37873 --- /dev/null +++ b/admin-ui/dist/assets/index-Dyz7s7hT.css @@ -0,0 +1 @@ +@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display: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;transition:background .16s ease,border-color .16s ease,color .16s ease}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor: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);box-shadow:0 0 0 4px var(--accent-wash),0 0 12px #56d36457}.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}.account-menu{position:relative;flex:0 0 auto}.account-trigger{max-width:190px;height:38px;padding:0 9px 0 5px;border-color:var(--line);background:var(--surface);color:var(--muted)}.account-trigger:hover:not(:disabled),.account-menu[data-open=true] .account-trigger{border-color:#56d36480;background:var(--surface-hi);color:var(--text)}.account-avatar{display:grid;place-items:center;width:27px;height:27px;flex:0 0 27px;border:1px solid rgba(86,211,100,.36);border-radius:50%;background:var(--accent-wash);color:var(--accent-ink);font-size:11px;font-weight:750}.account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}.account-caret{width:12px;height:12px;transition:transform .16s ease}.account-menu[data-open=true] .account-caret{transform:rotate(180deg)}.account-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(280px,calc(100vw - 24px));padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.account-identity{display:flex;align-items:center;gap:11px;min-width:0;padding:9px 10px 12px;border-bottom:1px solid var(--line-soft)}.account-avatar-large{width:34px;height:34px;flex-basis:34px;font-size:13px}.account-identity span:last-child{min-width:0}.account-identity small,.account-identity b{display:block}.account-identity small{color:var(--quiet);font-size:11px}.account-identity b{overflow:hidden;text-overflow:ellipsis;font-size:13.5px}.account-panel form{margin-top:6px}.account-panel form button{justify-content:flex-start;width:100%;height:38px;border-color:transparent;background:transparent;color:var(--muted)}.account-panel form button:hover:not(:disabled){background:var(--surface-hi);color:var(--text)}@media (max-width: 900px){.topbar-version,.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[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right: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 max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);min-width:0;margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-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 span{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{min-width:40px;height:40px;padding:0 10px;border:1px solid var(--line);background:var(--surface)}.topbar-status[data-tone=ok]{border-color:#56d36461;background:var(--accent-wash);color:var(--accent-ink)}.topbar-status[data-tone=bad]{border-color:#e5534b57;background:var(--danger-wash);color:var(--danger-ink)}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 1100px){.account-trigger{width:40px;padding:0}.account-name,.account-caret{display:none}}@media (max-width: 820px){.brand-word,.topbar-status b{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}@media (max-width: 680px){:root{--top: calc(108px + var(--safe-top))}.topbar{align-content:center;align-items:center;flex-wrap:wrap;gap:8px;padding:calc(var(--safe-top) + 8px) max(12px,var(--safe-right)) 8px max(12px,var(--safe-left))}.rail-toggle{order:1}.topbar-brand{order:2}.topbar-status{order:3;margin-left:auto}.bell{order:4}.account-menu{order:5}.topbar .omni{order:6;flex:1 0 100%;max-width:none}.brand-mark{width:32px;height:32px;flex-basis:32px}.omni-input{height:38px}.account-panel,.bell-panel{position:fixed;top:calc(var(--top) + 8px)}.account-panel{right:max(12px,var(--safe-right))}}@media (max-width: 370px){.tiles{grid-template-columns:minmax(0,1fr)}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.6 var(--mono);contain:layout paint style}.loghead{position:sticky;top:0;z-index:1;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);gap:12px;padding:7px 12px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:760px}.logbody{position:relative;min-width:760px}.logline{display:flex;gap:12px;padding:3px 12px;border-bottom:1px solid rgba(255,255,255,.03);white-space:pre-wrap;overflow-wrap:anywhere}.logline[data-virtual=true]{position:absolute;top:0;right:0;left:0;display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr);align-items:center;height:48px;padding-top:4px;padding-bottom:4px;white-space:nowrap;contain:strict}.logline:hover{background:#ffffff08}.logline time{flex:0 0 auto;color:var(--quiet)}.logline time small{display:block;color:var(--muted);font-size:10px}.logline .lvl{flex:0 0 46px;font-weight:700}.logline[data-level=ERROR] .lvl{color:var(--danger-ink)}.logline[data-level=WARN] .lvl{color:var(--warn-ink)}.logline[data-level=INFO] .lvl{color:var(--info-ink)}.logline[data-level=DEBUG] .lvl{color:var(--quiet)}.logline .msg{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}.logline .attrs{color:var(--quiet)}@media (min-width: 900px){.logline{display:grid;grid-template-columns:150px 46px minmax(180px,1fr) minmax(240px,2fr)}}.logline .attrs b{color:var(--muted);font-weight:400}.logattrs-button{display:block;width:100%;min-width:0;height:28px;padding:0;overflow:hidden;border:0;background:transparent;color:var(--quiet);font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logattrs-button:hover:not(:disabled){border:0;background:transparent;color:var(--text)}.logattrs-button:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.log-inspector{margin-top:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.log-inspector-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.log-inspector-head>div{min-width:0}.log-inspector-head b,.log-inspector-head span{display:block;overflow-wrap:anywhere}.log-inspector-head span{margin-top:3px;color:var(--quiet);font:11px/1.5 var(--mono)}.log-inspector dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:5px 14px;margin:12px 0 0;padding-top:10px;border-top:1px solid var(--line);font:12px/1.5 var(--mono)}.log-inspector dt{color:var(--muted)}.log-inspector dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.loghead,.logbody{min-width:680px}.loghead,.logline[data-virtual=true]{grid-template-columns:128px 44px minmax(160px,1fr) minmax(220px,1.4fr);gap:10px}.logview{height:min(66dvh,720px)}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:0}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar .account-trigger{width:44px;height:44px;padding:0}.topbar-status b{margin-left:1px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (pointer: fine) and (min-width: 821px) and (max-height: 800px){.page{padding-top:20px;padding-bottom:52px}.page-head{margin-bottom:16px}.card{padding-top:14px;padding-bottom:14px}.card-head{margin-bottom:11px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}} diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index bdf9c52..7791424 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -13,9 +13,9 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" /> - + - +
diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index fb0496d..76170c0 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -34,6 +34,7 @@ import { EngagementPage } from './pages/Engagement'; import { SearchesPage } from './pages/Searches'; import { ViewsPage } from './pages/Views'; import { MediaReportsPage } from './pages/MediaReports'; +import { CreditsPage } from './pages/Credits'; /* The console's routing table. * @@ -79,6 +80,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index a9af90f..cc6b9d5 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -216,6 +216,7 @@ export interface FeaturePolicy { export interface AdminStatus { serverVersion: string; + currentUser?: string; maintenance: Maintenance; updatePolicy: UpdatePolicy; library: LibraryStats; @@ -237,6 +238,53 @@ export interface AdminStatus { radarrReady: boolean; } +export interface CreditsSettings { + candidateLimit: number; + prefetchEpisodes: number; + maxPrefetch: number; + retryHours: number; +} + +export interface CreditsCandidate { + itemId: string; + seriesId: string; + season: number; + episode: number; + priority: number; + reason: string; + userCount: number; + lastViewed: string; +} + +export interface CreditsScanHistory { + id: number; + itemId: string; + itemName: string; + seriesName: string; + seriesId: string; + season: number; + episode: number; + reason: string; + priority: number; + outcome: 'detected' | 'unchanged' | 'no_match' | 'failed'; + markerMs: number; + confidence: number; + method: string; + frames: number; + error: string; + startedAt: string; + finishedAt: string; + durationMs: number; +} + +export interface CreditsResponse { + enabled: boolean; + settings: CreditsSettings; + queueDepth: number; + pending: CreditsCandidate[]; + history: CreditsScanHistory[]; +} + export interface ViewsReport { today: { visits: number; viewers: number }; lastWeek: { visits: number; viewers: number }; diff --git a/admin-ui/src/components/Layout.tsx b/admin-ui/src/components/Layout.tsx index e45b6a0..04eb1d1 100644 --- a/admin-ui/src/components/Layout.tsx +++ b/admin-ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom'; import { Icon } from './Icon'; import { OmniSearch } from './OmniSearch'; @@ -133,12 +133,17 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) { } export function Layout() { - const { version, online, checkedAt, status, setMaintenance } = useGateway(); + const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway(); const [railOpen, setRailOpen] = useState(false); + const [accountOpen, setAccountOpen] = useState(false); const [changingAvailability, setChangingAvailability] = useState(false); const [confirmingOffline, setConfirmingOffline] = useState(false); const location = useLocation(); const offline = Boolean(status?.maintenance?.enabled); + const account = useRef(null); + const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A'; + const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking'; + const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding'; const toggleAvailability = async () => { if (changingAvailability || !status) return; @@ -154,6 +159,22 @@ export function Layout() { // it sits over the page that was just opened. useEffect(() => setRailOpen(false), [location.pathname]); + useEffect(() => { + if (!accountOpen) return; + const close = (event: MouseEvent) => { + if (!account.current?.contains(event.target as Node)) setAccountOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setAccountOpen(false); + }; + document.addEventListener('mousedown', close); + window.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('mousedown', close); + window.removeEventListener('keydown', closeOnEscape); + }; + }, [accountOpen]); + // An open tablet drawer is modal navigation: keep the page underneath still and let // Escape close it. The media query decides whether the rail is overlaid; applying this // on desktop is harmless because the desktop rail cannot be opened by a hidden button. @@ -206,22 +227,46 @@ export function Layout() { -
- -
+ {accountOpen ? ( +
+
+ + Signed in as{currentUser} +
+
+ +
+
+ ) : null} + diff --git a/admin-ui/src/lib/gateway.tsx b/admin-ui/src/lib/gateway.tsx index 35ca220..db71847 100644 --- a/admin-ui/src/lib/gateway.tsx +++ b/admin-ui/src/lib/gateway.tsx @@ -27,6 +27,7 @@ const POLL_MS = 30_000; interface GatewayState { status: AdminStatus | undefined; version: string; + currentUser: string; online: boolean; /** checkedAt is the evidence behind the verdict: a page that looks alive while its * numbers are twenty minutes old is the failure the bar exists to prevent. */ @@ -41,7 +42,7 @@ const GatewayContext = createContext(null); export function GatewayProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState(); - const [online, setOnline] = useState(true); + const [online, setOnline] = useState(false); const [checkedAt, setCheckedAt] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); @@ -104,6 +105,7 @@ export function GatewayProvider({ children }: { children: ReactNode }) { () => ({ status, version: status?.serverVersion ?? '', + currentUser: status?.currentUser?.trim() || 'Administrator', online, checkedAt, error, diff --git a/admin-ui/src/nav.ts b/admin-ui/src/nav.ts index 1f6f211..ecd62b1 100644 --- a/admin-ui/src/nav.ts +++ b/admin-ui/src/nav.ts @@ -229,6 +229,14 @@ export const nav: NavGroup[] = [ intro: 'Which providers a viewer may fetch a missing subtitle from.', icon: 'captions', }, + { + id: 'credits', + path: '/admin/credits', + label: 'Credits detection', + title: 'Credits detection', + intro: 'Control predictive scanning and review every completed credits scan.', + icon: 'clock', + }, ], }, { diff --git a/admin-ui/src/pages/Credits.tsx b/admin-ui/src/pages/Credits.tsx new file mode 100644 index 0000000..a7e3dc2 --- /dev/null +++ b/admin-ui/src/pages/Credits.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import type { CreditsResponse, CreditsScanHistory, CreditsSettings } from '../api/types'; +import { Banner, Button, Card, EmptyRow, Field, Grid, Loading, Note, PageHead, TableWrap, Tag, Tiles } from '../components/ui'; +import { useAction, useQuery } from '../lib/hooks'; +import { useToast } from '../lib/toast'; +import { ago, duration, num, percent, when, type Tone } from '../lib/format'; + +const outcomeTone = (outcome: CreditsScanHistory['outcome']): Tone => { + if (outcome === 'detected') return 'ok'; + if (outcome === 'failed') return 'bad'; + if (outcome === 'no_match') return 'warn'; + return 'info'; +}; + +const outcomeLabel = (outcome: CreditsScanHistory['outcome']): string => + outcome === 'no_match' ? 'no match' : outcome; + +const reasonLabel = (reason: string): string => ({ + 'live-playback': 'Live playback', + 'tracearr-next': 'Next episode', + 'tracearr-binge-prefetch': 'Binge look-ahead', + 'multi-user-demand': 'Multiple viewers', +}[reason] ?? reason) || 'Unknown'; + +const episodeLabel = (season: number, episode: number): string => + season > 0 && episode > 0 ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : 'Episode'; + +export function CreditsPage() { + const query = useQuery('/admin/api/credits?limit=150', { pollMs: 15_000 }); + const { wrap } = useToast(); + const { busy, run } = useAction(); + const [draft, setDraft] = useState(); + const [touched, setTouched] = useState(false); + + useEffect(() => { + if (!touched && query.data) setDraft(query.data.settings); + }, [query.data, touched]); + + const patch = (next: Partial) => { + setDraft((current) => current ? { ...current, ...next } : current); + setTouched(true); + }; + + const save = () => { + if (!draft) return; + void run('save', async () => { + const next = await wrap( + () => api.put('/admin/api/credits', draft), + 'Credits scanning settings saved.', + ); + if (next) { + query.set(next); + setDraft(next.settings); + setTouched(false); + } + }); + }; + + const history = query.data?.history ?? []; + const pending = query.data?.pending ?? []; + const detected = history.filter((entry) => entry.outcome === 'detected').length; + const noMatch = history.filter((entry) => entry.outcome === 'no_match').length; + const failed = history.filter((entry) => entry.outcome === 'failed').length; + + return ( + <> + + + + {query.loading || !draft ? : ( + <> + {!query.data?.enabled ? ( + + Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled. + + ) : null} + + + + + Save settings} + > +
+ + patch({ candidateLimit: Number(event.target.value) })} /> + + + patch({ prefetchEpisodes: Number(event.target.value) })} /> + + + patch({ maxPrefetch: Number(event.target.value) })} /> + + + patch({ retryHours: Number(event.target.value) })} /> + +
+
+ + +

+ Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching. +

+

+ A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it. +

+
+
+ + + + + + + {pending.length === 0 ? No episodes are waiting to be scanned. : pending.map((candidate) => ( + + + + + + + + + ))} + +
EpisodeReasonPriorityViewersDemand seenItem ID
{episodeLabel(candidate.season, candidate.episode)}{reasonLabel(candidate.reason)}{num(candidate.priority)}{num(candidate.userCount)}{ago(candidate.lastViewed)}{candidate.itemId}
+
+
+ + + + + + + {history.length === 0 ? No credits scans have completed yet. : history.map((entry) => ( + + + + + + + + + + ))} + +
FinishedProgrammeSelected becauseResultMarkerEvidenceTook
{ago(entry.finishedAt)} + {entry.seriesName || entry.itemName || entry.itemId} + {episodeLabel(entry.season, entry.episode)}{entry.itemName && entry.seriesName ? ` · ${entry.itemName}` : ''} + {reasonLabel(entry.reason)}priority {entry.priority}{outcomeLabel(entry.outcome)}{entry.error ? {entry.error} : null}{entry.markerMs > 0 ? duration(entry.markerMs) : '—'}{entry.method || 'visual'}{entry.confidence > 0 ? ` · ${percent(entry.confidence)}` : ''}{entry.frames > 0 ? ` · ${entry.frames} frames` : ''}{duration(entry.durationMs)}
+
+
+ + )} + + ); +} diff --git a/admin-ui/src/styles.css b/admin-ui/src/styles.css index 7f07ae3..8598991 100644 --- a/admin-ui/src/styles.css +++ b/admin-ui/src/styles.css @@ -220,6 +220,7 @@ a { cursor: pointer; font: inherit; text-align: left; + transition: background .16s ease, border-color .16s ease, color .16s ease; } .topbar-status:hover:not(:disabled), .topbar-status:focus-visible { @@ -238,6 +239,7 @@ a { } .topbar-status[data-tone="ok"] .dot { background: var(--accent); + box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34); } .topbar-status[data-tone="bad"] .dot { background: var(--danger); @@ -252,6 +254,111 @@ a { white-space: nowrap; } +/* The account is an identity first and an exit second. Keeping sign-out in this menu + prevents an unexplained door icon from competing with the operational controls. */ +.account-menu { + position: relative; + flex: 0 0 auto; +} +.account-trigger { + max-width: 190px; + height: 38px; + padding: 0 9px 0 5px; + border-color: var(--line); + background: var(--surface); + color: var(--muted); +} +.account-trigger:hover:not(:disabled), +.account-menu[data-open="true"] .account-trigger { + border-color: rgba(86, 211, 100, .5); + background: var(--surface-hi); + color: var(--text); +} +.account-avatar { + display: grid; + place-items: center; + width: 27px; + height: 27px; + flex: 0 0 27px; + border: 1px solid rgba(86, 211, 100, .36); + border-radius: 50%; + background: var(--accent-wash); + color: var(--accent-ink); + font-size: 11px; + font-weight: 750; +} +.account-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12.5px; +} +.account-caret { + width: 12px; + height: 12px; + transition: transform .16s ease; +} +.account-menu[data-open="true"] .account-caret { + transform: rotate(180deg); +} +.account-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(280px, calc(100vw - 24px)); + padding: 7px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + box-shadow: 0 18px 44px rgba(0, 0, 0, .55); + z-index: 40; +} +.account-identity { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; + padding: 9px 10px 12px; + border-bottom: 1px solid var(--line-soft); +} +.account-avatar-large { + width: 34px; + height: 34px; + flex-basis: 34px; + font-size: 13px; +} +.account-identity span:last-child { + min-width: 0; +} +.account-identity small, +.account-identity b { + display: block; +} +.account-identity small { + color: var(--quiet); + font-size: 11px; +} +.account-identity b { + overflow: hidden; + text-overflow: ellipsis; + font-size: 13.5px; +} +.account-panel form { + margin-top: 6px; +} +.account-panel form button { + justify-content: flex-start; + width: 100%; + height: 38px; + border-color: transparent; + background: transparent; + color: var(--muted); +} +.account-panel form button:hover:not(:disabled) { + background: var(--surface-hi); + color: var(--text); +} + @media (max-width: 900px) { .topbar-version, .topbar-status span { @@ -575,6 +682,7 @@ a { .page { width: min(calc(100vw - var(--rail)), 1920px); + min-width: 0; margin: var(--top) 0 0 calc(var(--rail) + max(0px, (100vw - var(--rail) - 1920px) / 2)); padding: clamp(24px, 2.4vw, 44px) clamp(22px, 3vw, 56px) 80px; } @@ -1088,6 +1196,16 @@ table { border: 1px solid var(--line); background: var(--surface); } + .topbar-status[data-tone="ok"] { + border-color: rgba(86, 211, 100, .38); + background: var(--accent-wash); + color: var(--accent-ink); + } + .topbar-status[data-tone="bad"] { + border-color: rgba(229, 83, 75, .34); + background: var(--danger-wash); + color: var(--danger-ink); + } .page { width: 100%; padding: clamp(24px, 3vw, 34px) max(24px, calc(var(--safe-right) + 10px)) max(64px, calc(var(--safe-bottom) + 36px)) max(24px, calc(var(--safe-left) + 10px)); @@ -1106,6 +1224,17 @@ table { } } +@media (max-width: 1100px) { + .account-trigger { + width: 40px; + padding: 0; + } + .account-name, + .account-caret { + display: none; + } +} + @media (max-width: 820px) { .brand-word, .topbar-status b { @@ -1136,6 +1265,57 @@ table { padding-left: 16px; } } + +/* A phone gets two deliberate rows: navigation and live controls above, search below. + Compressing all six controls into one line made the search unusable at exactly the + widths where it is the quickest way around a long navigation drawer. */ +@media (max-width: 680px) { + :root { + --top: calc(108px + var(--safe-top)); + } + .topbar { + align-content: center; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left)); + } + .rail-toggle { order: 1; } + .topbar-brand { order: 2; } + .topbar-status { + order: 3; + margin-left: auto; + } + .bell { order: 4; } + .account-menu { order: 5; } + .topbar .omni { + order: 6; + flex: 1 0 100%; + max-width: none; + } + .brand-mark { + width: 32px; + height: 32px; + flex-basis: 32px; + } + .omni-input { + height: 38px; + } + .account-panel, + .bell-panel { + position: fixed; + top: calc(var(--top) + 8px); + } + .account-panel { + right: max(12px, var(--safe-right)); + } +} + +@media (max-width: 370px) { + .tiles { + grid-template-columns: minmax(0, 1fr); + } +} th { padding: 0 12px 8px 0; border-bottom: 1px solid var(--line); @@ -2610,7 +2790,7 @@ details summary { /* iPad is an operating surface, not a large phone. Cards gain enough separation to be parsed at arm's length, but data remains dense and two-column where portrait width can genuinely support it. */ -@media (min-width: 821px) and (max-width: 1400px) { +@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px) { .page-head { margin-bottom: 24px; } @@ -2718,7 +2898,7 @@ details summary { } .card-head-actions { width: 100%; - margin-left: 41px; + margin-left: 0; } .card-foot > button, .card-foot > .btn { @@ -2815,7 +2995,7 @@ details summary { .topbar .rail-toggle, .topbar .bell-button, .topbar .topbar-status, - .topbar form button { + .topbar .account-trigger { width: 44px; height: 44px; padding: 0; @@ -2885,6 +3065,25 @@ details summary { } } +/* Short laptop screens benefit from vertical density, while retaining the same readable + widths and full-size pointer targets. */ +@media (pointer: fine) and (min-width: 821px) and (max-height: 800px) { + .page { + padding-top: 20px; + padding-bottom: 52px; + } + .page-head { + margin-bottom: 16px; + } + .card { + padding-top: 14px; + padding-bottom: 14px; + } + .card-head { + margin-bottom: 11px; + } +} + @media (hover: none) { tbody tr:hover td, .list-row:hover, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a6ac3ac..0f08a82 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,7 +46,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.67" +val defaultVersionName = "0.2.68" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index f1a53af..10bc356 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -1,7 +1,9 @@ package com.ponzischeme89.memby.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -20,11 +22,13 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -64,6 +68,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -72,26 +77,37 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage +import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.MediaRating +import com.ponzischeme89.memby.data.model.displayable import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.CastGrid +import com.ponzischeme89.memby.ui.detail.DetailHeroMetrics import com.ponzischeme89.memby.ui.detail.DetailTab import com.ponzischeme89.memby.ui.detail.DetailZone import com.ponzischeme89.memby.ui.detail.TechnicalSpec import com.ponzischeme89.memby.ui.detail.castColumns +import com.ponzischeme89.memby.ui.detail.detailCardFocus import com.ponzischeme89.memby.ui.detail.extraKindLabel import com.ponzischeme89.memby.ui.detail.formatRuntime +import com.ponzischeme89.memby.ui.detail.heroArtworkAlpha +import com.ponzischeme89.memby.ui.detail.heroDimAlpha +import com.ponzischeme89.memby.ui.detail.heroPrimaryAlpha +import com.ponzischeme89.memby.ui.detail.heroSupportingAlpha +import com.ponzischeme89.memby.ui.detail.pinnedHeaderAlpha +import com.ponzischeme89.memby.ui.detail.pinnedScrimAlpha import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface @@ -215,21 +231,46 @@ internal data class DetailHeroAction( val onClick: () -> Unit, ) -/** Full-bleed artwork with a protected reading area on the left and at the fold. */ +/** + * Full-bleed artwork with a protected reading area on the left and at the fold. + * + * [artworkAlpha] is how much of the picture the hero is still showing — 1 at rest, lower as + * the page collapses. It is a lambda and is read only inside a `graphicsLayer`, so the + * backdrop recedes in the draw phase and the image request is never restarted: the artwork + * changing state must not cost a fetch, and a fetch in the middle of a D-pad press is the + * one thing that would make this page feel slow. + */ @Composable -internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) { +internal fun DetailBackdrop( + item: BaseItem, + modifier: Modifier = Modifier, + artworkAlpha: () -> Float = { 1f }, +) { + val context = LocalContext.current val repository = ServiceLocator.repository val artwork = remember(item.id, item.backdropImageTags, item.imageTags) { repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280) } + // The one place in the app where a crossfade earns its keep. Artwork is loaded without + // one everywhere else because a row of posters snapping in is faster and reads fine at + // card size; a full-screen backdrop appearing between two frames is a flash, and this + // one is behind a title the viewer is already reading. Remembered so that a redraw of + // the hero is not a new request object. + val request = remember(artwork) { + artwork?.let { + ImageRequest.Builder(context).data(it).crossfade(BackdropCrossfadeMs).build() + } + } Box(modifier.background(DetailBackground)) { - if (artwork != null) { + if (request != null) { AsyncImage( - model = artwork, + model = request, contentDescription = null, contentScale = ContentScale.Crop, alignment = Alignment.TopCenter, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = artworkAlpha().coerceIn(0f, 1f) }, ) } Box( @@ -283,6 +324,12 @@ internal fun DetailPageScaffold( onFocused: () -> Unit, ) -> Unit)? = null, eyebrow: String? = null, + /** + * The compact form of [eyebrow] for the pinned header — "S03E04" where the expanded + * hero has the room for "SEASON 3 · EPISODE 4". A pinned header is one line beside a + * logo and a title; the spelled-out form pushes the fact line off the end of it. + */ + pinnedEyebrow: String? = eyebrow, subtitle: String? = null, /** * The heading when the item has no logo to show. An episode's page is *about* the @@ -327,6 +374,16 @@ internal fun DetailPageScaffold( val actionRequesters = allActionRequesters.take(heroActions.size) var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) } var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) } + // Reported on the way *in* to a band, never on every focus move inside one. The pane's + // `hasFocus` fires for each card a viewer walks past, and each of those was a write into + // the position store and a callback into the page — work done once per D-pad press, in + // the one place where a press must feel free. + val enterZone: (DetailZone) -> Unit = { zone -> + if (focusedZone != zone) { + focusedZone = zone + onZoneFocused(zone) + } + } val heroReturn = if (lastHeroIndex in actionRequesters.indices) { actionRequesters[lastHeroIndex] } else { @@ -345,14 +402,27 @@ internal fun DetailPageScaffold( val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) } // The one number the whole layout is driven by: 0 is the complete opening frame, 1 is - // the hero out of the way with the strip at the top of the usable area. It is read - // *only* inside layout and draw lambdas below — never in a composable body — so the - // transition costs re-layout of three boxes rather than a recomposition of the page on - // every frame of it. 190ms because this is context getting out of the way, not an - // effect: fast enough not to be waited on, slow enough not to read as a jump. + // the hero out of the way with the strip at the top of the usable area. Height, the + // backdrop, the scrim, the identity block, the action row and the pinned header are all + // bands of *this* value (see `ui/detail/DetailHeroPhases.kt`) rather than animations of + // their own, which is what makes the transformation read as one movement and what stops + // them drifting apart when it is interrupted half way. + // + // It is read *only* inside layout and draw lambdas below — never in a composable body — + // so the transition costs re-layout of three boxes rather than a recomposition of the + // page on every frame of it. + // + // A spring, not a tween, and critically damped so it can never overshoot: this value + // drives opacities, and an overshoot past 1 would be a visible flicker. Focus changes + // faster than any animation on a TV — a held D-pad produces a press every few frames — + // and a spring retargets from wherever it currently is, so repeated presses continue one + // movement rather than restarting a duration each time. val collapse = animateFloatAsState( targetValue = if (detailHeroCollapsed(focusedZone)) 1f else 0f, - animationSpec = tween(190), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), label = "detail-hero-collapse", ) @@ -377,6 +447,7 @@ internal fun DetailPageScaffold( facts = facts, badges = badges, eyebrow = eyebrow, + pinnedEyebrow = pinnedEyebrow, subtitle = subtitle, title = title ?: item.name, playLabel = playLabel, @@ -397,20 +468,15 @@ internal fun DetailPageScaffold( collapse = { collapse.value }, onActionFocused = { index -> lastHeroIndex = index - focusedZone = DetailZone.PLAY - onZoneFocused(DetailZone.PLAY) + enterZone(DetailZone.PLAY) }, onPlayFocused = { lastHeroIndex = -1 - focusedZone = DetailZone.PLAY - onZoneFocused(DetailZone.PLAY) + enterZone(DetailZone.PLAY) }, ) } - val onStripFocused = { - focusedZone = DetailZone.TABS - onZoneFocused(DetailZone.TABS) - } + val onStripFocused = { enterZone(DetailZone.TABS) } // The band as one focus group, so a press that cannot reach the exact stop it // wanted still arrives somewhere in the strip. Box( @@ -447,12 +513,7 @@ internal fun DetailPageScaffold( // is the point — the content takes over rather than living in a slot. .weight(1f) .padding(start = DetailSideGutter, end = DetailSideGutter, top = 14.dp) - .onFocusChanged { - if (it.hasFocus) { - focusedZone = DetailZone.CONTENT - onZoneFocused(DetailZone.CONTENT) - } - } + .onFocusChanged { if (it.hasFocus) enterZone(DetailZone.CONTENT) } .focusRequester(contentEntryRequester) .focusGroup() // Deliberately a focus property and not a key handler: panes navigate @@ -525,6 +586,7 @@ private fun DetailHero( facts: List, badges: List, eyebrow: String?, + pinnedEyebrow: String?, subtitle: String?, title: String, playLabel: String, @@ -559,6 +621,15 @@ private fun DetailHero( if (repository.showTitleLogo) repository.logoUrl(item, 720) else null } val logo = logoUrl.takeIf { !useTextTitleForLogo(it) } + val hasRatings = remember(ratings, showRatingsStrip) { + showRatingsStrip && ratings.displayable().isNotEmpty() + } + // The description of whichever circular action holds focus, shown on a reserved line + // under the row. The icons are a heart, a tick, a bookmark and a film reel, and at three + // metres they are guesses; naming the focused one is the cheapest way to stop that. It + // is reserved rather than conditional, so nothing moves as focus travels the row. + var actionCaption by remember(item.id) { mutableStateOf(null) } + // Down belongs to the hero as a whole, not to the row of buttons inside it: whatever // in here holds focus, the press means "take me to the band under this". // @@ -576,154 +647,372 @@ private fun DetailHero( } .onVerticalNavigation(down = onNavigateDown), ) { - DetailBackdrop(item, Modifier.fillMaxSize()) + DetailBackdrop( + item = item, + modifier = Modifier.fillMaxSize(), + artworkAlpha = { heroArtworkAlpha(collapse()) }, + ) // Deepened as the hero gives way, so the strip and the top of the content pane are - // read against something closer to the launcher's near-black than to artwork. + // read against something closer to the launcher's near-black than to artwork. A + // gradient rather than a flat wash: the picture recedes furthest where the content + // is about to be, and least at the top, which is what keeps it looking like one + // photograph settling back rather than a sheet being drawn over it. Box( Modifier .fillMaxSize() - .graphicsLayer { alpha = collapse() } - .background(DetailBackground.copy(alpha = 0.72f)), + .graphicsLayer { alpha = heroDimAlpha(collapse()) } + .background( + Brush.verticalGradient( + 0f to DetailBackground.copy(alpha = 0.46f), + 0.55f to DetailBackground.copy(alpha = 0.78f), + 1f to DetailBackground.copy(alpha = 0.90f), + ), + ), + ) + // The separation behind the pinned header. It occupies exactly the band the pinned + // header will end up in and fades from nothing at its top edge, so what the viewer + // sees is content passing under a softening rather than a bar appearing. + Box( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(collapsedHeight) + .graphicsLayer { alpha = pinnedScrimAlpha(collapse()) } + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.45f to DetailBackground.copy(alpha = 0.55f), + 1f to DetailBackground.copy(alpha = 0.88f), + ), + ), ) DetailCollapsedHeader( title = title, facts = facts, logo = logo, + eyebrow = pinnedEyebrow, + subtitle = subtitle, modifier = Modifier .align(Alignment.BottomStart) - // Comes in over the second half of the transition, after the full hero has - // finished going. Two headings crossing through each other reads as a fault. - .graphicsLayer { alpha = ((collapse() - 0.5f) * 2f).coerceIn(0f, 1f) }, + // Arrives only once the expanded hero has finished leaving. Two headings + // crossing through each other reads as a fault, not as a transition. + .graphicsLayer { alpha = pinnedHeaderAlpha(collapse()) }, ) + // The expanded hero. Bounded at the top by the safe inset, which is the whole of + // the fix for a hero that used to grow until it hit the edge of the screen: with a + // ceiling, something inside has to give, and the column below decides what. Column( modifier = Modifier .align(Alignment.BottomStart) - // Out over the first half, and comfortably before the action row would be - // clipped by the shrinking band. - .graphicsLayer { alpha = (1f - collapse() * 2f).coerceIn(0f, 1f) } - .padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp) - .fillMaxWidth(0.58f), + .fillMaxWidth(DetailHeroMetrics.TextWidthFraction) + .heightIn(max = (expandedHeight - DetailHeroMetrics.TopInset).coerceAtLeast(180.dp)) + .graphicsLayer { alpha = heroPrimaryAlpha(collapse()) } + .padding( + start = DetailSideGutter, + end = DetailSideGutter, + bottom = DetailHeroMetrics.BottomInset, + ), ) { - if (logo != null) { - AsyncImage( - model = logo, - contentDescription = item.name, - contentScale = ContentScale.Fit, - alignment = Alignment.CenterStart, - modifier = Modifier.width(330.dp).height(92.dp), - ) - } else { - Text( - text = title, - color = Color.White, - fontSize = 38.sp, - lineHeight = 42.sp, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - // An episode's logo belongs to its *series*, so without this the page would - // name the show and never say which episode it is about. The number goes - // first and the episode's own title second: the viewer already knows the - // show they picked, and "Season 3 · Episode 4" is what they came to confirm. - if (eyebrow != null) { - Spacer(Modifier.height(10.dp)) - Text( - text = eyebrow, - color = DetailAccent, - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 1.6.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (subtitle != null) { - Spacer(Modifier.height(6.dp)) - Text( - text = subtitle, - color = Color.White, - fontSize = 26.sp, - lineHeight = 30.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + DetailIdentity( + logo = logo, + title = title, + eyebrow = eyebrow, + subtitle = subtitle, + contentDescription = item.name, + // Whether this title could *ever* show a logo, which is known synchronously + // from the image tags — as against whether it will, which needs the artwork + // fetched and its darkness judged. The reservation is only worth paying for + // where that swap can actually happen; a title Emby holds no logo for gets + // its heading at its natural height and gives the space to the synopsis. + reserveForLogo = logoUrl != null, + ) Spacer(Modifier.height(10.dp)) DetailFactRow(facts = facts, badges = badges) - if (showRatingsStrip) Spacer(Modifier.height(8.dp)) - RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true) - if (item.genres.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) + // Everything that answers "is this worth watching" — and the only part of the + // hero allowed to give way. A weighted child is measured from what the + // unweighted ones left over, so a two-line title, a full ratings strip and a + // synopsis now cost prose rather than costing the Play button its shape. + // `fill = false` keeps it at its natural height whenever there is room, which on + // an ordinary title is always. This inversion is the fix for the malformed + // primary action, and it is the same one the home hero already makes. + Column( + modifier = Modifier + .weight(1f, fill = false) + .clipToBounds() + .graphicsLayer { alpha = heroSupportingAlpha(collapse()) }, + ) { + // No reserved height here, unlike everywhere else the strip appears. Most + // of a library has no scores — an episode is rated as its series, and a + // household with no MDBList key has none at all — and 42dp held open for + // them was 42dp taken off the synopsis on every one of those pages. The + // block this sits in is the flexible one, so a strip arriving late costs a + // line of prose rather than moving anything the viewer is aiming at. + if (hasRatings) { + Spacer(Modifier.height(8.dp)) + RatingsStrip(ratings, visible = true) + } + // Not on an episode page: an episode inherits its series' genres, so the + // line would repeat what the show above it already said, on the one variant + // whose identity block is three lines tall to begin with. + if (item.genres.isNotEmpty() && eyebrow == null) { + Spacer(Modifier.height(8.dp)) + Text( + text = item.genres.take(4).joinToString(ValueSeparator), + color = DetailMutedText, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(10.dp)) + // The one thing in the hero that shrinks by degrees. Everything around it + // is a single line that is either there or not; the synopsis can honestly + // be three lines, or two, or none, so it is where the give is taken from — + // and [wholeLines] takes it a whole line at a time, because prose sliced + // through the middle of its letters reads as a rendering fault where two + // lines instead of three reads as nothing at all. Text( - text = item.genres.take(4).joinToString(ValueSeparator), - color = DetailMutedText, - fontSize = 14.sp, - maxLines = 1, + text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.", + color = DetailText, + fontSize = 15.sp, + lineHeight = SynopsisLineHeight, + maxLines = DetailHeroMetrics.SynopsisLines, overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f, fill = false) + // Outside [wholeLines], so it clips to the *snapped* height rather + // than to the text's own. A Text handed less room still draws every + // line it was asked for; without this the dropped lines simply + // painted over whatever the column placed underneath. + .clipToBounds() + .wholeLines(SynopsisLineHeight), ) + paceLabel?.let { + Spacer(Modifier.height(10.dp)) + Text( + text = it, + color = DetailQuietText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (airingNotice != null) { + Spacer(Modifier.height(10.dp)) + AiringNoticeBand(airingNotice) + } else if (reasons.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + Text( + text = reasons.first(), + color = DetailAccent, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - Spacer(Modifier.height(10.dp)) - Text( - text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.", - color = DetailText, - fontSize = 15.sp, - lineHeight = 20.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + // Directly above the actions, because it is what the primary button *means*: + // "Resume" and a bar showing 40 minutes gone are one statement. Never inside + // the flexible block — how far in somebody is must not be what gets cut. if (progress > 0f) { - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(14.dp)) DetailProgress(progress, progressLabel) } - paceLabel?.let { - // Tighter under the bar than under the synopsis: with a bar above it this - // is the second half of one thought, without one it is a line of its own. - Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp)) + Spacer(Modifier.height(16.dp)) + DetailHeroActions( + playLabel = playLabel, + onPlay = onPlay, + playFocusRequester = playFocusRequester, + actions = actions, + actionRequesters = actionRequesters, + caption = actionCaption, + onPlayFocused = { + actionCaption = null + onPlayFocused() + }, + onActionFocused = { index -> + actionCaption = actions.getOrNull(index)?.description + onActionFocused(index) + }, + ) + } + } +} + +/** + * The primary action and the circular secondary ones, plus the line that names them. + * + * One component for all three page variants, so a movie, a series and an episode cannot + * grow different button metrics — which is exactly how the Continue Watching page came to + * have a Play button that did not match the one on a film. + */ +@Composable +private fun DetailHeroActions( + playLabel: String, + onPlay: () -> Unit, + playFocusRequester: FocusRequester, + actions: List, + actionRequesters: List, + caption: String?, + onPlayFocused: () -> Unit, + onActionFocused: (Int) -> Unit, +) { + Column { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.focusGroup(), + ) { + MembyPlayButton( + label = playLabel, + onClick = onPlay, + onFocused = onPlayFocused, + modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester), + ) + actions.forEachIndexed { index, action -> + DetailCircularAction( + action = action, + onFocused = { onActionFocused(index) }, + modifier = Modifier.focusRequester(actionRequesters[index]), + ) + } + } + // Reserved, not conditional: a line that appears when focus reaches the second + // button would move the whole hero every time somebody pressed Right. + Box(Modifier.height(DetailActionCaptionHeight), contentAlignment = Alignment.CenterStart) { + caption?.let { Text( text = it, - color = DetailQuietText, + color = DetailMutedText, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - if (airingNotice != null) { - Spacer(Modifier.height(10.dp)) - AiringNoticeBand(airingNotice) - } else if (reasons.isNotEmpty()) { - Spacer(Modifier.height(10.dp)) + } + } +} + +/** Reserved for the focused action's description; see [DetailHeroActions]. */ +private val DetailActionCaptionHeight = 22.dp + +/** + * The hero synopsis' line box, shared by the text style and by [wholeLines]. + * + * In `sp` rather than `dp` so the two agree under a font scale: a snap computed from a + * fixed density would land part way through a line on a set with large text. + */ +private val SynopsisLineHeight = 20.sp + +/** + * Reports a height that is always a whole number of [lineHeight] boxes. + * + * A `Text` handed less room than it wants draws as much as it can and cuts the last line + * through the middle of the letters, which looks like a fault rather than like a decision. + * This measures the text at its natural height and then, only when there is not room for + * all of it, rounds the reported height *down* to the last complete line — so the block + * either shows three lines, or two, or one, or nothing. + * + * A single measure pass, in the layout phase. No subcomposition, no measuring twice, and + * nothing read in composition: the same rule the collapsing hero band follows. + */ +private fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints -> + val line = lineHeight.roundToPx().coerceAtLeast(1) + val placeable = measurable.measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity), + ) + val available = if (constraints.hasBoundedHeight) constraints.maxHeight else placeable.height + val height = if (placeable.height <= available) placeable.height else (available / line) * line + layout(placeable.width, height.coerceAtLeast(0)) { placeable.place(0, 0) } +} + +/** How long the hero backdrop takes to fade in. See [DetailBackdrop]. */ +private const val BackdropCrossfadeMs = 220 + +/** + * What the page is about: the logo when there is a legible one, the title when there is not, + * and — on an episode — the season/episode line and the episode's own name under it. + * + * The logo occupies a **reserved** height whether or not it has arrived. Deciding whether a + * logo is legible means fetching and decoding it, so every one of these pages begins on the + * text fallback and changes its mind a moment later; without the reservation that swap moved + * everything under it, on the opening frame, which is the frame the viewer is watching. + * + * Very wide and very tall logos are both a letterboxed picture inside that box, because the + * box is fixed and the scale is [ContentScale.Fit] — a logo cannot change the page's shape. + */ +@Composable +private fun DetailIdentity( + logo: String?, + title: String, + eyebrow: String?, + subtitle: String?, + contentDescription: String, + reserveForLogo: Boolean, +) { + Column { + Box( + modifier = Modifier + .then( + if (reserveForLogo) { + Modifier.height(DetailHeroMetrics.IdentityHeight) + } else { + Modifier.heightIn(max = DetailHeroMetrics.IdentityHeight) + }, + ) + .fillMaxWidth(), + contentAlignment = Alignment.BottomStart, + ) { + if (logo != null) { + AsyncImage( + model = logo, + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + modifier = Modifier + .width(DetailHeroMetrics.LogoMaxWidth) + .height(DetailHeroMetrics.LogoMaxHeight), + ) + } else { Text( - text = reasons.first(), - color = DetailAccent, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + text = title, + color = Color.White, + fontSize = 36.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } - Spacer(Modifier.height(16.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.focusGroup(), - ) { - MembyPlayButton( - label = playLabel, - onClick = onPlay, - onFocused = onPlayFocused, - modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester), - ) - actions.forEachIndexed { index, action -> - DetailCircularAction( - action = action, - onFocused = { onActionFocused(index) }, - modifier = Modifier.focusRequester(actionRequesters[index]), - ) - } - } + } + // An episode's logo belongs to its *series*, so without this the page would name + // the show and never say which episode it is about. The number goes first and the + // episode's own title second: the viewer already knows the show they picked, and + // "Season 3 · Episode 4" is what they came to confirm. + if (eyebrow != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = eyebrow, + color = DetailAccent, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.6.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (subtitle != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = subtitle, + color = Color.White, + fontSize = 25.sp, + lineHeight = 29.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } } @@ -742,11 +1031,13 @@ private fun DetailCollapsedHeader( facts: List, logo: String?, modifier: Modifier = Modifier, + eyebrow: String? = null, + subtitle: String? = null, ) { Row( modifier = modifier .fillMaxWidth() - .padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 14.dp), + .padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 15.dp), verticalAlignment = Alignment.CenterVertically, ) { if (logo != null) { @@ -755,20 +1046,51 @@ private fun DetailCollapsedHeader( contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.CenterStart, - modifier = Modifier.height(38.dp).width(160.dp), + modifier = Modifier + .height(DetailHeroMetrics.PinnedLogoMaxHeight) + .width(DetailHeroMetrics.PinnedLogoMaxWidth), ) } else { Text( text = title, color = Color.White, - fontSize = 24.sp, - lineHeight = 27.sp, + fontSize = 23.sp, + lineHeight = 26.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) } + // An episode's pinned header is headed by the show, so without this it would say + // nothing about which episode the tabs underneath belong to — which is the one + // thing the pinned state exists to keep saying. + subtitle?.let { + Spacer(Modifier.width(14.dp)) + Text( + text = it, + color = Color.White.copy(alpha = 0.88f), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + // The number, not the whole eyebrow: "S3 · E4" beside the show is what identifies + // the page, where the expanded hero has the room to spell it out. + eyebrow?.let { + Spacer(Modifier.width(12.dp)) + Text( + text = it, + color = DetailAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } if (facts.isNotEmpty()) { Spacer(Modifier.width(16.dp)) Text( @@ -894,6 +1216,53 @@ internal fun DetailFactRow( } } +/** + * The frame every band under the hero wears: the near-black plate, the side gutters, the + * hairline that separates it from the pane, and the chevron saying Down reveals something. + * + * Shared by the tab strip and by the episode page's season scroller, because the fold they + * define is the same fold — and two hand-written copies of it is exactly how the two pages + * came to sit their content a couple of pixels apart. [trailing] is whatever the band wants + * before the chevron: the series-progress line on an episode page, nothing on a movie's. + */ +@Composable +internal fun DetailStripFrame( + modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, + content: @Composable RowScope.() -> Unit, +) { + Box( + modifier + .fillMaxWidth() + .height(DetailStripHeight) + .background(DetailBackground) + .padding(horizontal = DetailSideGutter), + ) { + Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline)) + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.Bottom, + ) { + Row( + modifier = Modifier.weight(1f).fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(34.dp), + verticalAlignment = Alignment.Bottom, + content = content, + ) + trailing?.invoke() + // The tab's content begins below the fold by design. Nothing said so; the peek + // under this strip and this chevron are what say it. Never focusable — it is a + // caption on the Down key, not another thing to land on. + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = DetailQuietText, + modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp), + ) + } + } +} + @Composable private fun DetailTabStrip( tabs: List, @@ -904,14 +1273,7 @@ private fun DetailTabStrip( onExitDown: () -> Boolean, onFocused: () -> Unit, ) { - Box( - Modifier - .fillMaxWidth() - .height(DetailStripHeight) - .background(DetailBackground) - .padding(horizontal = DetailSideGutter), - ) { - Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline)) + DetailStripFrame { Row( modifier = Modifier .fillMaxSize() @@ -942,31 +1304,35 @@ private fun DetailTabStrip( .clickable { onSelect(tab); onFocused() }, horizontalAlignment = Alignment.CenterHorizontally, ) { + // Focus and selection say two different things with two different + // marks, even though on this page one causes the other. The accent + // underline is *selection* — which pane is open. The plate behind the + // label is *focus* — where the remote is. They coincide while the + // viewer is in the strip and come apart the moment they press Down, + // which is the case the distinction exists for: the open tab must go + // on saying it is open once nothing up here is focused. Text( tab.label, color = if (selected == tab || focused) Color.White else DetailQuietText, fontSize = 15.sp, fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium, maxLines = 1, - modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp), + modifier = Modifier + .padding(bottom = 9.dp) + .clip(RoundedCornerShape(MembyChipCorner)) + .background( + if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent, + ) + .padding(horizontal = 10.dp, vertical = 4.dp), ) Box( Modifier.fillMaxWidth().height(3.dp).background( - if (selected == tab || focused) DetailAccent else Color.Transparent, + if (selected == tab) DetailAccent else Color.Transparent, ), ) } } Spacer(Modifier.weight(1f)) - // The tab's content begins below the fold by design. Nothing said so; the - // peek under this strip and this chevron are what say it. Never focusable — - // it is a caption on the Down key, not another thing to land on. - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = DetailQuietText, - modifier = Modifier.size(18.dp).padding(bottom = 2.dp), - ) } } } @@ -1227,12 +1593,10 @@ private fun DetailPosterCard( ) { val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) } var focused by remember { mutableStateOf(false) } - val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus") Column( modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } - .zIndex(if (focused) 1f else 0f) + .detailCardFocus(focused) .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() } .clickable(onClick = onClick), ) { @@ -1305,13 +1669,11 @@ private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modif } val kind = remember(item.type) { extraKindLabel(item) } var focused by remember { mutableStateOf(false) } - val scale by animateFloatAsState(if (focused) 1.05f else 1f, tween(100), label = "extra-card-focus") val shape = RoundedCornerShape(MembyCardCorner) Column( modifier .fillMaxWidth() - .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } - .zIndex(if (focused) 1f else 0f) + .detailCardFocus(focused) .onFocusChanged { focused = it.isFocused } .clickable(onClick = onClick), ) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index 5a6317f..44a48e8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -37,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -61,6 +61,7 @@ import com.ponzischeme89.memby.ui.detail.episodeHeadline import com.ponzischeme89.memby.ui.detail.episodesForSeason import com.ponzischeme89.memby.ui.detail.detailPositions import com.ponzischeme89.memby.ui.detail.heroFacts +import com.ponzischeme89.memby.ui.detail.paddedEpisodeCode import com.ponzischeme89.memby.ui.detail.playbackProgress import com.ponzischeme89.memby.ui.detail.primaryActionLabel import com.ponzischeme89.memby.ui.detail.remainingLabel @@ -68,6 +69,7 @@ import com.ponzischeme89.memby.ui.detail.seasonMarkers import com.ponzischeme89.memby.ui.detail.seasonProgressLabel import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator import com.ponzischeme89.memby.ui.detail.seriesProgressLabel +import com.ponzischeme89.memby.ui.theme.MembyChipCorner import kotlinx.coroutines.delay /** @@ -232,11 +234,17 @@ internal fun EpisodeDetailContent( } } + // Held rather than rebuilt per composition, as on the other two pages. + val heroFacts = remember(item.id, item.runTimeTicks, item.premiereDate) { heroFacts(item) } + val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) } + val eyebrow = remember(item.id) { episodeEyebrow(item) } + DetailPageScaffold( item = item, - facts = heroFacts(item), - badges = mediaBadges(item), - eyebrow = episodeEyebrow(item), + facts = heroFacts, + badges = heroBadges, + eyebrow = eyebrow, + pinnedEyebrow = remember(item.id) { paddedEpisodeCode(item) }, subtitle = item.name.takeIf(String::isNotBlank), title = item.seriesName?.takeIf(String::isNotBlank) ?: item.name, playLabel = primaryActionLabel(item), @@ -347,54 +355,11 @@ private fun SeasonScroller( onSelect: (Int) -> Unit, onFocused: () -> Unit, ) { - Box( - Modifier - .fillMaxWidth() - .height(DetailStripHeight) - .background(DetailBackground) - .padding(horizontal = DetailSideGutter), - ) { - Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline)) - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.Bottom, - ) { - if (markers.isEmpty()) { - Text( - text = "Loading seasons…", - color = DetailQuietText, - fontSize = 13.sp, - modifier = Modifier.weight(1f).padding(bottom = 16.dp), - ) - } else { - LazyRow( - state = listState, - // Up and Down out of the band belong to the scaffold, which knows - // where to fall back to when the exact stop it wants is not composed - // — a season chip scrolled out of this row is an ordinary state. - modifier = Modifier - .weight(1f) - .fillMaxSize() - .focusGroup(), - horizontalArrangement = Arrangement.spacedBy(28.dp), - verticalAlignment = Alignment.Bottom, - ) { - items(markers, key = { it.season }) { marker -> - SeasonStop( - marker = marker, - selected = marker.season == selectedSeason, - watching = marker.season == currentSeason, - focusRequester = if (marker.season == selectedSeason) { - selectedFocusRequester - } else { - seasonFocusRequesters[marker.season] - }, - onSelect = { onSelect(marker.season) }, - onFocused = onFocused, - ) - } - } - } + // The same frame the tab strip wears, from the same component: the plate, the gutters, + // the hairline and the chevron are the fold, and the fold must be in the same place on + // an episode page as on a film's. + DetailStripFrame( + trailing = { seriesProgress?.let { Text( text = it, @@ -405,13 +370,43 @@ private fun SeasonScroller( modifier = Modifier.padding(start = 18.dp, bottom = 15.dp), ) } - // Same caption on the Down key the tab strip carries; never focusable. - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = DetailQuietText, - modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp), + }, + ) { + if (markers.isEmpty()) { + Text( + text = "Loading seasons…", + color = DetailQuietText, + fontSize = 13.sp, + modifier = Modifier.weight(1f).padding(bottom = 16.dp), ) + } else { + LazyRow( + state = listState, + // Up and Down out of the band belong to the scaffold, which knows + // where to fall back to when the exact stop it wants is not composed + // — a season chip scrolled out of this row is an ordinary state. + modifier = Modifier + .weight(1f) + .fillMaxSize() + .focusGroup(), + horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.Bottom, + ) { + items(markers, key = { it.season }) { marker -> + SeasonStop( + marker = marker, + selected = marker.season == selectedSeason, + watching = marker.season == currentSeason, + focusRequester = if (marker.season == selectedSeason) { + selectedFocusRequester + } else { + seasonFocusRequesters[marker.season] + }, + onSelect = { onSelect(marker.season) }, + onFocused = onFocused, + ) + } + } } } } @@ -446,9 +441,15 @@ private fun SeasonStop( .clickable(onClick = onSelect), horizontalAlignment = Alignment.Start, ) { + // Focus and selection are marked separately, as they are in the tab strip: the + // accent underline says which season the list below is showing, the plate says + // where the remote is. They part company the moment the viewer presses Down. Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 4.dp, end = 4.dp), + modifier = Modifier + .clip(androidx.compose.foundation.shape.RoundedCornerShape(MembyChipCorner)) + .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent) + .padding(horizontal = 8.dp, vertical = 3.dp), ) { if (done) { Icon( @@ -487,12 +488,12 @@ private fun SeasonStop( color = if (done) DetailQuietText.copy(alpha = 0.5f) else DetailQuietText, fontSize = 10.sp, maxLines = 1, - modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 11.dp), + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 2.dp, bottom = 8.dp), ) } Box( Modifier.fillMaxWidth().height(3.dp).background( - if (selected || focused) DetailAccent else Color.Transparent, + if (selected) DetailAccent else Color.Transparent, ), ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 85d9647..6accefe 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -107,6 +107,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -1891,6 +1892,18 @@ private fun HomeScreen( // home one page at a time; without this it would drop three levels to the launcher. var detailsTrail by remember { mutableStateOf>(emptyList()) } var restoreDetailPosition by remember { mutableStateOf(false) } + // The page playback was started from, so returning from the player comes back to it + // rather than to the launcher. + // + // Deliberately a *reopen* rather than leaving the page composed under the player. The + // page is what tells the viewer where they were, and it has to tell them the truth: the + // episode they just finished is now watched, the film is now part-way through, and the + // next episode is a different one. Reopening runs the same restore path a Back press + // through the trail already uses (`DetailPosition` plus `RestoreDetailFocus`), so the + // tab, the season, the grid offset and the band that held focus all come back with it — + // and focus is re-requested by that path rather than depending on Compose having held + // it across another activity's window. + var detailsResumeTarget by remember { mutableStateOf(null) } // Belongs to the *route*, not to the show: set only when a page is opened from the // "Shows airing" row, and dropped the moment the viewer moves anywhere else, so the // same series reached from Favourites or a search never claims a schedule. @@ -3237,6 +3250,23 @@ private fun HomeScreen( onSignedIn = { addingProfile = false }, ) } + // Coming back from the player. The page is reopened rather than restored from + // under the player — see [detailsResumeTarget] — and the item is re-requested on + // the way in, so what appears has this playback's progress on it rather than the + // record the row handed over before the film started. + LifecycleResumeEffect(Unit) { + detailsResumeTarget?.let { target -> + detailsResumeTarget = null + if (detailsItem == null) { + homeViewModel.focusItem(target.item) + detailsTrail = target.trail + detailsAiringNotice = target.airingNotice + restoreDetailPosition = true + detailsItem = target.item + } + } + onPauseOrDispose { } + } detailsItem?.let { selected -> BackHandler { val previous = detailsTrail.lastOrNull() @@ -3281,6 +3311,9 @@ private fun HomeScreen( detailsAiringNotice = null }, onPlay = { + // Kept, not discarded: this is what the viewer comes back to when the + // film ends or they press Back out of the player. + detailsResumeTarget = DetailsReturn(selected, detailsTrail, detailsAiringNotice) detailsItem = null detailsTrail = emptyList() restoreDetailPosition = false @@ -4070,6 +4103,20 @@ private fun FocusedHomeMetadata( ) } +/** + * The detail page a playback was launched from, held for the length of that playback. + * + * The trail comes with it, so pressing Back after the film still walks the "More like this" + * chain the viewer arrived by rather than dropping straight to the launcher; the airing + * notice comes with it because it belonged to the route into the page and that route has + * not changed. + */ +private data class DetailsReturn( + val item: BaseItem, + val trail: List, + val airingNotice: AiringNotice?, +) + @Composable private fun FocusedDetailsOverlay( homeViewModel: HomeViewModel, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index f6cd03f..8768c23 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier @@ -153,7 +154,11 @@ internal fun MediaDetailContent( ) // Every newly opened title starts as a complete hero frame. Remembering a content // tab also restored its focus and caused the page to reopen below the artwork. - var tabKey by remember(item.id, restorePosition) { + // + // `rememberSaveable`, as on the series page: a movie's tab is the same kind of state a + // show's is, and the two pages having different answers to "does this survive the + // activity being recreated" is precisely the drift this pass exists to remove. + var tabKey by rememberSaveable(item.id, restorePosition) { mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key) } val selectedTab = detailTab(tabKey, tabs) @@ -213,10 +218,20 @@ internal fun MediaDetailContent( } } + // Held rather than rebuilt: the scaffold takes these as lists, so allocating a new one + // per composition would make every focus move on the page a fresh identity for the + // hero's fact row and its badges, and recompose both. + val heroFacts = remember(item.id, item.productionYear, item.runTimeTicks, item.officialRating) { + heroFacts(item) + } + val heroBadges = remember(badges, item.id, item.membyAirDayLabel) { + badges + listOfNotNull(airingBadgeLabel(item)) + } + DetailPageScaffold( item = item, - facts = heroFacts(item), - badges = badges + listOfNotNull(airingBadgeLabel(item)), + facts = heroFacts, + badges = heroBadges, tabs = tabs, selectedTab = selectedTab, onSelectTab = { tabKey = it.key }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index 3403222..59f2a2a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -33,7 +33,6 @@ import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -335,13 +334,20 @@ internal fun SeriesDetailContent( } } + // Held rather than rebuilt per composition, as on the movie page: the scaffold takes + // lists, and a new identity for them on every focus move recomposes the fact row. + val heroFacts = remember(item.id, seasons.size, item.productionYear, item.officialRating) { + heroFacts(item, seasons.size) + } + val heroBadges = remember(item.id, item.mediaStreams) { mediaBadges(item) } + DetailPageScaffold( item = item, - facts = heroFacts(item, seasons.size), + facts = heroFacts, // A series' streams describe its episodes; when Emby gives them, they are as true // of the show as they are of a film, and the row this page opened from already // badges them. - badges = mediaBadges(item), + badges = heroBadges, tabs = tabs, selectedTab = selectedTab, onSelectTab = { tabKey = it.key }, @@ -667,22 +673,11 @@ internal fun EpisodeCard( shape = RoundedCornerShape(6.dp), ), ) + // The same cue a focused poster on the launcher wears. It was a hand-rolled + // black disc with its own diameter and its own icon size, which meant Play + // looked like one thing on a home row and another on an episode. if (focused) { - Box( - modifier = Modifier - .align(Alignment.Center) - .size(40.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black.copy(alpha = 0.76f)), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.PlayArrow, - contentDescription = "Play selected episode", - tint = Color.White, - modifier = Modifier.size(25.dp), - ) - } + MembyArtworkPlayCue(Modifier.align(Alignment.Center)) } if (episode.isPlayed) { Icon( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/CastGrid.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/CastGrid.kt index ed3ce71..6db1127 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/CastGrid.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/CastGrid.kt @@ -1,7 +1,5 @@ package com.ponzischeme89.memby.ui.detail -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -35,7 +33,6 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight @@ -43,7 +40,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import androidx.tv.material3.Text import coil.compose.AsyncImage import com.ponzischeme89.memby.data.model.EmbyPerson @@ -172,21 +168,11 @@ private fun CastGridCard( modifier: Modifier = Modifier, ) { var focused by remember { mutableStateOf(false) } - val scale by animateFloatAsState( - targetValue = if (focused) 1.06f else 1f, - animationSpec = tween(110), - label = "cast-grid-focus", - ) val shape = RoundedCornerShape(MembyCardCorner) Column( modifier = modifier .fillMaxWidth() - .graphicsLayer { - scaleX = scale - scaleY = scale - translationY = if (focused) -3f else 0f - } - .zIndex(if (focused) 1f else 0f) + .detailCardFocus(focused) .testTag("cast-card-${person.name}") .onFocusChanged { focused = it.isFocused diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt new file mode 100644 index 0000000..7318082 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailCardFocus.kt @@ -0,0 +1,44 @@ +package com.ponzischeme89.memby.ui.detail + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex + +/** + * What a focused card on a detail page does: lifts a little, grows a little, and comes to + * the front so its ring is not clipped by the card beside it. + * + * There were three copies of this — the cast grid's, the "More like this" poster's and the + * Extras thumbnail's — at 1.06, 1.06 and 1.05, over 110ms, 100ms and 100ms. Nobody chose + * three different figures; they were written on three different days. One card language + * means a viewer learns what focus looks like once, and it means the *cost* of focus is one + * decision rather than three. + * + * The animated value is read only inside the `graphicsLayer` block, which is the draw + * phase, so travelling a grid costs a redraw of the two cards involved rather than a + * recomposition of every card in it. That rule is the whole reason this is a modifier and + * not a wrapper composable. + */ +@Composable +fun Modifier.detailCardFocus(focused: Boolean): Modifier { + val scale by animateFloatAsState( + targetValue = if (focused) DetailCardFocusScale else 1f, + animationSpec = tween(DetailCardFocusMs), + label = "detail-card-focus", + ) + return this + .graphicsLayer { + scaleX = scale + scaleY = scale + translationY = if (focused) DetailCardFocusLiftPx else 0f + } + .zIndex(if (focused) 1f else 0f) +} + +private const val DetailCardFocusScale = 1.05f +private const val DetailCardFocusMs = 100 +private const val DetailCardFocusLiftPx = -3f diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailHeroPhases.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailHeroPhases.kt new file mode 100644 index 0000000..3b8fcc9 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailHeroPhases.kt @@ -0,0 +1,153 @@ +package com.ponzischeme89.memby.ui.detail + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * One number drives the whole detail hero, and this is what it means. + * + * The page has a single `collapse` float — 0 is the complete cinematic opening frame, 1 is + * the compact pinned identity with the content pane owning the screen. Every part of the + * transformation is a *band* of that one value rather than an animation of its own: the + * synopsis goes first, the actions follow, the artwork recedes throughout, and the pinned + * header arrives last. Because they are bands of one number they cannot drift out of step, + * cannot be interrupted separately, and cannot each cost their own animation frame. + * + * They live here, pure, for the reason the rest of `ui/detail/` does: what a viewer sees at + * 40% of the way through a collapse is a design decision, and a decision that can only be + * checked by watching a television is a decision nobody checks. [DetailHeroPhaseTest] pins + * the ordering — which is the part that matters, far more than any individual figure. + */ + +/** + * How far through [start]..[end] [value] has travelled, clamped to 0..1. + * + * The single primitive under every band below. Degenerate ranges answer 0 below the point + * and 1 at or above it rather than dividing by zero, because a band collapsed to an instant + * is a legitimate way to say "switch here". + */ +fun fadeBand(value: Float, start: Float, end: Float): Float { + // The upper bound is tested first, which is what makes a band collapsed to an instant + // read as a switch at that instant rather than as one that never fires. + if (value >= end) return 1f + if (value <= start) return 0f + return ((value - start) / (end - start)).coerceIn(0f, 1f) +} + +/** + * The synopsis, genres, ratings, pace line and recommendation reason. + * + * First to go, and gone well before anything moves far. This is the half of the hero that + * answers "is this worth watching" — a question somebody who has pressed Down has already + * answered — so removing it is what makes the collapse read as *progressive disclosure* + * rather than as everything on the page shrinking at once. + */ +fun heroSupportingAlpha(collapse: Float): Float = 1f - fadeBand(collapse, 0f, 0.34f) + +/** + * The identity block, the fact line, the progress bar and the action row. + * + * They leave second and together: they are the reason the hero exists, and the viewer's eye + * is on them. Fully out by 0.62 so the action row has finished fading before the shrinking + * band could ever clip it — a button cut in half by a moving edge is the single ugliest + * frame this transition can produce. + */ +fun heroPrimaryAlpha(collapse: Float): Float = 1f - fadeBand(collapse, 0.22f, 0.62f) + +/** + * The compact pinned identity. + * + * Deliberately starts after [heroPrimaryAlpha] has finished, so the two headings never + * cross through each other — two titles dissolving through one another reads as a fault + * rather than as one heading becoming another. + */ +fun pinnedHeaderAlpha(collapse: Float): Float = fadeBand(collapse, 0.62f, 1f) + +/** + * The soft plate behind the pinned header. + * + * It leads the header in — the separation should already be there when the title arrives on + * it, or the plate reads as a second thing appearing. It is a gradient, never a bar: the + * requirement is a *perceptible* edge between a fixed header and scrolling content, and a + * solid block reads as a toolbar borrowed from a phone. + */ +fun pinnedScrimAlpha(collapse: Float): Float = fadeBand(collapse, 0.30f, 0.92f) + +/** + * How much the backdrop artwork gives up as content takes over. + * + * It recedes rather than disappearing: at rest the picture is the page, pinned it is a tone + * behind the tabs. Never to zero — an artwork that vanishes at some point in the travel + * produces a visible step, and the whole promise here is continuity. + */ +fun heroArtworkAlpha(collapse: Float): Float = 1f - 0.42f * collapse.coerceIn(0f, 1f) + +/** The deepening wash over the artwork, so the strip and the pane are read against near-black. */ +fun heroDimAlpha(collapse: Float): Float = collapse.coerceIn(0f, 1f) + +/** + * The hero's fixed geometry. + * + * Everything here exists because something in the hero must not move. The identity block is + * a reserved height so that a logo arriving after the text fallback — which is what always + * happens, since the logo has to be decoded before it can be judged legible — does not shove + * the rest of the hero up or down. The top inset is the safe area the expanded content may + * never cross, which is what stopped the logo travelling to the top edge of the screen on a + * title with a lot to say. And the caps are what make a very wide or very tall logo a + * letterboxed picture inside a known box rather than a layout that changes size per title. + */ +object DetailHeroMetrics { + /** + * The expanded hero's content may not enter the top of the screen. + * + * The hero content is anchored to the *bottom* of its band and grows upward, so without + * a ceiling a page with a two-line title, ratings, genres, a synopsis and a pace line + * simply grew until it hit the top edge — and then kept going, squeezing whatever was + * last in the column, which is the primary action. This is that ceiling. + */ + val TopInset: Dp = 40.dp + + /** + * Between the expanded content and the strip below it. + * + * Small, because the reserved caption line under the action row is already most of the + * gap that used to be here — and because every dp of it is a dp the synopsis does not + * get. The hero is a tight budget on a 540dp television and this is the least valuable + * thing in it. + */ + val BottomInset: Dp = 10.dp + + /** + * How many lines of synopsis the hero shows. + * + * Two, not three. The hero's job is "what is this and shall I play it"; the whole of + * the description is one press away in Overview, which is the tab the page opens on. + * The third line was the difference between the movie hero fitting its band and + * overflowing it, and a line of prose is the cheapest thing on the page to give up. + */ + const val SynopsisLines: Int = 2 + + /** + * Reserved for the logo or the text title, whichever this title turns out to have. + * + * A constant, because it is the swap between them that has to be invisible: the logo is + * only judged legible once it has been fetched and decoded, so every page begins on the + * text fallback and changes its mind a moment later. + */ + val IdentityHeight: Dp = 86.dp + + val LogoMaxWidth: Dp = 296.dp + val LogoMaxHeight: Dp = 78.dp + + /** The pinned header's logo. Small enough to be identity, not so small it is decoration. */ + val PinnedLogoMaxWidth: Dp = 148.dp + val PinnedLogoMaxHeight: Dp = 34.dp + + /** + * How much of the width the expanded hero's text column takes. + * + * The right-hand two fifths belong to the backdrop, which is the only reason the page + * looks cinematic at all; a reading column wider than this puts prose over the picture. + */ + const val TextWidthFraction: Float = 0.56f +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/DetailHeroPhaseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/DetailHeroPhaseTest.kt new file mode 100644 index 0000000..bb4065b --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/DetailHeroPhaseTest.kt @@ -0,0 +1,162 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.ui.detail.DetailHeroMetrics +import com.ponzischeme89.memby.ui.detail.fadeBand +import com.ponzischeme89.memby.ui.detail.heroArtworkAlpha +import com.ponzischeme89.memby.ui.detail.heroDimAlpha +import com.ponzischeme89.memby.ui.detail.heroPrimaryAlpha +import com.ponzischeme89.memby.ui.detail.heroSupportingAlpha +import com.ponzischeme89.memby.ui.detail.pinnedHeaderAlpha +import com.ponzischeme89.memby.ui.detail.pinnedScrimAlpha +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The detail hero's collapse, as a set of rules rather than as something to watch on a + * television. + * + * What is worth pinning here is the *ordering* — which part of the hero leaves first, and + * that the two headings never overlap — not any individual figure. A tuning change should + * pass this; a change that lets the pinned title fade up through the expanded one, or lets + * the action row still be visible when the band has nearly finished shrinking onto it, + * should not. + */ +class DetailHeroPhaseTest { + + private val travel = (0..100).map { it / 100f } + + @Test + fun `a band is clamped at both ends and travels linearly between them`() { + assertEquals(0f, fadeBand(0f, 0.2f, 0.6f), 0.0001f) + assertEquals(0f, fadeBand(0.2f, 0.2f, 0.6f), 0.0001f) + assertEquals(0.5f, fadeBand(0.4f, 0.2f, 0.6f), 0.0001f) + assertEquals(1f, fadeBand(0.6f, 0.2f, 0.6f), 0.0001f) + assertEquals(1f, fadeBand(1f, 0.2f, 0.6f), 0.0001f) + } + + /** A band collapsed to an instant is a legitimate way to say "switch here". */ + @Test + fun `a zero width band switches rather than dividing by zero`() { + assertEquals(0f, fadeBand(0.4f, 0.5f, 0.5f), 0.0001f) + assertEquals(1f, fadeBand(0.5f, 0.5f, 0.5f), 0.0001f) + assertEquals(1f, fadeBand(0.6f, 0.5f, 0.5f), 0.0001f) + } + + /** Both ends of the travel are a settled state, with nothing half-drawn at either. */ + @Test + fun `the expanded and pinned frames are each complete`() { + assertEquals(1f, heroSupportingAlpha(0f), 0.0001f) + assertEquals(1f, heroPrimaryAlpha(0f), 0.0001f) + assertEquals(0f, pinnedHeaderAlpha(0f), 0.0001f) + assertEquals(0f, heroDimAlpha(0f), 0.0001f) + assertEquals(1f, heroArtworkAlpha(0f), 0.0001f) + + assertEquals(0f, heroSupportingAlpha(1f), 0.0001f) + assertEquals(0f, heroPrimaryAlpha(1f), 0.0001f) + assertEquals(1f, pinnedHeaderAlpha(1f), 0.0001f) + assertEquals(1f, heroDimAlpha(1f), 0.0001f) + } + + /** + * The whole of progressive disclosure, in one assertion: the secondary half of the hero + * is always at least as far gone as the primary half. Reversing this would be the + * failure the design exists to avoid — everything shrinking together rather than the + * page shedding what the viewer has finished with. + */ + @Test + fun `the supporting information always leaves ahead of the actions`() { + travel.forEach { collapse -> + assertTrue( + "at $collapse the supporting block was still ahead of the primary one", + heroSupportingAlpha(collapse) <= heroPrimaryAlpha(collapse) + 0.0001f, + ) + } + } + + /** + * Two titles dissolving through one another reads as a fault. The expanded hero has to + * be completely gone before the pinned header has begun. + */ + @Test + fun `the two headings are never on screen together`() { + travel.forEach { collapse -> + assertTrue( + "at $collapse both headings were visible", + heroPrimaryAlpha(collapse) <= 0.0001f || pinnedHeaderAlpha(collapse) <= 0.0001f, + ) + } + } + + /** The separation is already there when the pinned title arrives on it. */ + @Test + fun `the pinned plate leads the pinned header in`() { + travel.forEach { collapse -> + assertTrue( + "at $collapse the header had outrun its plate", + pinnedHeaderAlpha(collapse) <= pinnedScrimAlpha(collapse) + 0.0001f, + ) + } + } + + /** Every band is a proper opacity for the whole of the travel, and none reverses. */ + @Test + fun `every band is monotonic and within range`() { + listOf( + "supporting" to ::heroSupportingAlpha, + "primary" to ::heroPrimaryAlpha, + "artwork" to ::heroArtworkAlpha, + ).forEach { (name, band) -> + var previous = band(0f) + travel.forEach { collapse -> + val value = band(collapse) + assertTrue("$name left 0..1 at $collapse", value in 0f..1f) + assertTrue("$name rose again at $collapse", value <= previous + 0.0001f) + previous = value + } + } + listOf( + "pinned header" to ::pinnedHeaderAlpha, + "pinned plate" to ::pinnedScrimAlpha, + "dim" to ::heroDimAlpha, + ).forEach { (name, band) -> + var previous = band(0f) + travel.forEach { collapse -> + val value = band(collapse) + assertTrue("$name left 0..1 at $collapse", value in 0f..1f) + assertTrue("$name fell back at $collapse", value >= previous - 0.0001f) + previous = value + } + } + } + + /** + * The backdrop recedes; it never disappears. An artwork that reaches zero somewhere in + * the travel produces a visible step, and continuity is the whole promise here. + */ + @Test + fun `the backdrop recedes without ever vanishing`() { + travel.forEach { collapse -> + assertTrue("the backdrop vanished at $collapse", heroArtworkAlpha(collapse) > 0.4f) + } + assertTrue(heroArtworkAlpha(1f) < heroArtworkAlpha(0f)) + } + + /** + * The hero is anchored to the bottom of its band and grows upward, so the top inset is + * the only thing between a title with a lot to say and the top edge of the screen. It + * has to be a real safe area — and the reading column has to leave the backdrop + * something, or the page stops being cinematic. + */ + @Test + fun `the hero keeps a safe top area and leaves the backdrop room`() { + assertTrue(DetailHeroMetrics.TopInset.value >= 24f) + assertTrue(DetailHeroMetrics.TextWidthFraction in 0.4f..0.7f) + // The reserved identity block has to hold the largest thing that can go in it: two + // lines of the 36sp title fallback, which is what a page shows until the logo has + // been fetched and judged legible. + assertTrue(DetailHeroMetrics.IdentityHeight >= DetailHeroMetrics.LogoMaxHeight) + assertTrue(DetailHeroMetrics.PinnedLogoMaxHeight < DetailHeroMetrics.LogoMaxHeight) + assertTrue(DetailHeroMetrics.PinnedLogoMaxWidth < DetailHeroMetrics.LogoMaxWidth) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailScreenshotTest.kt index 3b6d0da..1d278e3 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/EpisodeDetailScreenshotTest.kt @@ -22,6 +22,7 @@ import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.MediaStream import com.ponzischeme89.memby.data.model.Studio import com.ponzischeme89.memby.data.model.UserItemData +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -70,6 +71,39 @@ class EpisodeDetailScreenshotTest { } } + /** + * The worst case the hero can be handed, and the one 0.2.67 got wrong. + * + * An episode page carries more than any other variant — the show's name where a film has + * one line, the season and episode number, the episode's own title, the fact row, the + * ratings strip, a synopsis, a progress bar and an "Up next" line — and all of it above + * the action row. The hero is anchored to the bottom of its band and grows upward, so + * before the reading column was made the part that gives way, this stack pushed the + * heading off the top of the screen and left the Play button squeezed into whatever + * height was left over. + */ + @Test + fun `episode page with everything the hero can carry`() { + capture("df_episode-detail-crowded") { EpisodePage(item = crowded) } + } + + /** + * The same case, asserted rather than looked at. + * + * A screenshot shows a malformed button to somebody who opens the file; this fails the + * build. The primary action is a fixed-height control, so anything materially under its + * natural height means the column above it has taken its space back. + */ + @Test + fun `the primary action keeps its shape however crowded the hero is`() { + compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { EpisodePage(item = crowded) } } + val density = compose.density + val play = compose.onNodeWithTag("detail-play").fetchSemanticsNode() + val heightDp = with(density) { play.size.height.toDp() } + assertTrue("Play was $heightDp tall", heightDp >= MinimumPlayButtonHeight) + assertTrue("Play was $heightDp tall", play.size.width > play.size.height) + } + /** The first frame, before the series' episode list lands. The hero must be whole. */ @Test fun `episode page loading`() { @@ -206,6 +240,16 @@ class EpisodeDetailScreenshotTest { people = cast, ) + /** + * A long show name with no logo artwork, a long episode title, a resume position and a + * full ratings strip — every line the hero can be asked to draw, at once. + */ + private val crowded = current.copy( + seriesName = "The Longest Northbound Winter of Signal Hill", + name = "What the Log Says About the Night of the Second Crossing", + userData = UserItemData(playbackPositionTicks = 21L * TICKS_PER_MINUTE), + ) + private val seasonOneTitles = listOf( "Carrier Wave", "Dead Air", "Nightingale", "Six Weeks Out", "The Hill", "Landfall", ) @@ -252,5 +296,12 @@ class EpisodeDetailScreenshotTest { private companion object { const val DIR = "build/screenshots/episode-detail" const val TICKS_PER_MINUTE = 600_000_000L + + /** + * What [MembyPlayButton] measures at its full size: a 23dp icon inside 12dp of + * vertical padding, plus its border. Deliberately a floor rather than the exact + * figure, so retuning the button is not a test edit but squeezing it is. + */ + val MinimumPlayButtonHeight = 40.dp } } diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index d55812b..aa8b7d4 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -199,12 +199,23 @@ func run(log *slog.Logger, events *logging.Buffer) error { PrefetchEpisodes: cfg.CreditsPrefetchEpisodes, MaxPrefetchEpisodes: cfg.CreditsMaxPrefetch, QueueLimit: cfg.CreditsQueueLimit, + RetryCooldown: credits.DefaultConfig().RetryCooldown, StrongWindow: credits.DefaultConfig().StrongWindow, UsefulWindow: credits.DefaultConfig().UsefulWindow, WeakWindow: credits.DefaultConfig().WeakWindow, } + if saved, found, settingsErr := st.CreditsSettings(ctx); settingsErr != nil { + log.Warn("credits settings unavailable; using environment defaults", "error", settingsErr) + } else if found { + creditsConfig.PrefetchEpisodes = saved.PrefetchEpisodes + creditsConfig.MaxPrefetchEpisodes = saved.MaxPrefetch + creditsConfig.QueueLimit = saved.CandidateLimit + creditsConfig.RetryCooldown = time.Duration(saved.RetryHours) * time.Hour + } + creditsConfig = credits.NormaliseConfig(creditsConfig) creditsService = credits.New(credits.Deps{ Repository: database, + History: database, Resolver: credits.NewEmbyResolver(embyClient, emby.Credentials{ UserID: cfg.SyncUserID, Token: cfg.SyncAPIKey, DeviceID: "memby-credits", DeviceName: "MbyGateway Credits", diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 227d4af..13c018d 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -102,6 +102,8 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("GET /admin/api/tasks", s.adminAuth(s.handleAdminTasks)) mux.Handle("POST /admin/api/tasks/{taskID}/run", s.adminAuth(s.handleAdminRunTask)) mux.Handle("PUT /admin/api/tasks/{taskID}", s.adminAuth(s.handleAdminTaskSettings)) + mux.Handle("GET /admin/api/credits", s.adminAuth(s.handleAdminCredits)) + mux.Handle("PUT /admin/api/credits", s.adminAuth(s.handleAdminCredits)) // An unmatched API path is a 404, stated rather than left to the catch-all below — // otherwise a mistyped or removed route would answer with the console's HTML shell, @@ -229,6 +231,7 @@ type adminStatus struct { // ServerVersion is what the page's footer reports. An operator reading the live log // needs to know which build wrote it, and the page is the one place that is asked. ServerVersion string `json:"serverVersion"` + CurrentUser string `json:"currentUser,omitempty"` Maintenance store.Maintenance `json:"maintenance"` UpdatePolicy appupdate.Policy `json:"updatePolicy"` Library store.LibraryStats `json:"library"` @@ -293,6 +296,10 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, adminStatus{ ServerVersion: buildinfo.Version(), + CurrentUser: func() string { + _, username, _ := s.browserSession(r, adminSessionPurpose) + return username + }(), Maintenance: s.maintenance.get(), UpdatePolicy: s.updatePolicy.get(), Library: stats, diff --git a/server/internal/api/admin_access_test.go b/server/internal/api/admin_access_test.go index cf91662..5a39a48 100644 --- a/server/internal/api/admin_access_test.go +++ b/server/internal/api/admin_access_test.go @@ -153,6 +153,10 @@ func TestAdministratorSignInMintsAnAdminSession(t *testing.T) { if !s.validAdminSession(req) { t.Fatal("an administrator's session was not accepted by the console") } + _, username, ok := s.browserSession(req, adminSessionPurpose) + if !ok || username != "Viewer" { + t.Fatalf("admin session identity = %q, valid %t; want Viewer", username, ok) + } } // An Emby that returns no policy at all is a question, not a refusal — reading its silence diff --git a/server/internal/api/admin_credits.go b/server/internal/api/admin_credits.go new file mode 100644 index 0000000..24ee985 --- /dev/null +++ b/server/internal/api/admin_credits.go @@ -0,0 +1,141 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/ponzischeme89/memby/server/internal/credits" + "github.com/ponzischeme89/memby/server/internal/store" +) + +type adminCreditsSettings struct { + CandidateLimit int `json:"candidateLimit"` + PrefetchEpisodes int `json:"prefetchEpisodes"` + MaxPrefetch int `json:"maxPrefetch"` + RetryHours int `json:"retryHours"` +} + +type adminCreditsCandidate struct { + ItemID string `json:"itemId"` + SeriesID string `json:"seriesId"` + Season int `json:"season"` + Episode int `json:"episode"` + Priority int `json:"priority"` + Reason string `json:"reason"` + UserCount int `json:"userCount"` + LastViewed time.Time `json:"lastViewed"` +} + +type adminCreditsResponse struct { + Enabled bool `json:"enabled"` + Settings adminCreditsSettings `json:"settings"` + QueueDepth int `json:"queueDepth"` + Pending []adminCreditsCandidate `json:"pending"` + History []store.CreditsScanHistoryRow `json:"history"` +} + +func creditsAdminSettings(cfg credits.Config) adminCreditsSettings { + cfg = credits.NormaliseConfig(cfg) + return adminCreditsSettings{ + CandidateLimit: cfg.QueueLimit, PrefetchEpisodes: cfg.PrefetchEpisodes, + MaxPrefetch: cfg.MaxPrefetchEpisodes, + RetryHours: int(cfg.RetryCooldown / time.Hour), + } +} + +func (s *Server) defaultCreditsConfig() credits.Config { + defaults := credits.DefaultConfig() + defaults.PrefetchEpisodes = s.cfg.CreditsPrefetchEpisodes + defaults.MaxPrefetchEpisodes = s.cfg.CreditsMaxPrefetch + defaults.QueueLimit = s.cfg.CreditsQueueLimit + return credits.NormaliseConfig(defaults) +} + +func (s *Server) handleAdminCredits(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + s.handleAdminCreditsSettings(w, r) + return + } + s.writeAdminCredits(w, r) +} + +func (s *Server) writeAdminCredits(w http.ResponseWriter, r *http.Request) { + cfg := s.defaultCreditsConfig() + pending := []adminCreditsCandidate{} + queueDepth := 0 + if s.credits != nil { + cfg = s.credits.Configuration() + queueDepth = s.credits.QueueDepth() + for _, candidate := range s.credits.Pending() { + pending = append(pending, adminCreditsCandidate{ + ItemID: candidate.ItemID, SeriesID: candidate.SeriesID, + Season: candidate.Season, Episode: candidate.Episode, + Priority: candidate.Priority, Reason: candidate.Reason, + UserCount: candidate.UserCount, LastViewed: candidate.LastViewed, + }) + } + } else if saved, found, err := s.store.CreditsSettings(r.Context()); err == nil && found { + cfg.PrefetchEpisodes = saved.PrefetchEpisodes + cfg.MaxPrefetchEpisodes = saved.MaxPrefetch + cfg.QueueLimit = saved.CandidateLimit + cfg.RetryCooldown = time.Duration(saved.RetryHours) * time.Hour + cfg = credits.NormaliseConfig(cfg) + } + history, err := s.store.CreditsScanHistory( + r.Context(), queryInt(r, "limit", 100, 500), + ) + if err != nil { + s.loggerFor(r.Context()).Warn("credits scan history read failed", "error", err) + history = []store.CreditsScanHistoryRow{} + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, adminCreditsResponse{ + Enabled: s.credits != nil, Settings: creditsAdminSettings(cfg), + QueueDepth: queueDepth, Pending: pending, History: history, + }) +} + +func (s *Server) handleAdminCreditsSettings(w http.ResponseWriter, r *http.Request) { + var req adminCreditsSettings + 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.CandidateLimit < 1 || req.CandidateLimit > 100 { + writeError(w, http.StatusBadRequest, "candidate limit must be between 1 and 100") + return + } + if req.PrefetchEpisodes < 1 || req.PrefetchEpisodes > 10 { + writeError(w, http.StatusBadRequest, "ordinary look-ahead must be between 1 and 10 episodes") + return + } + if req.MaxPrefetch < req.PrefetchEpisodes || req.MaxPrefetch > 20 { + writeError(w, http.StatusBadRequest, "maximum look-ahead must be at least the ordinary look-ahead and no more than 20") + return + } + if req.RetryHours < 0 || req.RetryHours > 24*30 { + writeError(w, http.StatusBadRequest, "retry delay must be between 0 and 720 hours") + return + } + settings := store.CreditsSettings{ + CandidateLimit: req.CandidateLimit, PrefetchEpisodes: req.PrefetchEpisodes, + MaxPrefetch: req.MaxPrefetch, RetryHours: req.RetryHours, + } + if err := s.store.SetCreditsSettings(r.Context(), settings); err != nil { + writeError(w, http.StatusInternalServerError, "could not save credits settings") + return + } + if s.credits != nil { + cfg := s.credits.Configuration() + cfg.QueueLimit = req.CandidateLimit + cfg.PrefetchEpisodes = req.PrefetchEpisodes + cfg.MaxPrefetchEpisodes = req.MaxPrefetch + cfg.RetryCooldown = time.Duration(req.RetryHours) * time.Hour + s.credits.Configure(cfg) + } + s.loggerFor(r.Context()).Info("credits detection settings changed", + "candidate_limit", req.CandidateLimit, "prefetch", req.PrefetchEpisodes, + "max_prefetch", req.MaxPrefetch, "retry_hours", req.RetryHours) + s.writeAdminCredits(w, r) +} diff --git a/server/internal/api/installer_auth.go b/server/internal/api/installer_auth.go index 3624af2..2612d3b 100644 --- a/server/internal/api/installer_auth.go +++ b/server/internal/api/installer_auth.go @@ -11,6 +11,7 @@ import ( "net/url" "strings" "time" + "unicode/utf8" "github.com/ponzischeme89/memby/server/internal/emby" ) @@ -84,11 +85,24 @@ func (s *Server) newInstallerSession() (string, error) { } func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, error) { - payload := make([]byte, 8+16) + return s.newBrowserSessionFor(purpose, ttl, "") +} + +// newBrowserSessionFor includes the verified account name in an admin session. It remains +// inside the signed, HttpOnly cookie: the console learns who is at the keyboard from the +// status response, without adding a second identity cookie that JavaScript could alter. +// Sessions minted by older gateways had only the 24-byte prefix and remain valid. +func (s *Server) newBrowserSessionFor(purpose string, ttl time.Duration, username string) (string, error) { + username = strings.TrimSpace(username) + if len(username) > 512 || !utf8.ValidString(username) { + username = "" + } + payload := make([]byte, 8+16+len(username)) binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(ttl).Unix())) - if _, err := rand.Read(payload[8:]); err != nil { + if _, err := rand.Read(payload[8:24]); err != nil { return "", err } + copy(payload[24:], username) signature := s.signInstallerValue(purpose, payload) return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(signature), nil @@ -98,31 +112,39 @@ func (s *Server) newBrowserSession(purpose string, ttl time.Duration) (string, e // cookie that is missing, malformed, forged, signed for a different purpose or already // expired is reported the same way: no session. func (s *Server) browserSessionExpiry(r *http.Request, purpose string) (time.Time, bool) { + expires, _, ok := s.browserSession(r, purpose) + return expires, ok +} + +// browserSession verifies the session once and returns the optional identity carried by +// newer cookies. The empty identity is legitimate for a session issued before it was +// added, so validity is reported separately. +func (s *Server) browserSession(r *http.Request, purpose string) (time.Time, string, bool) { if len(s.installerSecret()) == 0 { - return time.Time{}, false + return time.Time{}, "", false } cookie, err := r.Cookie(installerCookieName) if err != nil { - return time.Time{}, false + return time.Time{}, "", false } parts := strings.Split(cookie.Value, ".") if len(parts) != 2 { - return time.Time{}, false + return time.Time{}, "", false } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil || len(payload) != 24 { - return time.Time{}, false + if err != nil || len(payload) < 24 || len(payload) > 536 || !utf8.Valid(payload[24:]) { + return time.Time{}, "", false } signature, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil || !hmac.Equal(signature, s.signInstallerValue(purpose, payload)) { - return time.Time{}, false + return time.Time{}, "", false } expires := int64(binary.BigEndian.Uint64(payload[:8])) now := time.Now().Unix() if expires <= now || expires > now+int64(adminSessionTTL/time.Second)+60 { - return time.Time{}, false + return time.Time{}, "", false } - return time.Unix(expires, 0), true + return time.Unix(expires, 0), strings.TrimSpace(string(payload[24:])), true } // validInstallerSession gates the public installer, which an administrator's own session @@ -149,11 +171,11 @@ func (s *Server) validAdminSession(r *http.Request) bool { // an operator actually made — see operatorPresent — or an abandoned tab's own polling // would keep the session alive indefinitely, which is what the TTL exists to stop. func (s *Server) renewAdminSession(w http.ResponseWriter, r *http.Request) { - expires, ok := s.browserSessionExpiry(r, adminSessionPurpose) + expires, username, ok := s.browserSession(r, adminSessionPurpose) if !ok || time.Until(expires) > adminRenewWithin { return } - session, err := s.newBrowserSession(adminSessionPurpose, adminSessionTTL) + session, err := s.newBrowserSessionFor(adminSessionPurpose, adminSessionTTL, username) if err != nil { s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err) return @@ -317,7 +339,11 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) { } purpose, ttl = adminSessionPurpose, adminSessionTTL } - session, err := s.newBrowserSession(purpose, ttl) + verifiedUsername := strings.TrimSpace(auth.User.Name) + if verifiedUsername == "" { + verifiedUsername = username + } + session, err := s.newBrowserSessionFor(purpose, ttl, verifiedUsername) if err != nil { s.loggerFor(r.Context()).Error("installer session generation failed", "error", err) writeError(w, http.StatusInternalServerError, "could not start installer session") diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 8b6752f..b5b9d51 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -94,8 +94,12 @@ type playbackReportResponse struct { // Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so // the client decodes it into the same BaseItem it uses everywhere else. type nextEpisodeResponse struct { - Item json.RawMessage `json:"item"` - Title string `json:"title"` + Item json.RawMessage `json:"item"` + Title string `json:"title"` + // StreamReady says whether this answer carries a negotiated stream. A client asking + // with `stream=0` gets identity, artwork and runtime and nothing that touches Emby's + // PlaybackInfo — see [handleNextEpisode] for why that separation exists. + StreamReady bool `json:"streamReady"` URL string `json:"url"` ResumePositionMs int64 `json:"resumePositionMs"` Subtitles []playableSubtitle `json:"subtitles"` @@ -302,6 +306,20 @@ func episodeCode(item emby.Summary) string { // // "Nothing follows this" is a normal answer, not a failure: a movie, a series finale and an // unreadable series all come back as 404 and the client simply shows no banner. +// +// It answers in two shapes, and the separation is the whole reason auto-advance stopped +// showing a viewer the machinery. `stream=0` asks for **identity only** — the episode's +// item JSON, its title and its artwork — and touches nothing but the episode list. The +// full answer additionally negotiates PlaybackInfo, which mints a play session and, on a +// title that has to be transcoded, a transcode session at Emby. +// +// The television asks for the cheap shape when the episode it is watching *starts*, which +// is where the banner's picture and wording come from, and for the full one shortly before +// the hand-over. Doing both at once is what the player used to do, and it was wrong twice: +// a session negotiated forty minutes early has long since been torn down by the time it is +// used — so the transition failed, retried, and narrated all of it to the viewer — and the +// transcode it may have started sat there being paid for by a viewer who had not yet +// decided to watch it. func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess store.Session) { ctx := r.Context() itemID := r.PathValue("id") @@ -355,6 +373,27 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" { title = series + " – " + title } + + // The metadata-only shape. Everything below this point is a PlaybackInfo negotiation, + // and a client that said it does not want one yet must not be given one anyway. + if !nextEpisodeWantsStream(r) { + s.playbackTitles.remember(next.ID, title) + s.loggerFor(ctx).Debug("next episode identified", + "title", title, "item", next.ID, "after_item", itemID) + writeJSON(w, http.StatusOK, nextEpisodeResponse{ + Item: raw, + Title: title, + StreamReady: false, + ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + Subtitles: []playableSubtitle{}, + SubtitlesEnabled: true, + TrickplayAvailable: s.trickplayEnabled(ctx), + SkipIntroAvailable: s.skipIntroEnabled(ctx), + EndCreditsAvailable: s.endCreditsEnabled(ctx), + }) + return + } + subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles( ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess), @@ -380,6 +419,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess writeJSON(w, http.StatusOK, nextEpisodeResponse{ Item: raw, Title: title, + StreamReady: true, URL: streamURL, ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), Subtitles: subtitles, @@ -395,6 +435,19 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess }) } +// nextEpisodeWantsStream reads the client's `stream` parameter, and **defaults to yes**. +// An app built before the two-phase lookup existed sends nothing and expects the whole +// answer; reading a missing parameter as "identity only" would hand every one of those +// televisions a next-up banner with no stream behind it. +func nextEpisodeWantsStream(r *http.Request) bool { + switch strings.TrimSpace(strings.ToLower(r.URL.Query().Get("stream"))) { + case "0", "false", "no": + return false + default: + return true + } +} + func (s *Server) playbackSubtitles( ctx context.Context, cred emby.Credentials, itemID string, startTicks int64, subtitleIndex *int, currentPlaySessionID string, forceTranscode bool, diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 9506d14..5946feb 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.47 +0.1.48 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 2ac3baf..d59a231 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -175,12 +175,13 @@ type Config struct { // markers, on behavioural evidence alone — which on a well-watched show is the better // signal anyway. CreditsFFmpeg string - // CreditsPrefetchEpisodes is the look-ahead for an ordinary viewer; velocity moves the - // actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing exceeds. + // CreditsPrefetchEpisodes is the first-run look-ahead for an ordinary viewer; velocity + // moves the actual depth either side of it, and CreditsMaxPrefetch is the ceiling nothing + // exceeds. The admin console persists later operator choices in app_settings. CreditsPrefetchEpisodes int CreditsMaxPrefetch int - // CreditsQueueLimit bounds pending candidates. Past it, low-priority speculation is - // discarded rather than queued. + // CreditsQueueLimit is the first-run bound on pending candidates. Past it, low-priority + // speculation is discarded rather than queued. CreditsQueueLimit int // TracearrSyncInterval imports recent changed sessions. FullInterval reconciles diff --git a/server/internal/credits/README.md b/server/internal/credits/README.md index aa1f327..d4f7ccd 100644 --- a/server/internal/credits/README.md +++ b/server/internal/credits/README.md @@ -8,7 +8,7 @@ is in the library" and answers by reading all of it. This asks "what will somebo on over the next few evenings", which Tracearr already knows, and reads almost nothing: ``` -Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again +Tracearr demand → priority queue → marker/cooldown check → tiny tail scan → history + marker ``` ## Why it exists @@ -57,6 +57,19 @@ postgres.go the store adapter load.go whether the server is too busy for speculative work ``` +## Operator controls and history + +Admin Console → Credits detection controls three different bounds: the number of candidates +allowed to wait, the ordinary and maximum episode look-ahead, and the retry delay after an +inconclusive or failed speculative scan. The environment values are first-run defaults; a +saved console choice is restored on restart. + +The retry delay is important. A successful marker excludes an episode naturally, but an old +worker forgot a no-match result and selected the episode again at the next ten-minute +refresh. Completed attempts now have a small durable history and no-match/failed episodes +cool down before prediction may select them again. Live playback may still raise an episode +immediately because a viewer is waiting. + ## Two detectors, and the cheap one is often better **Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is @@ -111,7 +124,7 @@ number belonging to neither. | Fine pass | 750ms over ±30s — ~80 frames | | Frame buffer | 14,400 bytes, allocated once and reused for the whole pass | | Writes, cached marker | **0** | -| Writes, successful scan | **1** | +| Writes, completed scan | **1 history row**, plus **1 marker** only when accepted | Against scanning the full library: 20,000 items read end to end versus a queue capped at 20 candidates, most of which are rejected by the marker check before any media is touched. Once @@ -135,7 +148,8 @@ in which demand-driven narrowing is doing nothing, and the design would need rev - **The queue is deliberately not durable.** Candidate priorities are rebuilt from one Tracearr query on restart, which is cheaper and simpler than a second persistent job - scheduler. The database holds markers and nothing else. + scheduler. Completed scan history is durable because it is an operator record and the + source of retry cooldowns; it does not preserve or resume queue state. - **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the two costs this exists to minimise on a machine whose real job is streaming video. - **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a diff --git a/server/internal/credits/candidates.go b/server/internal/credits/candidates.go index c408697..da4b9d4 100644 --- a/server/internal/credits/candidates.go +++ b/server/internal/credits/candidates.go @@ -35,6 +35,10 @@ type Config struct { // QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than // queued — a backlog of candidates for episodes nobody reached is worse than no backlog. QueueLimit int + + // RetryCooldown keeps an unsuccessful speculative scan from returning on every queue + // refresh. Live playback remains immediate; this applies only to predicted work. + RetryCooldown time.Duration } func DefaultConfig() Config { @@ -45,9 +49,50 @@ func DefaultConfig() Config { UsefulWindow: 3 * 24 * time.Hour, WeakWindow: 7 * 24 * time.Hour, QueueLimit: 20, + RetryCooldown: 24 * time.Hour, } } +// NormaliseConfig makes both environment and admin-supplied tuning safe. It is exported +// because the gateway restores persisted settings before constructing the service. +func NormaliseConfig(cfg Config) Config { + defaults := DefaultConfig() + if cfg.PrefetchEpisodes <= 0 { + cfg.PrefetchEpisodes = defaults.PrefetchEpisodes + } + if cfg.PrefetchEpisodes > 10 { + cfg.PrefetchEpisodes = 10 + } + if cfg.MaxPrefetchEpisodes < cfg.PrefetchEpisodes { + cfg.MaxPrefetchEpisodes = cfg.PrefetchEpisodes + } + if cfg.MaxPrefetchEpisodes > 20 { + cfg.MaxPrefetchEpisodes = 20 + } + if cfg.QueueLimit <= 0 { + cfg.QueueLimit = defaults.QueueLimit + } + if cfg.QueueLimit > 100 { + cfg.QueueLimit = 100 + } + if cfg.StrongWindow <= 0 { + cfg.StrongWindow = defaults.StrongWindow + } + if cfg.UsefulWindow <= 0 { + cfg.UsefulWindow = defaults.UsefulWindow + } + if cfg.WeakWindow <= 0 { + cfg.WeakWindow = defaults.WeakWindow + } + if cfg.RetryCooldown < 0 { + cfg.RetryCooldown = 0 + } + if cfg.RetryCooldown > 30*24*time.Hour { + cfg.RetryCooldown = 30 * 24 * time.Hour + } + return cfg +} + // Watch is one episode one viewer played, reduced to the four things candidate generation // needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is // what lets every rule below be tested with a literal. diff --git a/server/internal/credits/credits.go b/server/internal/credits/credits.go index ccab52d..ada3701 100644 --- a/server/internal/credits/credits.go +++ b/server/internal/credits/credits.go @@ -174,6 +174,38 @@ type CandidateSource interface { Candidates(ctx context.Context) ([]Candidate, error) } +// ConfigurableCandidateSource accepts live operator tuning without rebuilding the worker. +type ConfigurableCandidateSource interface { + Configure(Config) +} + +// ScanAttempt is the durable operational account of one claimed candidate. Unlike the +// queue, this is history rather than scheduling state, so retaining it does not turn the +// worker into a persistent job system. +type ScanAttempt struct { + ItemID string + SeriesID string + Season int + Episode int + Reason string + Priority int + Outcome string + MarkerMs int64 + Confidence float64 + Method string + Frames int + Error string + StartedAt time.Time + FinishedAt time.Time +} + +// HistoryRepository records completed scans and answers which speculative candidates are +// still inside their retry cooldown. +type HistoryRepository interface { + SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error + RecentScanTimes(ctx context.Context, itemIDs []string, since time.Time) (map[string]time.Time, error) +} + // Repository is the marker store. Narrow on purpose — the scheduler must not be able to // write anything else, because "one row per successful scan and nothing else" is the // performance claim this subsystem is making. diff --git a/server/internal/credits/postgres.go b/server/internal/credits/postgres.go index a9fcde2..274beaf 100644 --- a/server/internal/credits/postgres.go +++ b/server/internal/credits/postgres.go @@ -40,6 +40,23 @@ func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error { }) } +func (p Postgres) SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error { + return p.Store.SaveCreditsScanAttempt(ctx, store.CreditsScanHistoryRow{ + ItemID: attempt.ItemID, SeriesID: attempt.SeriesID, + Season: attempt.Season, Episode: attempt.Episode, + Reason: attempt.Reason, Priority: attempt.Priority, Outcome: attempt.Outcome, + MarkerMs: attempt.MarkerMs, Confidence: attempt.Confidence, + Method: attempt.Method, Frames: attempt.Frames, Error: attempt.Error, + StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, + }) +} + +func (p Postgres) RecentScanTimes( + ctx context.Context, itemIDs []string, since time.Time, +) (map[string]time.Time, error) { + return p.Store.RecentCreditsScanTimes(ctx, itemIDs, since) +} + func (p Postgres) SeasonMarkers( ctx context.Context, seriesID string, season, limit int, ) ([]Marker, error) { diff --git a/server/internal/credits/queue.go b/server/internal/credits/queue.go index 6385c58..991573a 100644 --- a/server/internal/credits/queue.go +++ b/server/internal/credits/queue.go @@ -136,6 +136,25 @@ func (q *Queue) Len() int { return len(q.items) } +// SetLimit applies an operator change immediately. The strongest candidates survive a +// reduction, using the same ordering as Claim, so changing the setting cannot reshuffle +// the meaning of priority. +func (q *Queue) SetLimit(limit int) { + if limit <= 0 { + return + } + q.mu.Lock() + defer q.mu.Unlock() + q.limit = limit + for len(q.items) > q.limit { + weakestID, _ := q.weakestLocked() + if weakestID == "" { + break + } + delete(q.items, weakestID) + } +} + // Snapshot is the queue in worker order, for the log line and the admin console. func (q *Queue) Snapshot() []Candidate { q.mu.Lock() diff --git a/server/internal/credits/queue_test.go b/server/internal/credits/queue_test.go index 9a137b4..6556696 100644 --- a/server/internal/credits/queue_test.go +++ b/server/internal/credits/queue_test.go @@ -141,3 +141,17 @@ func TestQueueOrderIsTotalAndStable(t *testing.T) { t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID) } } + +func TestReducingQueueLimitKeepsTheStrongestCandidates(t *testing.T) { + queue := NewQueue(5) + queue.Push(queued("weak", PrioritySpeculative)) + queue.Push(queued("ahead", PriorityAhead2)) + queue.Push(queued("next", PriorityNext)) + + queue.SetLimit(2) + + pending := queue.Snapshot() + if len(pending) != 2 || pending[0].ItemID != "next" || pending[1].ItemID != "ahead" { + t.Fatalf("reduced queue = %+v; want the two strongest candidates", pending) + } +} diff --git a/server/internal/credits/service.go b/server/internal/credits/service.go index 1dd6ab8..2599e69 100644 --- a/server/internal/credits/service.go +++ b/server/internal/credits/service.go @@ -76,6 +76,7 @@ type LoadGauge interface { // missing from the image. type Deps struct { Repository Repository + History HistoryRepository Resolver MediaResolver Source CandidateSource Detector Detector @@ -87,6 +88,7 @@ type Deps struct { type Service struct { repo Repository + history HistoryRepository resolver MediaResolver source CandidateSource detector Detector @@ -94,6 +96,7 @@ type Service struct { load LoadGauge log *slog.Logger cfg Config + cfgMu sync.RWMutex queue *Queue flight *flightGroup @@ -107,10 +110,7 @@ type Service struct { } func New(deps Deps) *Service { - cfg := deps.Config - if cfg.QueueLimit <= 0 { - cfg = DefaultConfig() - } + cfg := NormaliseConfig(deps.Config) detector := deps.Detector if detector == nil { detector = noopDetector{} @@ -121,6 +121,7 @@ func New(deps Deps) *Service { } return &Service{ repo: deps.Repository, + history: deps.History, resolver: deps.Resolver, source: deps.Source, detector: detector, @@ -181,9 +182,37 @@ func (s *Service) Refresh(ctx context.Context) (string, error) { } wanted = append(wanted, candidate) } + deferred := 0 + cfg := s.Configuration() + if s.history != nil && cfg.RetryCooldown > 0 && len(wanted) > 0 { + ids := make([]string, 0, len(wanted)) + for _, candidate := range wanted { + ids = append(ids, candidate.ItemID) + } + recent, historyErr := s.history.RecentScanTimes( + ctx, ids, time.Now().UTC().Add(-cfg.RetryCooldown), + ) + if historyErr != nil { + s.log.Debug("credits scan history unavailable", "error", historyErr) + } else if len(recent) > 0 { + eligible := wanted[:0] + for _, candidate := range wanted { + if _, coolingDown := recent[candidate.ItemID]; coolingDown { + deferred++ + continue + } + eligible = append(eligible, candidate) + } + wanted = eligible + } + } s.queue.Replace(wanted) if len(wanted) == 0 { + if deferred > 0 { + return fmt.Sprintf("%d candidate%s cooling down, %d already known", + deferred, plural(deferred), skipped), nil + } return "", nil } for _, candidate := range wanted { @@ -191,8 +220,8 @@ func (s *Service) Refresh(ctx context.Context) (string, error) { "item", candidate.ItemID, "reason", candidate.Reason, "priority", candidate.Priority, "users", candidate.UserCount) } - return fmt.Sprintf("%d candidate%s queued, %d already known", - len(wanted), plural(len(wanted)), skipped), nil + return fmt.Sprintf("%d candidate%s queued, %d cooling down, %d already known", + len(wanted), plural(len(wanted)), deferred, skipped), nil } // NotePlayback is the live signal, and the strongest one there is: somebody is watching this @@ -303,10 +332,12 @@ func (s *Service) Run(ctx context.Context) { continue } + started := time.Now().UTC() scanCtx, cancel := context.WithTimeout(ctx, scanBudget) detection, stored, err := s.Process(scanCtx, candidate.ItemID) cancel() s.queue.Release(candidate.ItemID) + s.recordAttempt(ctx, candidate, detection, stored, err, started) switch { case err != nil && errors.Is(err, context.Canceled): @@ -496,6 +527,61 @@ func (s *Service) busy(ctx context.Context) bool { return s.load != nil && s.load.Busy(ctx) } +// Configuration is a snapshot safe to expose through the admin API. +func (s *Service) Configuration() Config { + s.cfgMu.RLock() + defer s.cfgMu.RUnlock() + return s.cfg +} + +// Configure applies persisted operator tuning to future candidate refreshes and trims an +// over-full queue immediately. A scan already in flight is deliberately left alone. +func (s *Service) Configure(cfg Config) { + cfg = NormaliseConfig(cfg) + s.cfgMu.Lock() + s.cfg = cfg + s.cfgMu.Unlock() + if source, ok := s.source.(ConfigurableCandidateSource); ok { + source.Configure(cfg) + } + s.queue.SetLimit(cfg.QueueLimit) +} + +func (s *Service) recordAttempt( + ctx context.Context, candidate Candidate, detection Detection, stored bool, scanErr error, + started time.Time, +) { + if s.history == nil { + return + } + outcome := "no_match" + switch { + case scanErr != nil: + outcome = "failed" + case stored: + outcome = "detected" + case detection.Found: + outcome = "unchanged" + } + errorText := "" + if scanErr != nil { + errorText = scanErr.Error() + } + finished := time.Now().UTC() + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := s.history.SaveScanAttempt(writeCtx, ScanAttempt{ + ItemID: candidate.ItemID, SeriesID: candidate.SeriesID, + Season: candidate.Season, Episode: candidate.Episode, + Reason: candidate.Reason, Priority: candidate.Priority, Outcome: outcome, + MarkerMs: detection.StartMs, Confidence: detection.Confidence, + Method: detection.Method, Frames: detection.FramesSampled, Error: errorText, + StartedAt: started, FinishedAt: finished, + }); err != nil { + s.log.Debug("credits scan history write failed", "item", candidate.ItemID, "error", err) + } +} + // QueueDepth is what the console reads. func (s *Service) QueueDepth() int { return s.queue.Len() } diff --git a/server/internal/credits/service_test.go b/server/internal/credits/service_test.go index a1ccd9c..f7d6073 100644 --- a/server/internal/credits/service_test.go +++ b/server/internal/credits/service_test.go @@ -85,6 +85,26 @@ type fakeBehaviour struct{ stops []StopEvent } func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil } +type fixedCandidates []Candidate + +func (f fixedCandidates) Candidates(context.Context) ([]Candidate, error) { return f, nil } + +type fakeHistory struct { + recent map[string]time.Time + attempts []ScanAttempt +} + +func (h *fakeHistory) SaveScanAttempt(_ context.Context, attempt ScanAttempt) error { + h.attempts = append(h.attempts, attempt) + return nil +} + +func (h *fakeHistory) RecentScanTimes( + _ context.Context, _ []string, _ time.Time, +) (map[string]time.Time, error) { + return h.recent, nil +} + func quietLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) } @@ -356,3 +376,42 @@ func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) { t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive) } } + +// An inconclusive scan used to be forgotten, so the ten-minute refresh selected the same +// episode for ever. History now makes that episode ineligible until its cooldown expires. +func TestRefreshDefersRecentlyScannedCandidate(t *testing.T) { + media := testMedia() + history := &fakeHistory{recent: map[string]time.Time{media.Version.ItemID: time.Now()}} + service := New(Deps{ + Repository: newFakeRepo(), History: history, + Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}}, + Source: fixedCandidates{{ItemID: media.Version.ItemID, Priority: PriorityNext}}, + Log: quietLog(), Config: DefaultConfig(), + }) + + detail, err := service.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if service.QueueDepth() != 0 { + t.Fatal("a recently scanned episode returned to the queue") + } + if detail != "1 candidate cooling down, 0 already known" { + t.Fatalf("refresh detail = %q", detail) + } +} + +func TestScanAttemptHistoryDescribesNoMatch(t *testing.T) { + history := &fakeHistory{} + service := New(Deps{ + Repository: newFakeRepo(), History: history, + Resolver: &fakeResolver{media: map[string]ResolvedMedia{}}, + Log: quietLog(), Config: DefaultConfig(), + }) + candidate := Candidate{ItemID: "episode-7", Reason: ReasonNext, Priority: PriorityNext} + service.recordAttempt(context.Background(), candidate, Detection{FramesSampled: 42}, false, nil, time.Now().Add(-time.Second)) + + if len(history.attempts) != 1 || history.attempts[0].Outcome != "no_match" || history.attempts[0].Frames != 42 { + t.Fatalf("history = %+v", history.attempts) + } +} diff --git a/server/internal/credits/tracearr.go b/server/internal/credits/tracearr.go index 2324e10..68ea91f 100644 --- a/server/internal/credits/tracearr.go +++ b/server/internal/credits/tracearr.go @@ -3,6 +3,7 @@ package credits import ( "context" "sort" + "sync" "time" ) @@ -39,6 +40,19 @@ const watchLimit = 500 type TracearrSource struct { DB Database Cfg Config + mu sync.RWMutex +} + +func (s *TracearrSource) Configure(cfg Config) { + s.mu.Lock() + s.Cfg = NormaliseConfig(cfg) + s.mu.Unlock() +} + +func (s *TracearrSource) config() Config { + s.mu.RLock() + defer s.mu.RUnlock() + return NormaliseConfig(s.Cfg) } // Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer @@ -51,10 +65,7 @@ func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) { if s == nil || s.DB == nil { return nil, nil } - cfg := s.Cfg - if cfg.QueueLimit <= 0 { - cfg = DefaultConfig() - } + cfg := s.config() now := time.Now().UTC() // The decay window is the query's window too. Anything older cannot survive diff --git a/server/internal/recommend/magic.go b/server/internal/recommend/magic.go new file mode 100644 index 0000000..ac2b370 --- /dev/null +++ b/server/internal/recommend/magic.go @@ -0,0 +1,306 @@ +package recommend + +import ( + "context" + "math" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" +) + +// Magic answers "I don't care exactly what — put something good on". +// +// It is deliberately not the same thing as the top of a recommendation row. A row is read, +// compared and chosen from, so its order is the whole product and being stable matters; this +// is pressed instead of choosing, so being *the same answer twice* is the one failure it +// cannot have. What it does share with the rows is the evidence: the same profile, the same +// candidate pool, the same explanation layer. The difference is only what happens after the +// scoring, which is a weighted draw rather than a sort. +// +// The scoring is a sum of stated terms rather than an opaque model, for the same reason the +// rest of this package is: a Magic pick that lands badly is something the household will +// want explained, and [MagicSelection.Signals] is the explanation. +const ( + // MagicPoolLimit is how many titles are in the hat. Large enough that a household + // pressing Magic every evening for a fortnight does not exhaust it, small enough that + // nothing genuinely unsuitable can be drawn. + MagicPoolLimit = 40 + + // magicUnwatchedBonus is the largest single term, because "something I have not seen" + // is most of what somebody means by the button. + magicUnwatchedBonus = 1.4 + // magicSeenPenalty applies to a title this viewer has already watched. A penalty and + // not an exclusion: a rewatch is a legitimate answer, and a library whose owner has + // seen most of it must still have something to offer. + magicSeenPenalty = 1.1 + // magicFavouriteBonus is for a title the viewer marked themselves. Below the unwatched + // bonus deliberately — a favourite is by definition something already watched. + magicFavouriteBonus = 0.7 + // magicRecentlyAddedBonus surfaces what the household has just acquired. + magicRecentlyAddedBonus = 0.5 + magicRecentlyAddedDays = 30 + // magicRuntimeFitBonus and its penalty are how "there is an hour before bed" gets a + // different answer from "it is Saturday afternoon". Only applied when the television + // said how long it had. + magicRuntimeFitBonus = 0.5 + magicRuntimeOverPenalty = 1.2 + magicRuntimeSlackMinutes = 10 +) + +// MagicOptions are the request-scoped constraints on one press. +type MagicOptions struct { + // ExcludeIDs is what must not come back: the film playing right now, and whatever the + // last few presses produced. Repetition protection lives with the caller because it is + // the caller that knows what it already offered. + ExcludeIDs []string + // AvailableMinutes is zero for no limit. + AvailableMinutes int + // Roll is the randomness, in [0,1). Injected rather than read from a global so the + // draw is deterministic under test — and so that the *only* non-deterministic thing + // about this feature sits in one named parameter. + Roll float64 + // Now is injectable for the same reason. + Now time.Time +} + +// MagicSelection is one drawn title with its evidence. +type MagicSelection struct { + Item Item + // Reasons is viewer-facing wording from the same explanation layer a detail page uses. + Reasons []string + // Signals is why this title was *eligible*, in machine-readable slugs, so the choice + // can be reported and the weighting improved later. Not shown to anybody. + Signals []string + // Score is what it scored, and PoolSize how many it was drawn from. Both are recorded + // rather than displayed: a pool of one is a household that has run out of library, and + // that is a different problem from a bad weighting. + Score float64 + PoolSize int +} + +// MagicPick gathers the signals and draws. Errors only when the profile cannot be built at +// all and the catalogue is empty with it — every lesser failure degrades, on the principle +// [Engine.RelatedTo] already applies: a button that sometimes does nothing is worse than one +// that occasionally picks less well. +func (e *Engine) MagicPick( + ctx context.Context, + cred emby.Credentials, + opts MagicOptions, +) (MagicSelection, bool) { + if opts.Now.IsZero() { + opts.Now = e.now() + } + + history, favorites, err := e.gatherSignals(ctx, cred) + if err != nil { + // A profile that cannot be built costs the weighting, not the button. What is left + // is an unweighted draw over the catalogue, which is still "put something on". + e.log.Warn("magic signals unavailable; drawing without taste", "error", err) + } + profile := BuildProfile(history, favorites) + + candidates := e.magicCandidates(ctx, cred, profile) + if len(candidates) == 0 { + return MagicSelection{}, false + } + + selection, ok := ChooseMagic(profile, candidates, opts) + if !ok { + return MagicSelection{}, false + } + selection.Reasons = Why(profile, selection.Item, 2) + return selection, true +} + +// magicCandidates prefers the imported catalogue, which costs Postgres one read rather than +// costing Emby a library scan per press, and falls back to Emby where there is no library. +// Both paths ask for films only: the button plays something immediately, and a series is a +// question about which episode. +func (e *Engine) magicCandidates( + ctx context.Context, cred emby.Credentials, profile Profile, +) []Item { + // A cold profile has no genres to ask the catalogue for, and LibraryCandidates answers + // nothing when asked for none — so that case goes to Emby, which can list a library + // without being told what the viewer likes. + if genres := profile.TopGenres(12); e.Library != nil && len(genres) > 0 { + if raws, libErr := e.Library.LibraryCandidates(ctx, genres, MagicPoolLimit*8); libErr == nil { + if movies := onlyMovies(Decode(raws)); len(movies) > 0 { + return movies + } + } else { + e.log.Warn("magic library candidates failed; falling back to emby", "error", libErr) + } + } + + // Not filtered to unplayed: a rewatch is a legitimate Magic answer and the scoring is + // what decides between them. Sorted by rating so a truncated read keeps the better + // half of the library rather than the alphabetical front of it. + result, err := e.source.Items(ctx, cred, url.Values{ + "IncludeItemTypes": {"Movie"}, + "Recursive": {"true"}, + "SortBy": {"CommunityRating"}, + "SortOrder": {"Descending"}, + "Limit": {strconv.Itoa(MagicPoolLimit * 8)}, + "Fields": {candidateFields}, + "ImageTypeLimit": {"1"}, + "EnableImages": {"true"}, + "EnableImageTypes": {rowImageTypes}, + "EnableUserData": {"true"}, + }) + if err != nil { + e.log.Warn("magic emby candidates failed", "error", err) + return nil + } + return onlyMovies(Decode(result.Items)) +} + +func onlyMovies(items []Item) []Item { + out := make([]Item, 0, len(items)) + for _, item := range items { + if strings.EqualFold(item.Type, "Movie") && item.ID != "" { + out = append(out, item) + } + } + return out +} + +// ChooseMagic is the draw, and it is pure so the weighting can be argued with in a test +// rather than on a television. +// +// Ranking then taking the top is what a row does and is exactly wrong here: the same +// household would get the same film every night, which is the one outcome the button cannot +// have. Ranking then *drawing from the ranking* keeps merit deciding which titles are in the +// hat and how many tickets each holds, while leaving the answer genuinely unpredictable. +func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSelection, bool) { + excluded := map[string]bool{} + for _, id := range opts.ExcludeIDs { + if id = strings.TrimSpace(id); id != "" { + excluded[id] = true + } + } + + type scored struct { + item Item + score float64 + signals []string + } + pool := make([]scored, 0, len(candidates)) + seen := map[string]bool{} + for _, candidate := range candidates { + if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] { + continue + } + seen[candidate.ID] = true + score, signals := magicScore(profile, candidate, opts) + pool = append(pool, scored{item: candidate, score: score, signals: signals}) + } + if len(pool) == 0 { + return MagicSelection{}, false + } + + sort.SliceStable(pool, func(i, j int) bool { + if pool[i].score != pool[j].score { + return pool[i].score > pool[j].score + } + // Ties break by id so the *pool* is reproducible even though the draw is not. + return pool[i].item.ID < pool[j].item.ID + }) + if len(pool) > MagicPoolLimit { + pool = pool[:MagicPoolLimit] + } + + // Tickets decay linearly with rank rather than by score, so a library whose scores + // happen to be bunched together still favours the front of the pool, and one with a + // runaway leader still gives the rest a real chance. + total := float64(len(pool)*(len(pool)+1)) / 2 + roll := opts.Roll + if roll < 0 || roll >= 1 || math.IsNaN(roll) { + roll = 0 + } + target := roll * total + var cumulative float64 + for index, entry := range pool { + cumulative += float64(len(pool) - index) + if target < cumulative { + return MagicSelection{ + Item: entry.item, + Signals: entry.signals, + Score: entry.score, + PoolSize: len(pool), + }, true + } + } + last := pool[len(pool)-1] + return MagicSelection{ + Item: last.item, + Signals: last.signals, + Score: last.score, + PoolSize: len(pool), + }, true +} + +// magicScore sums the stated terms and reports which of them fired. The signals are the +// point of returning two values: a weighting nobody can see the workings of is a weighting +// nobody can improve. +func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []string) { + signals := make([]string, 0, 6) + score := profile.Affinity(item) + if score > 0 { + signals = append(signals, "taste") + } + + if profile.HasSeen(item) { + score -= magicSeenPenalty + signals = append(signals, "seen") + } else { + score += magicUnwatchedBonus + signals = append(signals, "unwatched") + } + + if item.UserData.IsFavorite { + score += magicFavouriteBonus + signals = append(signals, "favourite") + } + + if addedDays, ok := daysSince(item.DateCreated, opts.Now); ok && addedDays <= magicRecentlyAddedDays { + score += magicRecentlyAddedBonus + signals = append(signals, "recently_added") + } + + if opts.AvailableMinutes > 0 { + switch runtime := item.RuntimeMinutes(); { + case runtime <= 0: + // Nothing recorded is not evidence either way, and refusing to draw it would + // quietly delete a slice of the library from the feature. + case runtime > opts.AvailableMinutes+magicRuntimeSlackMinutes: + score -= magicRuntimeOverPenalty + signals = append(signals, "too_long") + default: + score += magicRuntimeFitBonus + signals = append(signals, "fits_time") + } + } + + return score, signals +} + +// daysSince reads Emby's ISO-8601 DateCreated. A field that is absent or unreadable is not +// an error: it simply cannot earn the recently-added bonus. +func daysSince(value string, now time.Time) (int, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + created, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, false + } + if created.After(now) { + return 0, true + } + return int(now.Sub(created).Hours() / 24), true +} diff --git a/server/internal/recommend/profile.go b/server/internal/recommend/profile.go index d0403ca..6f1f265 100644 --- a/server/internal/recommend/profile.go +++ b/server/internal/recommend/profile.go @@ -262,21 +262,40 @@ func (p Profile) TopGenres(n int) []string { return out } -// Score rates a candidate against the profile. A negative score means "exclude". -func (p Profile) Score(candidate Item) float64 { +// HasSeen reports whether this viewer has already watched or begun the candidate, by any +// of the four things that can say so: the item itself, the series it belongs to, another +// copy of the same title, and Emby's own user data on the payload. +// +// It is the veto half of [Score], separated because one caller needs the two halves apart: +// the Magic pick treats "already seen" as a heavy penalty rather than an exclusion, since a +// library whose owner has watched most of it must still be able to answer "put something +// good on". +func (p Profile) HasSeen(candidate Item) bool { if p.Seen[candidate.ID] { - return -1 + return true } if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] { - return -1 + return true } if p.SeenTitles[candidate.SeenKey()] { - return -1 - } - if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 { - return -1 + return true } + return candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 +} +// Score rates a candidate against the profile. A negative score means "exclude". +func (p Profile) Score(candidate Item) float64 { + if p.HasSeen(candidate) { + return -1 + } + return p.Affinity(candidate) +} + +// Affinity is what [Score] measures once the candidate has passed the seen veto: genre and +// studio weight, a mild quality nudge and a small bonus for a recent production. Never +// negative for a plausible candidate, which is what makes it usable as one term of a larger +// sum rather than only as a verdict. +func (p Profile) Affinity(candidate Item) float64 { var genreScore float64 for _, genre := range candidate.Genres { genreScore += p.GenreWeights[strings.TrimSpace(genre)] diff --git a/server/internal/store/credits.go b/server/internal/store/credits.go index a3c93c7..fa7eba9 100644 --- a/server/internal/store/credits.go +++ b/server/internal/store/credits.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -9,12 +10,159 @@ import ( "github.com/jackc/pgx/v5" ) +const CreditsSettingsKey = "credits_settings" + +// CreditsSettings is the durable operator tuning for candidate generation. Environment +// values remain the first-run defaults; this row exists only after the console saves an +// explicit choice. +type CreditsSettings struct { + CandidateLimit int `json:"candidateLimit"` + PrefetchEpisodes int `json:"prefetchEpisodes"` + MaxPrefetch int `json:"maxPrefetch"` + RetryHours int `json:"retryHours"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (s *Store) CreditsSettings(ctx context.Context) (CreditsSettings, bool, error) { + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, CreditsSettingsKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return CreditsSettings{}, false, nil + } + if err != nil { + return CreditsSettings{}, false, fmt.Errorf("store: read credits settings: %w", err) + } + var settings CreditsSettings + if err := json.Unmarshal(raw, &settings); err != nil { + return CreditsSettings{}, false, fmt.Errorf("store: decode credits settings: %w", err) + } + return settings, true, nil +} + +func (s *Store) SetCreditsSettings(ctx context.Context, settings CreditsSettings) error { + settings.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(settings) + 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()`, + CreditsSettingsKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write credits settings: %w", err) + } + return nil +} + +// CreditsScanHistoryRow is one completed scan, enriched with current library names for the +// console. Names are not copied into the history table, so metadata corrections appear here. +type CreditsScanHistoryRow struct { + ID int64 `json:"id"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + SeriesName string `json:"seriesName"` + SeriesID string `json:"seriesId"` + Season int `json:"season"` + Episode int `json:"episode"` + Reason string `json:"reason"` + Priority int `json:"priority"` + Outcome string `json:"outcome"` + MarkerMs int64 `json:"markerMs"` + Confidence float64 `json:"confidence"` + Method string `json:"method"` + Frames int `json:"frames"` + Error string `json:"error"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + DurationMs int64 `json:"durationMs"` +} + +func (s *Store) SaveCreditsScanAttempt(ctx context.Context, row CreditsScanHistoryRow) error { + duration := row.FinishedAt.Sub(row.StartedAt).Milliseconds() + if duration < 0 { + duration = 0 + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO credits_scan_history ( + item_id, series_id, season_number, episode_number, reason, priority, outcome, + marker_ms, confidence, method, frames_sampled, error_text, + started_at, finished_at, duration_ms + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, + row.ItemID, row.SeriesID, row.Season, row.Episode, row.Reason, row.Priority, + row.Outcome, row.MarkerMs, row.Confidence, row.Method, row.Frames, row.Error, + row.StartedAt, row.FinishedAt, duration) + if err != nil { + return fmt.Errorf("store: save credits scan history: %w", err) + } + return nil +} + +func (s *Store) RecentCreditsScanTimes( + ctx context.Context, itemIDs []string, since time.Time, +) (map[string]time.Time, error) { + out := map[string]time.Time{} + if len(itemIDs) == 0 { + return out, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT item_id, max(finished_at) + FROM credits_scan_history + WHERE item_id = ANY($1) AND finished_at >= $2 + AND outcome IN ('no_match', 'failed') + GROUP BY item_id`, itemIDs, since.UTC()) + if err != nil { + return nil, fmt.Errorf("store: recent credits scans: %w", err) + } + defer rows.Close() + for rows.Next() { + var itemID string + var finished time.Time + if err := rows.Scan(&itemID, &finished); err != nil { + return nil, err + } + out[itemID] = finished + } + return out, rows.Err() +} + +func (s *Store) CreditsScanHistory(ctx context.Context, limit int) ([]CreditsScanHistoryRow, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.pool.Query(ctx, ` + SELECT h.id, h.item_id, + coalesce(i.payload->>'Name', ''), coalesce(i.payload->>'SeriesName', ''), + h.series_id, h.season_number, h.episode_number, h.reason, h.priority, + h.outcome, h.marker_ms, h.confidence, h.method, h.frames_sampled, + h.error_text, h.started_at, h.finished_at, h.duration_ms + FROM credits_scan_history h + LEFT JOIN library_items i ON i.id = h.item_id + ORDER BY h.finished_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("store: credits scan history: %w", err) + } + defer rows.Close() + out := make([]CreditsScanHistoryRow, 0, limit) + for rows.Next() { + var row CreditsScanHistoryRow + if err := rows.Scan(&row.ID, &row.ItemID, &row.ItemName, &row.SeriesName, + &row.SeriesID, &row.Season, &row.Episode, &row.Reason, &row.Priority, + &row.Outcome, &row.MarkerMs, &row.Confidence, &row.Method, &row.Frames, + &row.Error, &row.StartedAt, &row.FinishedAt, &row.DurationMs); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + // The database half of credits marking. // -// Four queries, and the shape of each one is chosen to keep the promise the subsystem makes -// about database activity: a settled household reads one indexed row per candidate and -// writes nothing at all. Nothing here is written per candidate, per queue transition or per -// scan attempt — only a finished marker. +// Marker queries keep the playback path cheap. Scan history is operational data on the +// background worker path only; queue transitions remain entirely in memory. // CreditsMarkerRow is one stored marker. type CreditsMarkerRow struct { diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index 4a140fd..a891373 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -740,3 +740,29 @@ CREATE TABLE IF NOT EXISTS credits_markers ( CREATE INDEX IF NOT EXISTS credits_markers_season_idx ON credits_markers (series_id, season_number, confidence DESC) WHERE series_id <> ''; + +-- Operational history for credits scans. This is deliberately separate from queue state: +-- the queue remains disposable, while completed attempts explain repeated candidates and +-- provide the cooldown that stops an inconclusive episode being scanned every ten minutes. +CREATE TABLE IF NOT EXISTS credits_scan_history ( + id BIGSERIAL PRIMARY KEY, + item_id TEXT NOT NULL, + series_id TEXT NOT NULL DEFAULT '', + season_number INT NOT NULL DEFAULT 0, + episode_number INT NOT NULL DEFAULT 0, + reason TEXT NOT NULL DEFAULT '', + priority INT NOT NULL DEFAULT 0, + outcome TEXT NOT NULL, + marker_ms BIGINT NOT NULL DEFAULT 0, + confidence REAL NOT NULL DEFAULT 0, + method TEXT NOT NULL DEFAULT '', + frames_sampled INT NOT NULL DEFAULT 0, + error_text TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ NOT NULL, + duration_ms BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS credits_scan_history_item_time_idx + ON credits_scan_history (item_id, finished_at DESC); +CREATE INDEX IF NOT EXISTS credits_scan_history_time_idx + ON credits_scan_history (finished_at DESC);