diff --git a/.env.example b/.env.example index 1593474..47314e0 100644 --- a/.env.example +++ b/.env.example @@ -66,7 +66,11 @@ MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd MEMBY_PUBLIC_URL=https://mserver.sublogue.com MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets -# Library import. Hourly incremental keeps up with episodes added through the day. +# Catalogue sweep. With both *arr webhooks below configured this is reconciliation for +# media Sonarr and Radarr do not manage — a file dropped in by hand, a title edited in +# Emby, a notification that never arrived — so it can be lengthened to 6h. Without them +# it is the only way a new title is ever found and must stay frequent. The console can +# override it at Settings > Catalogue sweep without a redeployment. MEMBY_SYNC_INTERVAL=1h MEMBY_SYNC_ON_START=false @@ -81,9 +85,20 @@ MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075 MEMBY_SONARR_URL=http://10.0.0.2:8989 MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a MEMBY_SONARR_TTL=5m -# How long after an episode airs the "aired, coming soon" banner keeps being offered. -# 0 turns the banners off and leaves the airing-today row alone. +# How long an episode banner keeps being offered — both the "aired, coming soon" one and +# the "new episode added" one a finished scan produces. 0 turns both off and leaves the +# airing-today row alone. MEMBY_SONARR_ALERT_WINDOW=3h +# Shared secret for Sonarr's webhook, which is how the catalogue learns that an episode +# landed rather than waiting for the next sweep. In Sonarr: Settings > Connect > + > +# Webhook, with On Import, On Upgrade, On Rename, On Episode File Delete and On Series +# Delete ticked, URL https:///hooks/sonarr?token=. Empty +# makes the hook 404. +MEMBY_SONARR_WEBHOOK_TOKEN=09f091f0f2ee8af3cba1aac29557e344 +# How long after a webhook the gateway first looks for the file in Emby. Sonarr and +# Radarr fire the moment they have moved the file into place and Emby has not scanned it +# yet, so asking immediately spends a request to learn nothing. +MEMBY_ARR_INGEST_SETTLE=1m # Optional Radarr calendar integration. Upcoming movies cover the coming month, ordered by # Radarr's digital release date. A film with no digital date yet is estimated at its cinema @@ -91,12 +106,15 @@ MEMBY_SONARR_ALERT_WINDOW=3h MEMBY_RADARR_URL=http://10.0.0.2:7878 MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5 MEMBY_RADARR_TTL=5m -# Shared secret for Radarr's "On Import" webhook, which announces a newly added film on -# every TV that is awake. In Radarr: Settings > Connect > + > Webhook, On Import only, -# URL https:///hooks/radarr?token=. Empty makes the hook 404. +# Shared secret for Radarr's webhook, which is how the catalogue learns that a film landed +# rather than waiting for the next sweep. In Radarr: Settings > Connect > + > Webhook, with +# On Import, On Upgrade, On Rename, On Movie File Delete and On Movie Delete ticked, URL +# https:///hooks/radarr?token=. Empty makes the hook 404. +# The catalogue reads all five; the banner is announced once the film has actually scanned +# in, and only for an import — an upgrade replaced a film that was already there. MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758 -# How long an imported film keeps being announced, so a TV switched on shortly after the -# import still hears about it. 0 turns the banners off. +# How long a newly scanned film keeps being announced, so a TV switched on shortly after +# the import still hears about it. 0 turns the banners off. MEMBY_RADARR_ALERT_WINDOW=3h # Optional Bazarr integration, one of the two providers a viewer can fetch a missing diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aac55f..5b8dfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.2.75 - 2026-08-18 +- Chore: Upgrade player dependencies. + ## 0.2.74 — 2026-08-17 - Fixed: Genre shelves were showing films only. A programme whose details came from TMDb carries its genre as one label — "Sci-Fi & Fantasy", "Action & Adventure", "War & Politics" — which the shelves did not recognise, so every show was missing from Sci-Fi & Fantasy, Action & Adventure and War & History. Shows now appear on them beside the films. - Improved: Genre shelves recognise more ways of spelling the same genre, including the hyphenated names IMDb uses, so fewer titles are missed. Sport finds more than it did, though a film your server has not tagged with a sport genre at all still cannot appear there. diff --git a/CLAUDE.md b/CLAUDE.md index e1fc92e..4e16569 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,6 +315,56 @@ household, so watched/favourite/resume state must never be cached there and stil from Emby live. A full import mark-and-sweeps on `synced_at`; incremental uses `MinDateLastSaved` with a minute of overlap. +**But asking Emby is no longer how the gateway finds out.** Sonarr and Radarr are the +things that put files on disk, so they are what the catalogue learns from: both post to +`/hooks/sonarr` and `/hooks/radarr`, `internal/library/events.go` turns a notification into +a piece of work, and `ingest.go`'s single worker reads that one title out of Emby a minute +later. An episode imported at 19:05 is searchable at 19:06 rather than as late as 20:00, +and the hourly sweep becomes reconciliation for what the *arrs do not manage — a file +dropped in by hand, a title edited in Emby, a webhook that arrived while the container was +down. Things to preserve: + +- **`library_ingest_queue` is durable, and it is the only queue in the schema that is.** A + Tracearr-derived credits candidate is rebuilt from one query on restart; "Sonarr imported + this at 19:05" cannot be rederived from anything, so a container restarted during the + settle delay must still read the file. The key names the **file** rather than the + delivery, which is what makes `ON CONFLICT` the whole of the repeat-delivery defence — + both *arrs re-notify on retry — while a file deleted and re-imported is a different file + and its own work. +- **Both hooks sit outside the quiet-time gate**, which the Radarr one previously sat + inside. That gate answers 503 and neither *arr re-delivers, so a quiet hour silently + discarded every import that happened during it. The hook records at any hour and the + *worker* is where quiet time is honoured — which is only possible because the queue is + durable. +- **An upgrade is silent as news and still refreshes the row.** Those are two judgements + made in two places: `AnnounceLibraryIngest` refuses to announce anything that is not + `ReasonImport` — the title was already there — and the catalogue re-reads it because the + file genuinely changed. A rename is a + refresh and never an invalidation — the Emby item id survives a move, and so does the + credits marker measured against it. A delete only counts when the media went with it: a + series unfollowed in Sonarr with its files left on disk is still in the library. +- **Emby not having scanned yet is the expected first answer**, not a fault. One + `RefreshItem` nudge at the parent, then a widening backoff (`IngestRetryDelay`) out to an + attempt limit — because past the last step the cause is not timing, and a row retrying for + ever is one nobody looks at. +- **The lookup asks for `syncFields`**, the scheduled import's own set, for the reason + `Syncer.Find` does: a thinner query leaves an event-imported title without People, + MediaStreams or ProviderIds — no cast, no ratings lookup, no format badges — until Emby + next reports it changed, which for a film nobody edits again is never. +- **A refresh resolves through Emby; a delete resolves through the catalogue.** That + asymmetry is deliberate. The local series index answers for every show ever imported and + Emby is asked only when it misses, which is exactly the case the feature exists for — a + brand-new show whose first episode has just landed, whose series row is then written + beside its episode. A delete is the other way round because the file is gone and Emby is + the least likely thing to still be able to name it. +- **The sweep interval is an operator override** (`librarySyncMinutes`, the + `embyHealthInterval` pattern) and `Syncer.Schedule` takes a *function* rather than a + value, because a setting read once at start-up is not a setting: lengthening the sweep to + 6h after wiring the webhooks up must not need a restart. +- **`store.DeleteLibraryItem` takes the credits marker with the row.** `credits_markers` is + keyed on the item id and nothing else prunes it, so a deleted title would otherwise leave + a Skip Credits position behind for a file that no longer exists. + **External ratings** (MDBList) are bought by the day, not by the request, so the design question is never "how fast can we fetch" but "how few times must we ever ask". The answer is that a title is fetched once and kept: `external_media_ratings` holds the raw provider @@ -365,12 +415,41 @@ window closes — a list rather than a push because the gateway holds no connect television, and a window is what lets a set that was off or in the screensaver at the time still hear the news. Four publishers today: -- `api/radarr_alerts.go` — a film Radarr just imported. `POST /hooks/radarr` is the "On - Import" webhook and the one thing that pushes *into* the gateway, guarded by - `MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted - outside both the auth middleware and the maintenance gate, because an event dropped - during maintenance is lost rather than delayed. A quality upgrade is deliberately - silent: the film was already there. +- `AnnounceLibraryIngest` in `api/ingest_alerts.go` — a film or an episode whose scan has + **finished**. `POST /hooks/radarr` and `POST /hooks/sonarr` are what push *into* the + gateway, guarded by `MEMBY_RADARR_WEBHOOK_TOKEN` / `MEMBY_SONARR_WEBHOOK_TOKEN` (unset + ⇒ 404, the stance `/admin` takes) and mounted outside both the auth middleware and the + maintenance gate, because an event dropped during maintenance is lost rather than + delayed — but the *hook* no longer announces anything. It records, and the announcement + is hung off `Ingester.Announce` in `main.go`, the `SetAfterSync` arrangement, so + `library` stays ignorant of what an alert is. Things to preserve: + - **The webhook is not the news.** Both *arrs fire the moment they have moved a file and + Emby has not scanned it in yet, which is why the banner published from the hook could + only ever promise a film would be available "shortly" and why an episode could not be + announced at all — there was nothing true to say about one until it was there. Behind + the scan the banner says the title is **ready to watch**, and a title Emby never + manages to scan is never announced, which is the right way round. + - **A quality upgrade is still silent, and so are a rename and a delete.** Only + `ReasonImport` is news; the file genuinely changed, so the row is still re-read. That + judgement lives in `api` rather than in the worker: the worker's business is that the + row moved, this is the separate question of whether anybody should be told. + - **A season pack is one banner.** `ingestRuns` tallies a season's arrivals within + `ingestRunWindow` and every later one replaces the same alert, because the id is + anchored on the run's **first** episode. The anchor is what makes both halves work: + within the window a burst collapses, and next week's episode — arriving after it has + closed — starts a run of its own rather than reusing an id every television in the + house has already dismissed as seen. Bounded and lossy in memory, the `playbackTitles` + arrangement; a gateway restarted mid-pack announces the rest as a second run. + - **One arrival is named, several are counted.** Naming the last of six would be + arbitrary — nothing makes it the one worth mentioning — where the count is what the + viewer wants. `episodeSummary` drops the episode title when Emby has recorded it as + the show's own name, since "S03E05 — The Bear" reads as a mistake. + - **Emby's names outrank the *arr's.** The result carries what was actually written to + the catalogue, so the banner and the card underneath it cannot name one thing two ways. + - **The two windows still switch their own half off**: films answer to + `MEMBY_RADARR_ALERT_WINDOW`, episodes to `MEMBY_SONARR_ALERT_WINDOW`. `sonarr-import` + is its own kind, distinct from `sonarr-aired` — one says an episode has been broadcast + and is *not* here, the other that it is. - `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in `main.go` — "24 titles added or updated". Only a run that *changed* something is announced; the import is scheduled, most passes find nothing, and an hourly "no news" @@ -2199,6 +2278,56 @@ which is the same as the feature not existing. The only switch is the operator's from its own clock that it is Halloween, while the household's gateway has seasons switched off, would be the feature failing rather than degrading. `data/Themes.kt` is only hex parsing, and it refuses anything it cannot read so the app's own token stands in. +**Icons are the server's answer too.** `ui/theme/MembyIcons.kt` is `DesignTokens.kt` for +marks: an enum of ~70 **slots** named for what they mean (`Search`, `Drama`, `Sparkle`), one +process-wide `mutableStateOf(MembyIconPack)`, and `applyMembyIconPack` to repaint. Before it +the app held seventy literal `Icons.Default.*` across nineteen files, which put icons exactly +where the palette was before its own work: unreachable from the gateway, because the server +can only change what the television has a slot for. The packs are +`ui/theme/MembyIconPack*.kt` — Material (what the app shipped with), Lucide and Font Awesome +Solid, from `com.composables:icons-*-cmp` — and the gateway names one with `iconSet` on the +theme document. Things to preserve: + +- **A slot is named for the job, never for the mark that fills it today.** Filing the + recommendation slot under `AutoAwesome` would describe Material's four stars, and a pack + whose answer is a wand would then sit under a name that lies about it. +- **Nothing may hold a resolved mark.** `MembyIcon.mark` reads process-wide state, so a mark + captured in an `enum` constant or a top-level `val` freezes whichever pack was loaded when + that class initialised — the same `val`-versus-`get()` trap that made `SettingsSheet` the + one screen a palette could never reach. `BrowseDestination`, `SettingsPage` and + `RequestCardAction` therefore carry the **slot** and resolve it where they draw. +- **The marks are lambdas, not vectors.** An `ImageVector` is built when it is first read, so + a map of them would build all seventy on the first frame that touched a pack — on the cold + start, which is the one thing in this app nothing may cost. +- **A pack may be partial, and an absent slot falls back to Material.** Lucide is stroke-only + and has no filled heart, so mapping `Favourite` and `FavouriteOutline` to one glyph would + make "this is a favourite" and "this is not" identical on screen — a pack must never cost + the app a distinction. Font Awesome Solid declines the outline halves for the same reason + from the other side. The mixture is small and is the honest answer. +- **The wire carries a slug and never geometry**, the line the palette already draws: a + gateway that could send paths could draw an unreadable rail, where the worst a pack slug + does is look unchanged. An unknown slug resolves to Material at *both* ends — + `membyIconPackFor` on the television and `knownIconPack` on the gateway, which refuses to + echo a pack nothing can draw. +- **It rides the theme revision**, so it costs no new field on the status poll and no second + sync loop: `ThemeSync` already refetches on a revision it does not hold. The slug is cached + beside the palette and applied *before* any request, and on its own evidence — a set whose + stored palette will not parse still opens wearing the marks it was told to wear. +- **Where it is chosen is the `iconSet` preference**, beside `themeId`, so an operator sets it + per viewer from the console's existing catalogue-driven editor and no admin page was needed. + A **season may replace the marks; a selectable theme may not** (`IconSet` on + `themeDefinition`, the `Decoration` shape) — a season is a look, where a scheme somebody + picked to live with all year taking their marks away leaves no way to tell which of the two + choices did it. +- **R8 is what makes three packs affordable.** Only the slots named in the maps survive out of + packs holding a thousand icons each: measured, two complete packs cost **+16 KB** on the + release APK. A slot nothing draws costs three vectors for nothing. +- **`IconPackScreenshotTest` is the only test that can judge this** (`build/screenshots/ + icon-packs/`), the point `ThemeScreenshotTest` makes about palettes. A unit test can check + that a pack names a mark for a slot; it cannot check whether that mark *means* the slot — + it is what caught Lucide's Action genre drawing an award ribbon. Both sizes are captured + because a stroke set has least to spare at the 21dp the rail draws. + **Seasonal decorations** are `ui/seasonal/SeasonalDecorations.kt`: snow, bats or blossom drifting over the launcher for the few days a season is on. A palette on its own is a thin idea of Christmas — the colours change and nothing says why — and this is the half that diff --git a/admin-ui/dist/assets/index-BmnCg8np.js b/admin-ui/dist/assets/index-BmnCg8np.js new file mode 100644 index 0000000..0d8daf3 --- /dev/null +++ b/admin-ui/dist/assets/index-BmnCg8np.js @@ -0,0 +1,11 @@ +import{r as p,a as hn,u as is,L as re,b as as,m as Xe,N as Ws,O as un,c as Ve,B as mn,R as pn,d as B,e as xn}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const a of o.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&i(a)}).observe(document,{childList:!0,subtree:!0});function t(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function i(r){if(r.ep)return;r.ep=!0;const o=t(r);fetch(r.href,o)}})();var Bs={exports:{}},He={};/** + * @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 jn=p,vn=Symbol.for("react.element"),gn=Symbol.for("react.fragment"),bn=Object.prototype.hasOwnProperty,fn=jn.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,yn={key:!0,ref:!0,__self:!0,__source:!0};function Vs(s,n,t){var i,r={},o=null,a=null;t!==void 0&&(o=""+t),n.key!==void 0&&(o=""+n.key),n.ref!==void 0&&(a=n.ref);for(i in n)bn.call(n,i)&&!yn.hasOwnProperty(i)&&(r[i]=n[i]);if(s&&s.defaultProps)for(i in n=s.defaultProps,n)r[i]===void 0&&(r[i]=n[i]);return{$$typeof:vn,type:s,key:o,ref:a,props:r,_owner:fn.current}}He.Fragment=gn;He.jsx=Vs;He.jsxs=Vs;Bs.exports=He;var e=Bs.exports,Hs,ms=hn;Hs=ms.createRoot,ms.hydrateRoot;const zs={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 Y({name:s,className:n}){const t=zs[s];return t?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:t})}):null}function ze({name:s,tone:n}){return zs[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(Y,{name:s})}):null}const Se=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"},{id:"credits",path:"/admin/credits",label:"Credits detection",title:"Credits detection",intro:"Control predictive scanning and review every completed credits scan.",icon:"clock"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time.",icon:"wrench"},{id:"gateway-settings",path:"/admin/settings",label:"Gateway settings",title:"Gateway settings",intro:"Timezone, logging and the other server-level settings for this gateway.",icon:"sliders",hidden:!0},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],wn=Se.flatMap(s=>s.items),kn=Se.flatMap(s=>s.items.filter(n=>!n.path.includes(":")).map(n=>({...n,group:s.label??""})));class ps extends Error{constructor(n,t){super(n),this.status=t,this.name="ApiError"}}const Nn=5*60*1e3;let Ks=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{Ks=Date.now()},{passive:!0});const Sn=()=>Date.now()-Ks({}));throw new ps(i.error??`Request failed (${t.status})`,t.status)}if(t.status!==204)return await t.json()}function qe(s){const n=new URLSearchParams;for(const[i,r]of Object.entries(s))r==null||r===""||r===!1||n.set(i,String(r));const t=n.toString();return t?`?${t}`:""}const F={get:s=>Pe(s),post:(s,n)=>Pe(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Pe(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Pe(s,{method:"DELETE"})},Mn=3e4,Gs=p.createContext(null);function En({children:s}){var h;const[n,t]=p.useState(),[i,r]=p.useState(!1),[o,a]=p.useState(""),[c,d]=p.useState(""),[l,j]=p.useState(!0),v=p.useRef(0),u=p.useCallback(async()=>{const g=++v.current;try{const b=await F.get("/admin/api/status");if(g!==v.current)return;t(b),r(!0),d("")}catch(b){if(g!==v.current)return;r(!1),d(b instanceof Error?b.message:String(b))}finally{g===v.current&&(a(new Date().toISOString()),j(!1))}},[]),m=p.useCallback(async g=>{var b;await F.post("/admin/api/maintenance",{enabled:g,message:((b=n==null?void 0:n.maintenance)==null?void 0:b.message)??""}),await u()},[u,(h=n==null?void 0:n.maintenance)==null?void 0:h.message]);p.useEffect(()=>{u();let g;const b=()=>{window.clearInterval(g),g=document.hidden?void 0:window.setInterval(()=>void u(),Mn)},k=()=>{b(),document.hidden||u()};return b(),document.addEventListener("visibilitychange",k),()=>{window.clearInterval(g),document.removeEventListener("visibilitychange",k)}},[u]);const f=p.useMemo(()=>{var g;return{status:n,version:(n==null?void 0:n.serverVersion)??"",currentUser:((g=n==null?void 0:n.currentUser)==null?void 0:g.trim())||"Administrator",online:i,checkedAt:o,error:c,loading:l,reload:u,setMaintenance:m}},[n,i,o,c,l,u,m]);return e.jsx(Gs.Provider,{value:f,children:s})}function oe(){const s=p.useContext(Gs);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function An(s,n){const t=s.label.toLowerCase(),i=s.group.toLowerCase();return t.startsWith(n)?4:t.includes(n)?3:i.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function Rn(){const s=is(),{status:n}=oe(),[t,i]=p.useState(!1),[r,o]=p.useState(""),[a,c]=p.useState(0),d=p.useRef(null),l=p.useRef(null),j=p.useMemo(()=>{const u=r.trim().toLowerCase();return[...kn,...((n==null?void 0:n.requestUsers)??[]).map(f=>({id:`user-${f.id}`,path:`/admin/accounts/${encodeURIComponent(f.id)}`,label:f.username||"Unnamed user",title:`User: ${f.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(f=>({id:`device-${f.deviceId}`,path:`/admin/devices/${encodeURIComponent(f.deviceId)}`,label:f.deviceName||"Unnamed device",title:`Device: ${f.deviceName||"Unnamed device"}`,intro:`${f.username||"Unknown user"} · ${f.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((f,h)=>({item:f,rank:u?An(f,u):1,index:h})).filter(f=>f.rank>0).sort((f,h)=>h.rank-f.rank||f.index-h.index).map(({item:f,rank:h})=>({item:f,rank:h}))},[r,n]);p.useEffect(()=>c(0),[r]),p.useEffect(()=>{const u=m=>{var f;(f=d.current)!=null&&f.contains(m.target)||i(!1)};return document.addEventListener("pointerdown",u),()=>document.removeEventListener("pointerdown",u)},[]),p.useEffect(()=>{const u=m=>{var h,g;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(m.preventDefault(),(h=l.current)==null||h.focus(),(g=l.current)==null||g.select())};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[]);const v=u=>{var m;i(!1),o(""),(m=l.current)==null||m.blur(),s(u)};return e.jsxs("div",{className:"omni",ref:d,"data-open":t||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(Y,{name:"search"}),e.jsx("input",{ref:l,type:"search",value:r,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":t,onFocus:()=>i(!0),onChange:u=>{o(u.target.value),i(!0)},onKeyDown:u=>{var m;if(u.key==="Escape")o(""),i(!1),(m=l.current)==null||m.blur();else if(u.key==="ArrowDown")u.preventDefault(),c(f=>Math.min(f+1,j.length-1));else if(u.key==="ArrowUp")u.preventDefault(),c(f=>Math.max(f-1,0));else if(u.key==="Enter"){const f=j[a];if(!f)return;u.preventDefault(),v(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),t?e.jsx("div",{className:"omni-panel",role:"listbox",children:j.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):j.map((u,m)=>e.jsxs("a",{className:m===a?"omni-item on":"omni-item",href:u.item.path,role:"option","aria-selected":m===a,onPointerEnter:()=>c(m),onClick:f=>{f.preventDefault(),v(u.item.path)},children:[u.item.icon?e.jsx(Y,{name:u.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:u.item.label}),e.jsx("small",{children:u.item.intro})]}),e.jsx("span",{className:"omni-group",children:u.item.group})]},u.item.id))}):null]})}const w=s=>(s??0).toLocaleString(),P=s=>s?new Date(s).toLocaleString():"—";function 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 t=Math.floor(n/60);return t<60?`${t}m ${n%60}s`:`${Math.floor(t/60)}h ${t%60}m`}function Ce(s){const n=Math.round(Math.max(0,s??0)/6e4);if(n<=0)return"none";if(n<60)return`${n} min`;const t=Math.floor(n/60),i=n%60;return i===0?t===1?"1 hour":`${t} hours`:`${t}h ${i}m`}function es(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 t=Math.round(n/60);return t<48?t===1?"hourly":`every ${t} hours`:`every ${Math.round(t/24)} days`}function $e(s){const n=["B","KB","MB","GB"];let t=Number(s??0),i=0;for(;t>=1024&&iString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),We=s=>`${Math.round((s??0)*100)}%`;function je(s){if(!s)return"—";const n=Date.now()-new Date(s).getTime();if(n<0)return"just now";const t=Math.floor(n/1e3);if(t<45)return"just now";const i=Math.floor(t/60);if(i<60)return`${i} min ago`;const r=Math.floor(i/60);if(r<24)return`${r}h ago`;const o=Math.floor(r/24);return o<30?`${o}d ago`:new Date(s).toLocaleDateString()}const Js=15*60*1e3,Tn=3*60*60*1e3,Le=s=>!!s&&Date.now()-new Date(s).getTime(){var b;try{const k=await F.get(`/admin/api/notifications?limit=${js}`);t(k.events),r(k.unread),a(k.types),v.current=Math.max(v.current,((b=k.events[0])==null?void 0:b.id)??0),j("")}catch(k){j(k instanceof Error?k.message:String(k))}},[]),m=p.useCallback(b=>{v.current=Math.max(v.current,b.id),t(k=>k.some(x=>x.id===b.id)?k:[b,...k].sort((x,y)=>y.id-x.id).slice(0,js)),b.readAt||r(k=>k+1),a(k=>k.some(x=>x.type===b.type)?k.map(x=>x.type===b.type?{...x,count:x.count+1}:x):[...k,{type:b.type,count:1}])},[]);p.useEffect(()=>{u()},[u]),p.useEffect(()=>{let b=null,k,x=!1;return(()=>{x||(b=new EventSource(`/admin/api/notifications/stream?after=${v.current}`),b.addEventListener("open",()=>{d(!0),window.clearInterval(k),k=void 0}),b.addEventListener("admin",R=>{try{m(JSON.parse(R.data))}catch{}}),b.addEventListener("error",()=>{d(!1),k===void 0&&(k=window.setInterval(()=>void u(),$n))}))})(),()=>{x=!0,b==null||b.close(),window.clearInterval(k)}},[m,u]);const f=p.useCallback(async b=>{const k=b.filter(x=>x>0);if(k.length!==0){t(x=>x.map(y=>k.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const x=await F.post("/admin/api/notifications/read",{ids:k});r(x.unread)}catch{u()}}},[u]),h=p.useCallback(async()=>{r(0),t(b=>b.map(k=>k.readAt?k:{...k,readAt:new Date().toISOString()}));try{const b=await F.post("/admin/api/notifications/read",{all:!0});r(b.unread)}catch{u()}},[u]),g=p.useMemo(()=>({events:n,unread:i,types:o,connected:c,error:l,markRead:f,markAllRead:h,reload:u}),[n,i,o,c,l,f,h,u]);return e.jsx(Ys.Provider,{value:g,children:s})}function ls(){const s=p.useContext(Ys);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function Qs(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function Xs(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 vs(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 gs=99;function Ln(){const{events:s,unread:n,connected:t,markRead:i,markAllRead:r}=ls(),[o,a]=p.useState(!1),c=p.useRef(null);return p.useEffect(()=>{const d=j=>{var v;(v=c.current)!=null&&v.contains(j.target)||a(!1)},l=j=>{j.key==="Escape"&&a(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",l),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",l)}},[]),p.useEffect(()=>{if(!o)return;const d=s.filter(l=>!l.readAt).map(l=>l.id);d.length>0&&i(d)},[o]),e.jsxs("div",{className:"bell",ref:c,"data-open":o||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":o,onClick:()=>a(d=>!d),children:[e.jsx(Y,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>gs?`${gs}+`:n}):null]}),o?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:[t?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void r(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(d=>{const l=e.jsxs(e.Fragment,{children:[e.jsx(ze,{name:Xs(d.type),tone:Qs(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:je(d.occurredAt)})]})]});return d.link?e.jsx(re,{className:"bell-item","data-unread":!d.readAt||void 0,to:d.link,onClick:()=>a(!1),children:l},d.id):e.jsx("div",{className:"bell-item","data-unread":!d.readAt||void 0,children:l},d.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(re,{to:"/admin/activity",onClick:()=>a(!1),children:"All activity"})})]}):null]})}function W({title:s,intro:n,actions:t,crumbs:i,icon:r}){var c;const o=as(),a=r??((c=wn.find(d=>Xe({path:d.path,end:!0},o.pathname)))==null?void 0:c.icon);return e.jsxs("header",{className:"page-head",children:[i?e.jsx("nav",{className:"crumbs",children:i}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[a?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(Y,{name:a})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),t?e.jsx("div",{className:"page-head-actions",children:t}):null]})]})}function T({title:s,intro:n,icon:t,tone:i,actions:r,footer:o,children:a}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[t?e.jsx(ze,{name:t,tone:i}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),r?e.jsx("div",{className:"card-head-actions",children:r}):null]}):null,a,o?e.jsx("div",{className:"card-foot",children:o}):null]})}function he({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function le({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(ze,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function M({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function ae({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function Q({children:s}){return e.jsx("p",{className:"empty",children:s})}function X({columns:s,children:n}){return e.jsx("tr",{children:e.jsx("td",{colSpan:s,className:"muted",children:e.jsx("p",{className:"empty",children:n})})})}function fe({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function $({children:s,onClick:n,variant:t,size:i,disabled:r,busy:o,icon:a,type:c="button",title:d}){return e.jsxs("button",{type:c,className:"","data-variant":t,"data-size":i,disabled:r||o,onClick:n,title:d,children:[o?e.jsx("span",{className:"spinner"}):a?e.jsx(Y,{name:a}):null,s]})}function q({label:s,hint:n,children:t,grow:i}){return e.jsxs("label",{className:i?"field grow":"field",children:[e.jsx("span",{children:s}),t,n?e.jsx("small",{children:n}):null]})}function z({label:s,hint:n,checked:t,onChange:i,disabled:r}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:t,disabled:r,onChange:o=>i(o.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 Be({value:s,options:n,onChange:t}){return e.jsx("div",{className:"segments",role:"group",children:n.map(i=>e.jsx("button",{type:"button","aria-pressed":i.value===s,onClick:()=>t(i.value),children:i.label},String(i.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function en({data:s,labelOf:n,valueOf:t,toneOf:i,title:r}){if(s.length===0)return e.jsx(Q,{children:"Nothing in this window."});const o=s.map(c=>t(c)),a=Math.max(1,...o);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((c,d)=>{const l=t(c);return e.jsx("div",{className:"bar","data-tone":i==null?void 0:i(c),"data-empty":l===0||void 0,style:{height:`${Math.max(2,l/a*100)}%`},title:r?r(c):`${n(c,d)}: ${l}`},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 qn({value:s,total:n,tone:t}){const i=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":t,children:e.jsx("div",{style:{width:`${i*100}%`}})})}function U({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(Y,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(Y,{name:"close"})}):null]}):null}function V({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,t)=>e.jsx("span",{className:"skeleton"},t))}),Array.from({length:s},(n,t)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},t))]})}function xe({title:s,body:n,confirmLabel:t="Confirm",destructive:i,busy:r,onConfirm:o,onCancel:a}){const c=p.useId(),d=p.useRef(null);return p.useEffect(()=>{var j;(j=d.current)==null||j.focus();const l=v=>{v.key==="Escape"&&a()};return document.addEventListener("keydown",l),()=>document.removeEventListener("keydown",l)},[a]),e.jsx("div",{className:"scrim",onPointerDown:l=>l.target===l.currentTarget&&a(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":c,ref:d,tabIndex:-1,children:[e.jsx("h2",{id:c,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{onClick:a,variant:"quiet",children:"Cancel"}),e.jsx($,{onClick:o,variant:i?"danger":"primary",busy:r,children:t})]})]})})}function ss({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 sn({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 Dn({open:s,onNavigate:n}){const{unread:t}=ls(),i=as(),[r,o]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),a=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};p.useEffect(()=>{const d=Se.find(l=>l.items.some(j=>Xe({path:j.path,end:!0},i.pathname)));d&&o(l=>{const j={...l};return Se.forEach(v=>{v.collapsible!==!1&&(j[v.id]=v.id!==d.id)}),a(j),j})},[i.pathname]);const c=(d,l=!1)=>{o(j=>{const v={...j},u=!(j[d]??l);return Se.forEach(m=>{m.collapsible!==!1&&(v[m.id]=m.id===d?u:!0)}),a(v),v})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:Se.map(d=>{const l=d.items.filter(m=>!m.hidden);if(l.length===0)return null;const j=l.some(m=>Xe({path:m.path,end:!0},i.pathname)),v=d.collapsible!==!1,u=j||!v||!(r[d.id]??d.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[d.label&&v?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":u,onClick:()=>c(d.id,d.defaultCollapsed),children:[d.label,e.jsx(Y,{name:"caret",className:"ico caret"})]}):d.label?e.jsx("div",{className:"rail-head rail-head-static",children:d.label}):null,u?l.map(m=>e.jsx(Ws,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:f})=>f?"on":"","aria-current":void 0,children:({isActive:f})=>e.jsxs("span",{style:{display:"contents"},ref:h=>{const g=h==null?void 0:h.parentElement;g&&(f?g.setAttribute("aria-current","page"):g.removeAttribute("aria-current"))},children:[m.icon?e.jsx(Y,{name:m.icon}):null,m.label,m.badge==="notifications"&&t>0?e.jsx("span",{className:"rail-badge",children:t>99?"99+":t}):null]})},m.id)):null]},d.id)})})}function Fn(){var R,S,H;const{version:s,currentUser:n,online:t,loading:i,status:r,setMaintenance:o}=oe(),[a,c]=p.useState(!1),[d,l]=p.useState(!1),[j,v]=p.useState(!1),[u,m]=p.useState(!1),f=as(),h=!!((R=r==null?void 0:r.maintenance)!=null&&R.enabled),g=!!((S=r==null?void 0:r.quietTime)!=null&&S.active),b=p.useRef(null),k=((H=Array.from(n.trim())[0])==null?void 0:H.toLocaleUpperCase("en-NZ"))||"A",x=r&&t&&!h&&!g?"ok":r||!i?"bad":"checking",y=async()=>{if(!(j||!r)){v(!0);try{await o(!h)}finally{v(!1)}}};return p.useEffect(()=>c(!1),[f.pathname]),p.useEffect(()=>{if(!d)return;const E=G=>{var se;(se=b.current)!=null&&se.contains(G.target)||l(!1)},I=G=>{G.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",E),window.addEventListener("keydown",I),()=>{document.removeEventListener("mousedown",E),window.removeEventListener("keydown",I)}},[d]),p.useEffect(()=>{if(!a)return;const E=document.body.style.overflow;document.body.style.overflow="hidden";const I=G=>{G.key==="Escape"&&c(!1)};return window.addEventListener("keydown",I),()=>{document.body.style.overflow=E,window.removeEventListener("keydown",I)}},[a]),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":a,"aria-controls":"rail",onClick:()=>c(E=>!E),children:e.jsx(Y,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Rn,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsx("button",{type:"button",className:"topbar-status","data-tone":x,"aria-pressed":h,disabled:!r||!t||j||g,title:r?g?"Memby quiet time is active":h?"Bring Memby back online":t?"Take Memby offline":"Memby is not responding":"Checking Memby status","aria-label":g?"Memby quiet time is active":h?"Memby is offline. Bring it online":r&&t?"Memby is online. Take it offline":i?"Checking Memby status":"Memby is not responding",onClick:()=>h?void y():m(!0),children:e.jsx("span",{className:"dot","aria-hidden":"true"})}),e.jsx(Ln,{}),e.jsxs("div",{className:"account-menu","data-open":d||void 0,ref:b,children:[e.jsxs("button",{type:"button",className:"account-trigger","aria-haspopup":"menu","aria-expanded":d,"aria-label":`Signed in as ${n}`,onClick:()=>l(E=>!E),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:k}),e.jsx("span",{className:"account-name",children:n}),e.jsx(Y,{name:"caret",className:"ico account-caret"})]}),d?e.jsxs("div",{className:"account-panel",role:"menu",children:[e.jsxs("div",{className:"account-identity",children:[e.jsx("span",{className:"account-avatar account-avatar-large","aria-hidden":"true",children:k}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsxs(Ws,{to:"/admin/settings",role:"menuitem",onClick:()=>l(!1),children:[e.jsx(Y,{name:"sliders"}),"Gateway settings"]}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsxs("button",{type:"submit",role:"menuitem",children:[e.jsx(Y,{name:"logout"}),"Log out"]})})]}):null]})]})]}),a?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>c(!1)}):null,e.jsx(Dn,{open:a,onNavigate:()=>c(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(un,{})}),u?e.jsx(xe,{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:j,onConfirm:()=>{m(!1),y()},onCancel:()=>m(!1)}):null]})}const nn=p.createContext(null),Pn=5e3;function On({children:s}){const[n,t]=p.useState([]),i=p.useRef(1),r=p.useCallback(d=>{t(l=>l.filter(j=>j.id!==d))},[]),o=p.useCallback((d,l="ok")=>{const j=i.current++;t(v=>[...v,{id:j,message:d,tone:l}]),window.setTimeout(()=>r(j),Pn)},[r]),a=p.useCallback(async(d,l)=>{try{const j=await d();return l&&o(l,"ok"),j}catch(j){o(j instanceof Error?j.message:String(j),"bad");return}},[o]),c=p.useMemo(()=>({show:o,wrap:a}),[o,a]);return e.jsxs(nn.Provider,{value:c,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(Y,{name:d.tone==="bad"?"alert":"check"}),e.jsx("span",{children:d.message}),e.jsx("button",{type:"button",onClick:()=>r(d.id),"aria-label":"Dismiss",children:e.jsx(Y,{name:"close"})})]},d.id))})]})}function te(){const s=p.useContext(nn);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:t,enabled:i=!0}=n,[r,o]=p.useState(),[a,c]=p.useState(""),[d,l]=p.useState(i),[j,v]=p.useState(!1),u=p.useRef(0),m=p.useRef(!1),f=p.useCallback(async()=>{if(!i)return;const h=++u.current;m.current&&v(!0);try{const g=await F.get(s);if(h!==u.current)return;o(g),c(""),m.current=!0}catch(g){if(h!==u.current)return;c(g instanceof Error?g.message:String(g))}finally{h===u.current&&(l(!1),v(!1))}},[s,i]);return p.useEffect(()=>(m.current=!1,l(!0),f(),()=>{u.current+=1}),[f]),p.useEffect(()=>{if(!t||!i)return;let h;const g=()=>{window.clearInterval(h),h=document.hidden?void 0:window.setInterval(()=>void f(),t)},b=()=>{g(),document.hidden||f()};return g(),document.addEventListener("visibilitychange",b),()=>{window.clearInterval(h),document.removeEventListener("visibilitychange",b)}},[t,i,f]),{data:r,error:a,loading:d,refreshing:j,reload:f,set:o}}function ee(){const[s,n]=p.useState(null),t=p.useRef(!0);p.useEffect(()=>()=>{t.current=!1},[]);const i=p.useCallback(async(r,o)=>{n(r);try{return await o(),!0}finally{t.current&&n(null)}},[]);return{busy:s,run:i}}function _n(){var g,b,k,x,y,R;const{status:s,error:n,loading:t}=oe(),i=J("/admin/api/runtime",{pollMs:3e4}),r=J("/admin/api/views",{pollMs:6e4});if(t||!s)return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(V,{})]});const o=s.features??{features:[],revision:0,safeMode:!1},a=o.features??[],c=s.clients??[],d=c.filter(S=>Le(S.lastSeen)).length,l=s.updatePolicy??{},j=!!l.minimumVersion&&l.minimumVersion===l.latestVersion,v=s.playbackPolicy,u=s.mdblist,m=s.forYou,f=(s.runs??[]).slice(0,5),h=i.data;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(U,{message:n}),e.jsx(le,{tiles:[{label:"items in the library",value:w(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:w((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:w(c.length),icon:"tv",tone:"info"},{label:`visits today · ${((g=r.data)==null?void 0:g.lastWeek.visits)??0} this time last week`,value:w((b=r.data)==null?void 0:b.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((k=r.data)==null?void 0:k.lastWeek.viewers)??0} this time last week`,value:w((x=r.data)==null?void 0:x.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${a.filter(S=>S.enabled).length} / ${a.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(ss,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(M,{tone:"bad",children:"offline for maintenance"}):(R=s.quietTime)!=null&&R.active?e.jsx(M,{tone:"warn",children:"quiet time active"}):e.jsx(M,{tone:"ok",children:"online"})},{label:"Feature control plane",value:o.safeMode?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["revision r",w(o.revision)]})},{label:"App update prompt",value:l.enabled?e.jsxs(M,{tone:j?"warn":"ok",children:[j?"required · ":"optional · ",l.latestVersion]}):e.jsx(M,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(M,{tone:"warn",children:"running"}):e.jsxs(M,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(v==null?void 0:v.prerollEnabled)===!1?e.jsx(M,{children:"off"}):e.jsxs(M,{tone:"ok",children:[((v==null?void 0:v.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(T,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(ss,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"MDBList ratings",value:u!=null&&u.enabled?e.jsxs(M,{tone:"ok",children:[w(u.cachedTitles)," titles stored"]}):e.jsx(M,{children:u!=null&&u.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(M,{tone:"warn",children:"rebuilding"}):e.jsxs(M,{children:[w((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:w((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(re,{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(X,{columns:4,children:"No imports have run yet."}):f.map(S=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(S.startedAt)}),e.jsx("td",{children:S.kind}),e.jsx("td",{children:e.jsx(M,{tone:S.status==="success"?"ok":S.status==="running"?"warn":"bad",children:S.status})}),e.jsx("td",{className:"num",children:w(S.itemsUpserted)})]},S.id||S.startedAt))})]})})}),e.jsx(T,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:h?e.jsxs(e.Fragment,{children:[e.jsx(sn,{tiles:[{label:"goroutines",value:w(h.goroutines)},{label:"heap in use",value:$e(h.heapInuse)},{label:"reserved",value:$e(h.sys)},{label:"collections",value:w(h.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",$e(h.nextGc)," · memory limit"," ",h.memoryLimit>0&&h.memoryLimit`/admin/api/notifications${qe({days:r,type:a,severity:d,unread:j,limit:f,offset:u*f})}`,[r,a,d,j,u]),{data:g,error:b,loading:k,reload:x}=J(h),y=async()=>{await t(),await x()};return e.jsxs(e.Fragment,{children:[e.jsx(W,{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($,{onClick:()=>void y(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(U,{message:b}),e.jsx(le,{tiles:[{label:"Events in window",value:w((g==null?void 0:g.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:w(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:w((g==null?void 0:g.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Be,{value:r,options:Un.map(R=>({value:R.value,label:R.label})),onChange:R=>{o(R),m(0)}})}),e.jsx(q,{label:"Kind",children:e.jsxs("select",{value:a,onChange:R=>{c(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((g==null?void 0:g.types)??[]).map(R=>e.jsxs("option",{value:R.type,children:[vs(R.type)," (",R.count,")"]},R.type))]})}),e.jsx(q,{label:"Severity",children:e.jsxs("select",{value:d,onChange:R=>{l(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:j?"unread":"",onChange:R=>{v(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($,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{x(),i()},children:"Refresh"})})]}),k?e.jsx(V,{}):e.jsx(T,{title:"Events",icon:"bell",tone:"info",footer:((g==null?void 0:g.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx($,{size:"sm",disabled:u===0,onClick:()=>m(u-1),children:"Newer"}),e.jsx($,{size:"sm",disabled:(u+1)*f>=((g==null?void 0:g.total)??0),onClick:()=>m(u+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((g==null?void 0:g.events.length)??0)===0?e.jsx(X,{columns:6,children:"Nothing has happened in this window."}):g==null?void 0:g.events.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(R.occurredAt),children:je(R.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(ze,{name:Xs(R.type),tone:Qs(R)}),vs(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(M,{tone:"ok",children:"new"}),R.link?e.jsx(re,{className:"table-row-link",to:R.link,children:"Open"}):null]})]},R.id))})]})})})]})}function bs(s){const n=s?new Date(s).getTime():0;return Number.isFinite(n)?n:0}function fs(){return e.jsx("span",{className:"muted",title:"No Tracearr sessions matched to this person",children:"—"})}function Bn(){const{data:s,error:n,loading:t}=J("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],r=i.flatMap(j=>j.devices??[]),o=i.filter(j=>{var v;return(v=j.recommendations)==null?void 0:v.completed}).length,a=i.filter(j=>{var v,u;return((v=j.recommendations)==null?void 0:v.prompted)&&!((u=j.recommendations)!=null&&u.completed)}).length,c=i.filter(j=>{var v;return(v=j.watchTime)==null?void 0:v.matched}),d=c.reduce((j,v)=>{var u;return j+(((u=v.watchTime)==null?void 0:u.weekMs)??0)},0),l=[...i].sort((j,v)=>bs(v.lastSeen)-bs(j.lastSeen));return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on."}),e.jsx(U,{message:n}),t?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Memby users",value:w(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:w(r.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(r.filter(j=>Le(j.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:w(o),icon:"check",tone:"ok"},{label:"setup prompts queued",value:w(a),icon:"sparkle",tone:"note"},...c.length?[{label:"watched by the household this week",value:Ce(d),icon:"pulse",tone:"data"}]:[]]}),e.jsx(T,{title:"People",icon:"people",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Devices"}),e.jsx("th",{className:"num",children:"This week"}),e.jsx("th",{className:"num",children:"This month"}),e.jsx("th",{children:"Recommendations"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:l.length===0?e.jsx(X,{columns:6,children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):l.map(j=>{var g,b;const v=j.devices??[],u=v.filter(k=>Le(k.lastSeen)).length,m=(g=j.recommendations)!=null&&g.completed?{label:"personalised",tone:"ok"}:(b=j.recommendations)!=null&&b.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},f=rs(j.lastSeen),h=j.watchTime;return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":f.tone,title:f.label}),e.jsx("span",{className:"avatar",children:j.initials||Zs(j.username)}),e.jsx(re,{className:"table-row-link",to:`/admin/accounts/${encodeURIComponent(j.id)}`,children:j.username||"Unnamed user"})]})}),e.jsxs("td",{className:"num",children:[w(v.length),u?e.jsxs("span",{className:"table-sub",children:[w(u)," active now"]}):null]}),e.jsx("td",{className:"num",children:h!=null&&h.matched?Ce(h.weekMs):e.jsx(fs,{})}),e.jsx("td",{className:"num muted",children:h!=null&&h.matched?Ce(h.monthMs):e.jsx(fs,{})}),e.jsx("td",{children:e.jsx(M,{tone:m.tone,children:m.label})}),e.jsx("td",{className:"nowrap muted",title:P(j.lastSeen),children:je(j.lastSeen)})]},j.id)})})]})})})]})]})}function Je(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function Vn(){var ue;const{userId:s=""}=Ve(),n=is(),{wrap:t}=te(),{busy:i,run:r}=ee(),o=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:a,error:c,loading:d,reload:l}=J("/admin/api/accounts",{pollMs:3e4}),[j,v]=p.useState(null),[u,m]=p.useState(null),[f,h]=p.useState(null),[g,b]=p.useState(null),[k,x]=p.useState(null),y=((a==null?void 0:a.accounts)??[]).find(A=>A.id===s),R=(a==null?void 0:a.catalogue)??[],S=(a==null?void 0:a.themes)??[];p.useEffect(()=>{var A;j===null&&y&&v({...((A=y.settings)==null?void 0:A.preferences)??{}})},[y,j]),p.useEffect(()=>{f===null&&y&&h({...y.notifications})},[y,f]),p.useEffect(()=>{if(u!==null||!y)return;const A=y.themes??[];m(A.length===0?S.map(L=>L.id):A)},[y,u,S]);const H=p.useMemo(()=>{const A=[];for(const L of R){let N=A.find(D=>D.name===L.area);N||A.push(N={name:L.area,definitions:[]}),N.definitions.push(L)}return A},[R]),E=(A,L,N,D)=>r(A,async()=>{const _=await t(L,N);b(null),_!==void 0&&(D==null||D()),await l()});if(d)return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"User",crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"})}),e.jsx(V,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"User",crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"})}),e.jsx(U,{message:c}),e.jsx(T,{children:e.jsx(Q,{children:"This user is no longer signed in to Memby."})})]});const I=y.devices??[],G=I.filter(A=>Le(A.lastSeen)).length,se=y.settings??{},ne=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:y.username||"Unnamed user",intro:`Memby user · ${w(I.length)} device${I.length===1?"":"s"} · last seen ${P(y.lastSeen)}`,crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:y.initials||Zs(y.username)}),G?e.jsxs(M,{tone:"ok",children:[G," active now"]}):e.jsx(M,{children:"idle"}),e.jsx(ae,{children:y.id})]})}),e.jsx(U,{message:c}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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:I.length===0?e.jsx(Q,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:I.map(A=>{const L=rs(A.lastSeen);return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":L.tone,title:L.label})," ",e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(A.id)}`,children:A.name||"Memby TV"})]}),e.jsxs("p",{children:[A.version?`Memby ${A.version}`:"Legacy Memby client"," · ",L.label," · last seen ",P(A.lastSeen)," · signed in ",P(A.signedInAt)]}),(A.versions??[]).length>0?e.jsx("div",{className:"chips",children:(A.versions??[]).map(N=>e.jsxs(ae,{tone:N.version===A.version?"ok":void 0,children:[N.version,N.version===A.version?" · now":""]},N.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx($,{size:"sm",disabled:!A.id,onClick:()=>x({id:A.id,name:A.name}),children:"Rename"}),e.jsx($,{size:"sm",variant:"danger",disabled:!A.id,onClick:()=>b({kind:"remove-device",deviceId:A.id,name:A.name}),children:"Sign out"})]})]},A.id||A.name)})})}),e.jsxs(T,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:ne.completed?e.jsx($,{busy:i==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):ne.prompted?e.jsx($,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx($,{variant:"primary",busy:i==="prompt",onClick:()=>void E("prompt",()=>F.put(`${o}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:ne.completed?e.jsx(M,{tone:"ok",children:"completed"}):ne.prompted?e.jsx(M,{tone:"warn",children:"prompt queued"}):e.jsx(M,{children:"not invited"})}),e.jsx(Hn,{prompt:ne})]})]}),(ue=y.watchTime)!=null&&ue.matched?e.jsx(T,{title:"Watch time",intro:"From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the household's own time.",icon:"pulse",tone:"data",actions:y.watchTime.tracearrUsername?e.jsx(ae,{children:y.watchTime.tracearrUsername}):null,children:e.jsx(le,{tiles:[{label:`this week · ${w(y.watchTime.weekSessions)} session${y.watchTime.weekSessions===1?"":"s"}`,value:Ce(y.watchTime.weekMs),icon:"pulse",tone:"data"},{label:`this month · ${w(y.watchTime.monthSessions)} session${y.watchTime.monthSessions===1?"":"s"}`,value:Ce(y.watchTime.monthMs),icon:"calendar",tone:"info"},{label:"since Tracearr started recording",value:Ce(y.watchTime.totalMs),icon:"clock",tone:"note"},{label:"last watched",value:P(y.watchTime.lastWatchedAt),icon:"history",tone:void 0,small:!0}]})}):null,e.jsx(T,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:f!=null&&f.enabled?e.jsx(M,{tone:"ok",children:"enabled"}):e.jsx(M,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="notifications",onClick:()=>void E("notifications",()=>F.put(`${o}/notifications`,f),"Notification settings saved.",()=>h(null)),children:"Save notifications"}),e.jsx($,{onClick:()=>{h(null),l()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(z,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:A=>h(L=>L&&{...L,enabled:A})}),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:A=>h(L=>L&&{...L,showReturnAlerts:A})}),e.jsx(z,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,sonarrAlerts:A})}),e.jsx(z,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,radarrAlerts:A})}),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:A=>h(L=>L&&{...L,updateAlerts:A})}),e.jsx(z,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,libraryAlerts:A})}),e.jsx(z,{label:"Weekly watch-time summary",hint:"Send this person their week-to-date and month-to-date viewing on Sunday evening, and a summary of the month just gone once it ends. Needs Tracearr.",checked:f.watchTimeDigest,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,watchTimeDigest:A})}),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:A=>h(L=>L&&{...L,systemAlerts:A})})]}):null}),e.jsx(T,{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:se.saved?e.jsxs(M,{tone:se.source==="admin"?"warn":"ok",children:["r",w(se.revision)," · ",se.source||"device"," · ",P(se.updatedAt)]}):e.jsx(M,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="push",onClick:()=>void E("push",()=>F.put(`${o}/preferences`,{preferences:j??{}}),"Pushed to their televisions.",()=>v(null)),children:"Push to their televisions"}),e.jsx($,{onClick:()=>{v(null),l()},children:"Discard changes"}),e.jsx($,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(re,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:H.map(A=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:A.name}),A.definitions.map(L=>e.jsx(zn,{definition:L,value:j==null?void 0:j[L.key],onChange:N=>v(D=>({...D??{},[L.key]:N}))},L.key))]},A.name))}),e.jsx(T,{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(M,{children:"all schemes"}):e.jsxs(M,{tone:"note",children:[w((y.themes??[]).length)," of ",w(S.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="themes",onClick:()=>(u??[]).length===0?b({kind:"no-themes"}):void E("themes",()=>F.put(`${o}/themes`,{themes:u??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx($,{onClick:()=>m(S.map(A=>A.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:S.map(A=>{var L,N,D;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(u??[]).includes(A.id),onChange:_=>m(ie=>_.target.checked?[...ie??[],A.id]:(ie??[]).filter(pe=>pe!==A.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":Je((L=A.palette)==null?void 0:L.surface),"--swatch-accent":Je((N=A.palette)==null?void 0:N.accent),"--swatch-hairline":Je((D=A.palette)==null?void 0:D.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:A.name}),e.jsx("p",{children:A.description})]})]},A.id)})})}),e.jsx(T,{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($,{variant:"danger",onClick:()=>b({kind:"remove-account"}),children:"Remove Memby access"})}),k?e.jsx(Kn,{initial:k.name,busy:i==="rename",onCancel:()=>x(null),onConfirm:A=>void E("rename",()=>F.put(`${o}/devices/${encodeURIComponent(k.id)}`,{deviceName:A}),"Device renamed.",()=>x(null))}):null,g?e.jsx(Gn,{pending:g,busy:i,username:y.username,onCancel:()=>b(null),onConfirm:()=>{switch(g.kind){case"remove-device":return void E("remove-device",()=>F.del(`${o}/devices/${encodeURIComponent(g.deviceId)}`),"Device signed out.");case"remove-account":return void E("remove-account",()=>F.del(`${o}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void E("reset-rec",()=>F.del(`${o}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void E("reset-prefs",()=>F.del(`${o}/preferences`),"Defaults restored.",()=>v(null));case"no-themes":return void E("themes",()=>F.put(`${o}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function Hn({prompt:s}){const n=s.ratings??[],t=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],i=[...n.map(r=>e.jsxs(ae,{tone:"warn",children:[r.title," · ",w(r.rating)," ★"]},`r:${r.title}`)),...t.flatMap(([r,o])=>(o??[]).map(a=>e.jsxs(ae,{children:[r,": ",a]},`${r}:${a}`)))];return i.length===0?e.jsx(Q,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function zn({definition:s,value:n,onChange:t}){if(s.kind==="toggle")return e.jsx(z,{label:s.name,hint:s.description,checked:!!n,onChange:t});if(s.kind==="choice"||s.kind==="number"){const r=s.unit??"",o=s.kind==="number"?(s.numbers??[]).map(a=>({value:String(a),label:a===0?"No limit":r?`${a} ${r}`:String(a)})):s.options??[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:a=>t(s.kind==="number"?Number(a.target.value):a.target.value),children:o.map(a=>e.jsx("option",{value:a.value,children:a.label},a.value))})})}if(s.kind==="multi"){const r=Array.isArray(n)?n:[],o=[...r,...(s.options??[]).map(a=>a.value).filter(a=>!r.includes(a))];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:o.map(a=>{const c=(s.options??[]).find(d=>d.value===a);return c?e.jsx(z,{label:c.label,checked:r.includes(a),onChange:d=>t(d?[...r,a]:r.filter(l=>l!==a))},a):null})})]})}if(s.kind==="text")return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("input",{type:"text",value:String(n??""),maxLength:s.maxLength,placeholder:"Generated from their name",onChange:r=>t(r.target.value.toLocaleUpperCase("en-NZ"))})});const i=Array.isArray(n)?n:[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` +`),onChange:r=>t(r.target.value.split(` +`).map(o=>o.trim()).filter(Boolean))})})}function Kn({initial:s,busy:n,onConfirm:t,onCancel:i}){const[r,o]=p.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(q,{label:"Device name",children:e.jsx("input",{type:"text",value:r,autoFocus:!0,maxLength:80,onChange:a=>o(a.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{variant:"quiet",onClick:i,children:"Cancel"}),e.jsx($,{variant:"primary",busy:n,disabled:!r.trim(),onClick:()=>t(r.trim()),children:"Rename"})]})]})})}function Gn({pending:s,busy:n,username:t,onConfirm:i,onCancel:r}){const a={"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 ${t||"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(xe,{title:a.title,body:a.body,confirmLabel:a.label,destructive:a.destructive,busy:!!n,onConfirm:i,onCancel:r})}function Zn(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const i=(s.options??[]).find(r=>r.value===n);return i?i.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const t=Array.isArray(n)?n:[];return t.length===0?"None":t.map(i=>{var r;return((r=(s.options??[]).find(o=>o.value===i))==null?void 0:r.label)??i}).join(", ")}function Jn(){const{userId:s=""}=Ve(),{wrap:n}=te(),{busy:t,run:i}=ee(),r=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:o,error:a,loading:c,reload:d}=J(`${r}/preferences/history`,{pollMs:3e4}),[l,j]=p.useState(new Set),[v,u]=p.useState(null),m=(o==null?void 0:o.username)||"this account",f=(o==null?void 0:o.devices)??[],h=(o==null?void 0:o.revisions)??[],g=(o==null?void 0:o.catalogue)??[],b=x=>j(y=>{const R=new Set(y);return R.has(x)?R.delete(x):R.add(x),R}),k=x=>i("restore",async()=>{await n(()=>F.post(`${r}/preferences/revisions/${x}/restore`),`Restored r${x}.`),u(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(re,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(U,{message:a}),c?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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:o!=null&&o.saved?e.jsxs(M,{tone:o.currentSource==="admin"?"warn":"ok",children:["now on r",w(o.currentRevision)," · ",o.currentSource||"device"]}):e.jsx(M,{children:"defaults · never synced"}),children:f.length===0?e.jsx(Q,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.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(M,{children:"signed out"}):null]}),e.jsxs("p",{children:[x.never?"Has not fetched these settings yet":`Holding r${w(x.revision)} · taken ${P(x.ackedAt)}`,x.clientVersion?` · Memby ${x.clientVersion}`:"",x.signedOut?"":` · last seen ${P(x.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:x.never?e.jsx(M,{children:"never taken one"}):x.behind?e.jsxs(M,{tone:"bad",children:[w(x.behind)," behind"]}):e.jsx(M,{tone:"ok",children:"up to date"})})]},x.deviceId||x.name)})})}),e.jsx(T,{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:h.length===0?e.jsx(X,{columns:6,children:"Nothing has been changed on this account yet."}):h.flatMap(x=>{const y=l.has(x.revision),R=x.acks??[],S=x.changes??[],H=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(x.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",w(x.revision)," ",x.current?e.jsx(M,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(M,{tone:x.source==="admin"?"warn":"ok",children:x.author}),x.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",w(x.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:x.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):S.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:S.map((E,I)=>e.jsxs(ae,{children:[E.name,": ",E.before," → ",E.after]},`${E.name}:${I}`))})}),e.jsx("td",{className:"num",children:R.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:R.map(E=>E.deviceName||E.deviceId).join(", "),children:w(R.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx($,{size:"sm",onClick:()=>b(x.revision),children:y?"Hide":"Show"}),x.current?null:e.jsx($,{size:"sm",onClick:()=>u(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:g.map(E=>{var I;return e.jsxs(ae,{children:[E.name,":"," ",Zn(E,(I=x.preferences)==null?void 0:I[E.key])]},E.key)})})})},`${x.revision}:detail`)),H})})]})})})]}),v!==null?e.jsx(xe,{title:`Restore revision ${v}?`,body:"It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to.",confirmLabel:"Restore",busy:t==="restore",onConfirm:()=>void k(v),onCancel:()=>u(null)}):null]})}function Yn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(t=>e.jsx(ae,{tone:t.version===s.version?"ok":void 0,children:t.version},t.version))})}function Qn(){const{status:s,error:n,loading:t}=oe(),i=(s==null?void 0:s.clients)??[],r=i.filter(a=>(a.capabilities??[]).includes("server_features_v1")),o=new Set(i.map(a=>a.version).filter(Boolean));return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands."}),e.jsx(U,{message:n}),t?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"devices known",value:w(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(i.filter(a=>Le(a.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:w(r.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:w(o.size),icon:"download",tone:"note"}]}),e.jsx(T,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(X,{columns:6,children:"No devices have signed in yet."}):i.map(a=>{const c=(a.capabilities??[]).includes("server_features_v1"),d=rs(a.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(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(a.deviceId)}`,children:a.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:a.username}),e.jsx("td",{className:"mono",children:a.version||"legacy"}),e.jsx("td",{children:e.jsx(Yn,{client:a})}),e.jsx("td",{children:e.jsx(M,{tone:c?"ok":"warn",children:c?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:P(a.lastSeen)})]},`${a.deviceId}:${a.username}`)})})]})})})]})]})}const ys={user:"",q:"",ip:"",outcome:"",from:"",to:""},Xn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function et(){var x,y,R,S,H,E;const[s,n]=p.useState("log"),[t,i]=p.useState(7),[r,o]=p.useState(ys),[a,c]=p.useState(0),d=100,l=p.useMemo(()=>qe({...r,days:r.from?void 0:t||void 0,limit:d,offset:a*d}),[r,t,a]),j=J(`/admin/api/logins${l}`,{enabled:s==="log"}),v=J(`/admin/api/logins/devices${l}`,{enabled:s==="devices"}),u=((x=j.data)==null?void 0:x.users)??((y=v.data)==null?void 0:y.users)??[],m=((R=j.data)==null?void 0:R.totals)??((S=v.data)==null?void 0:S.totals),f=((H=j.data)==null?void 0:H.retentionDays)??((E=v.data)==null?void 0:E.retentionDays)??90,h=s==="log"?j.loading:v.loading,g=s==="log"?j.error:v.error,b=I=>{o(G=>({...G,...I})),c(0)},k=Object.entries(r).some(([,I])=>I!=="")||!!r.from;return e.jsxs(e.Fragment,{children:[e.jsx(W,{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(Be,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(U,{message:g}),m?e.jsx(le,{tiles:[{label:"Successful sign-ins",value:w(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:w(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:w(m.devices),icon:"tv",tone:"info"},{label:"People",value:w(m.users),icon:"people",tone:"note"},{label:"Addresses",value:w(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Be,{value:r.from?-1:t,options:Xn.map(I=>({value:I.value,label:I.label})),onChange:I=>{i(I),b({from:"",to:""})}})}),e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r.user,onChange:I=>b({user:I.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),u.map(I=>e.jsx("option",{value:I.id,children:I.username||I.id},I.id))]})}),e.jsx(q,{label:"Outcome",children:e.jsxs("select",{value:r.outcome,onChange:I=>b({outcome:I.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(q,{label:"Address",children:e.jsx("input",{type:"text",value:r.ip,placeholder:"10.0.0.4",onChange:I=>b({ip:I.target.value})})}),e.jsx(q,{label:"From",children:e.jsx("input",{type:"date",value:r.from,onChange:I=>b({from:I.target.value})})}),e.jsx(q,{label:"To",children:e.jsx("input",{type:"date",value:r.to,onChange:I=>b({to:I.target.value})})}),e.jsx(q,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:r.q,placeholder:"Name, device or address",onChange:I=>b({q:I.target.value})})}),e.jsx("div",{className:"filter-actions",children:k?e.jsx($,{variant:"quiet",size:"sm",onClick:()=>{o(ys),c(0)},children:"Clear"}):null})]}),h?e.jsx(V,{}):s==="log"?e.jsx(st,{data:j.data,page:a,limit:d,onPage:c}):e.jsx(nt,{data:v.data})]})}function st({data:s,page:n,limit:t,onPage:i}){if(!s)return null;const r=s.events.length,o=s.total===0?0:n*t+1;return e.jsxs(e.Fragment,{children:[e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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(en,{data:s.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} refused, ${a.devices} televisions`})}),e.jsx(T,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(Q,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(a=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.ipAddress}),e.jsxs("p",{children:[w(a.logins)," in",a.failures>0?` · ${w(a.failures)} refused`:""]})]}),a.failures>0&&a.logins===0?e.jsx(M,{tone:"bad",children:"only refused"}):null]},a.ipAddress))})})]}),e.jsx(T,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${w(o)}–${w(o+r-1)} of ${w(s.total)}`}),footer:s.total>t?e.jsxs(e.Fragment,{children:[e.jsx($,{size:"sm",disabled:n===0,onClick:()=>i(n-1),children:"Newer"}),e.jsx($,{size:"sm",disabled:(n+1)*t>=s.total,onClick:()=>i(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(X,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:a.deviceId?e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(a.deviceId)}`,children:a.deviceName||a.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]})}function nt({data:s}){return s?e.jsx(T,{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(X,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?w(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:w(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:w(n.failures)}):"—"}),e.jsx("td",{className:"num",children:w(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?P(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const tt=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function it(){const{deviceId:s=""}=Ve(),[n,t]=p.useState(7),i=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${qe({days:n||void 0,limit:200})}`,[s,n]),{data:r,error:o,loading:a}=J(i,{enabled:!!s}),c=r==null?void 0:r.summary,d=(c==null?void 0:c.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:d,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(re,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(re,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:d})]}),actions:e.jsx(Be,{value:n,options:tt.map(l=>({value:l.value,label:l.label})),onChange:t})}),e.jsx(U,{message:o}),a?e.jsx(V,{}):r?e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Sign-ins today",value:w((c==null?void 0:c.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:w((c==null?void 0:c.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:w((c==null?void 0:c.failures)??0),icon:"shield",tone:((c==null?void 0:c.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:w((c==null?void 0:c.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:c!=null&&c.firstLogin?P(c.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:c!=null&&c.lastLogin?P(c.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(en,{data:r.days,labelOf:l=>l.day,valueOf:l=>l.logins+l.failures,toneOf:l=>l.failures>l.logins?"bad":void 0,title:l=>`${l.day}: ${l.logins} in${l.failures?`, ${l.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(T,{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:(c==null?void 0:c.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:r.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(c==null?void 0:c.clientVersion)||"unknown"})]})})]})}),e.jsx(T,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:r.versions.length===0?e.jsx(Q,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:r.versions.map(l=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:l.version}),e.jsxs("p",{children:[P(l.firstSeen)," → ",P(l.lastSeen)]})]})},l.version))})})]})]}),e.jsx(T,{title:"Addresses",icon:"globe",tone:"data",children:r.addresses.length===0?e.jsx(Q,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:r.addresses.map(l=>e.jsxs(ae,{tone:l.failures>0?"warn":"data",children:[l.ipAddress," · ",w(l.logins),l.failures>0?` (+${w(l.failures)} refused)`:""]},l.ipAddress))})}),e.jsx(T,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[w(r.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:r.events.length===0?e.jsx(X,{columns:6,children:"This television has not connected in the selected window."}):r.events.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(l.occurredAt)}),e.jsx("td",{children:l.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:l.ipAddress||"—"}),e.jsx("td",{className:"mono",children:l.clientVersion||"—"}),e.jsx("td",{className:"muted",children:l.method}),e.jsx("td",{className:"nowrap",children:l.success?l.newDevice?e.jsx(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:l.failureReason||"refused"})})]},l.id))})]})})})]}):null]})}function at(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(!1),l=(s==null?void 0:s.library.byType)??{},j=!!(s!=null&&s.syncRunning),v=u=>a(u,async()=>{await r(()=>F.post("/admin/api/sync",{kind:u}),u==="full"?"Full re-import started.":"Import started."),d(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(U,{message:n}),t||!s?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"items",value:w(s.library.total),icon:"library",tone:"data"},...Object.keys(l).sort().map(u=>({label:u,value:w(l[u]),icon:"list"})),{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(T,{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:j?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",icon:"sync",disabled:j,busy:o==="incremental",onClick:()=>void v("incremental"),children:"Sync new items"}),e.jsx($,{icon:"database",disabled:j,onClick:()=>d(!0),children:"Full re-import"})]})})]}),c?e.jsx(xe,{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:o==="full",onConfirm:()=>void v("full"),onCancel:()=>d(!1)}):null]})}const rt={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 lt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(!1),[l,j]=p.useState(""),[v,u]=p.useState(!1),[m,f]=p.useState([]),[h,g]=p.useState(!1),b=s==null?void 0:s.mdblist;p.useEffect(()=>{h||!b||(d(b.enabled),f(b.sources??[]))},[b,h]);const k=()=>a("save",async()=>{await r(()=>F.post("/admin/api/mdblist-settings",{enabled:c,apiKey:l.trim(),clearApiKey:v,sources:m}),"Ratings settings saved."),j(""),u(!1),g(!1),await i()}),x=(b==null?void 0:b.cachedTitles)??0;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Movie ratings",intro:"Optional MDBList scores on films and shows."}),e.jsx(U,{message:n}),t||!b?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"titles stored",value:w(x),icon:"database",tone:"data"},{label:"due to be re-checked",value:w(b.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:w((b.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:b.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:b.apiKeyConfigured?"ok":void 0}]}),e.jsxs(he,{cols:"2",children:[e.jsxs(T,{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:c?e.jsxs(M,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(M,{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:c,onChange:y=>{d(y),g(!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:l,placeholder:b.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>j(y.target.value)})}),e.jsx(z,{label:"Remove the saved key",checked:v,onChange:u})]}),e.jsx(T,{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(Q,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(b.availableSources??[]).map(y=>e.jsx(z,{label:rt[y]??y,checked:m.includes(y),onChange:R=>{g(!0),f(S=>R?[...S,y]:S.filter(H=>H!==y))}},y))})})]}),e.jsx(T,{children:e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void k(),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 ot(){var v;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),c=(s==null?void 0:s.requestUsers)??[],d=((v=s==null?void 0:s.requestPolicy)==null?void 0:v.allowedUserIds)??[],l=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(u=>[u.userId,u])),[s==null?void 0:s.requestUsage]),j=u=>a(`access-${u}`,async()=>{const m=d.includes(u)?d.filter(f=>f!==u):[...d,u];await r(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(u)?"Request access granted.":"Request access removed."),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(U,{message:n}),t?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(T,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(M,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(M,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(Q,{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(T,{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:c.length===0?e.jsx(Q,{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:c.map(u=>{const m=l.get(u.id),f=d.includes(u.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:u.username})}),e.jsx("td",{className:"muted nowrap",children:P(u.lastSeen)}),e.jsx("td",{className:"num",children:(m==null?void 0:m.requests)??0}),e.jsx("td",{className:"muted nowrap",children:m!=null&&m.lastRequest?P(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx($,{size:"sm",variant:f?"quiet":"primary",busy:o===`access-${u.id}`,onClick:()=>void j(u.id),children:f?"Remove access":"Give access"})})]},u.id)})})]})})})]})]})}function ct(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(!1),l=s==null?void 0:s.forYou,j=!!(s!=null&&s.forYouRunning),v=(u,m,f)=>a(m,async()=>{await r(()=>F.post("/admin/api/for-you",{action:u}),f),d(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(U,{message:n}),t?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Tracearr sessions",value:w((l==null?void 0:l.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:w((l==null?void 0:l.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:w((l==null?void 0:l.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:P(l==null?void 0:l.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(T,{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:j?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",icon:"download",disabled:j,busy:o==="import",onClick:()=>void v("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx($,{icon:"database",disabled:j,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx($,{icon:"sync",disabled:j,busy:o==="rebuild",onClick:()=>void v("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(T,{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(re,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),c?e.jsx(xe,{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:o==="full",onConfirm:()=>void v("full-import","full","Backfill started."),onCancel:()=>d(!1)}):null]})}const dt=[["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 ht(s){return s?dt.flatMap(([n,t])=>Object.entries(s[t]??{}).map(([i,r])=>({dimension:n,name:i,weight:r.weight??0,evidence:r.evidence??0}))).sort((n,t)=>Math.abs(t.weight)-Math.abs(n.weight)):[]}const ws=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function ut(){const{status:s}=oe(),{wrap:n}=te(),{busy:t,run:i}=ee(),[r,o]=p.useState(""),[a,c]=p.useState("default"),[d,l]=p.useState("0"),[j,v]=p.useState(""),[u,m]=p.useState(null),[f,h]=p.useState("Choose a person to inspect their recommendations."),[g,b]=p.useState(""),k=(s==null?void 0:s.requestUsers)??[],x=()=>i("run",async()=>{if(!r){b("Choose a person to pressure-test.");return}b(""),h("Running the permission check and the scorer…");const E=new URLSearchParams({userId:r,context:a,minutes:d||"0",limit:"100"});j&&E.set("at",new Date(j).toISOString());const I=await n(()=>F.get(`/admin/api/recommendations?${E.toString()}`));I?(m(I),h(`Scored at ${new Date().toLocaleTimeString()}.`)):h("Pressure test failed.")}),y=ht((u==null?void 0:u.profile)??null).slice(0,24),R=(u==null?void 0:u.actions)??[],S=(u==null?void 0:u.items)??[],H=(u==null?void 0:u.profileMeta)??{};return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Score inspector",intro:"Re-run the ranker for one person and read every component."}),e.jsx(U,{message:g}),e.jsx(T,{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($,{variant:"primary",busy:t==="run",onClick:()=>void x(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r,onChange:E=>o(E.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),k.map(E=>e.jsx("option",{value:E.id,children:E.username},E.id))]})}),e.jsx(q,{label:"Context",children:e.jsxs("select",{value:a,onChange:E=>c(E.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:E=>l(E.target.value)})}),e.jsx(q,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:j,onChange:E=>v(E.target.value)})})]})}),u?e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"prepared pool",value:w(u.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:w(u.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:w(S.length),icon:"sparkle",tone:"note"},{label:"source events",value:w(H.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:H.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:P(H.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(T,{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(Q,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(E=>e.jsxs(ae,{tone:E.weight<0?"bad":void 0,children:[E.dimension,": ",E.name," ",ws(E.weight)," · n=",w(E.evidence)]},`${E.dimension}:${E.name}`))}),R.length===0?e.jsx(Q,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:R.map((E,I)=>e.jsxs(ae,{tone:"ok",children:[E.action,": ",E.title||E.itemId]},`${E.action}:${I}`))})]}),S.length===0?e.jsx(T,{children:e.jsx(Q,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(he,{children:S.map((E,I)=>{const G=E.explanation??{},se=Object.entries(G.components??{}).sort((A,L)=>Math.abs(L[1])-Math.abs(A[1])),ne=E.exposure??{},ue=[E.type,E.year,E.runtimeMinutes?`${E.runtimeMinutes} min`:null,...E.genres??[]].filter(Boolean).join(" · ");return e.jsxs(T,{title:`#${I+1} · ${E.title}`,intro:ue,actions:e.jsx(ae,{tone:"ok",children:Number(G.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[E.preparedReason||"No legacy prepared explanation",E.compatibilityLabel?` · ${E.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(G.reasonCodes??[]).map(A=>e.jsx(ae,{tone:"ok",children:A},A)),se.map(([A,L])=>e.jsxs(ae,{tone:L<0?"bad":void 0,children:[A,"=",ws(L)]},A))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",w(E.baseRank??0)," · base ",Number(E.baseScore??0).toFixed(3)," · affinity ",Number(E.affinityScore??0).toFixed(3)," · compatibility"," ",Number(E.compatibilityScore??0).toFixed(3)," · impressions"," ",w(ne.impressions??0)," · focuses ",w(ne.focuses??0)," · selects"," ",w(ne.selects??0)]}),e.jsx("div",{className:"chips",children:(E.eligibleRows??[]).map(A=>e.jsx(ae,{tone:"ok",children:A},A))}),E.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",E.preparedEvidenceTitle]}):null]})]},`${I}:${E.title}`)})})]}):null]})}const mt=4,we=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],Ee=()=>({pinnedItems:[],primeSubtitle:""}),ns=[{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 ks(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Ns(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function pt(s){return s.frequency??"once"}function xt(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=ns.filter(i=>(s.weekdays??[]).includes(i.value));return`${(n.length===7?"Every day":n.map(i=>i.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function Ss(s,n){const t=new Date;t.setMinutes(Math.ceil(t.getMinutes()/30)*30,0,0);const i=new Date(t.getTime()+2*60*60*1e3),r=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:t.toISOString(),endAt:i.toISOString(),priority:0,enabled:!0,placements:[r?n:"home"]}}function jt(){var ue,A,L;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=te(),{busy:a,run:c}=ee(),[d,l]=p.useState("home"),[j,v]=p.useState({home:Ee(),movies:Ee(),tv_shows:Ee()}),[u,m]=p.useState(!1),[f,h]=p.useState(""),[g,b]=p.useState(null),[k,x]=p.useState([]),[y,R]=p.useState(null),S=s==null?void 0:s.heroPolicy;p.useEffect(()=>{var N,D,_;u||!S||(v({home:((N=S.placements)==null?void 0:N.home)??{pinnedItems:S.pinnedItems??[],primeSubtitle:S.primeSubtitle??""},movies:((D=S.placements)==null?void 0:D.movies)??Ee(),tv_shows:((_=S.placements)==null?void 0:_.tv_shows)??Ee()}),x(S.schedules??[]))},[S,u]);const H=()=>c("search",async()=>{const N=f.trim();if(!N)return;const D=await r(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(N)}`));D&&b(D.items??[])}),E=j[d],I=E.pinnedItems??[],G=N=>{v(D=>({...D,[d]:{...D[d],...N}})),m(!0)},se=N=>{if(!I.some(D=>D.id===N.id)){if(I.length>=mt){o("Remove a pinned title before adding another.","bad");return}G({pinnedItems:[...I,N]})}},ne=()=>c("save",async()=>{await r(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(j).map(([N,D])=>[N,{pinnedItemIds:(D.pinnedItems??[]).map(_=>_.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:k}),"Hero saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Featured content",intro:"Manage an independent, backend-resolved hero for Home, Movies and TV Shows."}),e.jsx(U,{message:n}),t?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(he,{children:we.map(N=>{var Fe;const D=j[N.id],_=k.filter(me=>me.enabled&&(me.placements??["home"]).includes(N.id)).sort((me,Ge)=>Ge.priority-me.priority)[0],ie=(D.pinnedItems??[]).length?"Manual":_?"Schedule ready":"Automatic",pe=(Fe=S==null?void 0:S.items)==null?void 0:Fe.find(me=>me.id===(_==null?void 0:_.itemId)),De=(D.pinnedItems??[]).map(me=>me.name).join(", ")||(pe==null?void 0:pe.name)||(_==null?void 0:_.itemId)||"Resolved for each viewer";return e.jsx(T,{title:N.label,intro:`${ie} · ${De}`,tone:N.id===d?"info":void 0,children:e.jsxs($,{size:"sm",variant:"quiet",onClick:()=>l(N.id),children:["Manage ",N.label]})},N.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:we.map(N=>e.jsx($,{variant:d===N.id?"primary":"quiet",onClick:()=>l(N.id),children:N.label},N.id))}),e.jsxs(T,{title:`${(ue=we.find(N=>N.id===d))==null?void 0:ue.label} hero`,intro:`Pinned ${(A=we.find(N=>N.id===d))==null?void 0:A.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($,{variant:"primary",busy:a==="save",onClick:()=>void ne(),children:"Save hero"}),e.jsx($,{onClick:()=>{G({pinnedItems:[]})},children:"Clear pins"}),u?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[I.length===0?e.jsx(Q,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:I.map((N,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:N.name}),e.jsxs("small",{children:[N.type,N.year?` · ${N.year}`:""]})]}),e.jsx($,{size:"sm",icon:"clock",onClick:()=>R(Ss(N,d)),children:"Schedule"}),e.jsx($,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${N.name}`,onClick:()=>G({pinnedItems:I.filter(_=>_.id!==N.id)})})]},N.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:E.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:N=>{G({primeSubtitle:N.target.value})}})})]}),e.jsx(T,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(M,{tone:"info",children:(S==null?void 0:S.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:a==="save",onClick:()=>void ne(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:k.length===0?e.jsx(Q,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...k].sort((N,D)=>Number(D.enabled)-Number(N.enabled)||D.priority-N.priority).map(N=>{const D=[...(S==null?void 0:S.items)??[],...I,...g??[]].find(_=>_.id===N.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":N.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:N.frequency==="weekly"?"Weekly":N.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:N.frequency?N.startTime:N.startAt?new Date(N.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)??N.itemId}),e.jsx(M,{tone:N.enabled?"ok":void 0,children:N.enabled?"enabled":"paused"})]}),e.jsx("p",{children:xt(N)}),e.jsxs("div",{className:"chips",children:[(N.placements??["home"]).map(_=>{var ie;return e.jsx("span",{className:"chip",children:(ie=we.find(pe=>pe.id===_))==null?void 0:ie.label},_)}),N.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",N.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx($,{size:"sm",onClick:()=>R({...N}),children:"Edit"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{x(_=>_.map(ie=>ie.id===N.id?{...ie,enabled:!ie.enabled}:ie)),m(!0)},children:N.enabled?"Pause":"Enable"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{x(_=>_.filter(ie=>ie.id!==N.id)),m(!0)},children:"Remove"})]})]},N.id)})})}),e.jsxs(T,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(L=we.find(N=>N.id===d))==null?void 0:L.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:f,placeholder:"Search films and television shows",onChange:N=>h(N.target.value),onKeyDown:N=>{N.key==="Enter"&&H()}})}),e.jsx($,{busy:a==="search",icon:"search",onClick:()=>void H(),children:"Search"})]}),g===null?null:g.length===0?e.jsx(Q,{children:"No playable films or series matched that search."}):e.jsx(he,{children:g.map(N=>e.jsx(T,{title:N.name,intro:`${N.type||"Title"} · ${N.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx($,{size:"sm",icon:"plus",disabled:I.some(D=>D.id===N.id)||d==="movies"&&N.type!=="Movie"||d==="tv_shows"&&N.type!=="Series",onClick:()=>se(N),children:I.some(D=>D.id===N.id)?"Pinned":"Add to hero"}),e.jsx($,{size:"sm",icon:"clock",onClick:()=>R(Ss(N,d)),children:"Schedule"})]})},N.id))})]})]}),y?e.jsx(vt,{schedule:y,item:[...(S==null?void 0:S.items)??[],...I,...g??[]].find(N=>N.id===y.itemId),timeZone:(S==null?void 0:S.timeZone)||"server local time",isNew:!k.some(N=>N.id===y.id),onCancel:()=>R(null),onSave:N=>{x(D=>D.some(_=>_.id===N.id)?D.map(_=>_.id===N.id?N:_):[...D,N]),R(null),m(!0)}}):null]})}function vt({schedule:s,item:n,timeZone:t,isNew:i,onSave:r,onCancel:o}){const[a,c]=p.useState({...s,weekdays:[...s.weekdays??[]]}),d=pt(a),l=h=>{const g=new Date,b=new Date(g.getTime()+2*60*60*1e3);c(k=>{var x;return h==="once"?{...k,frequency:void 0,startAt:Ns(k.startAt)?k.startAt:g.toISOString(),endAt:Ns(k.endAt)?k.endAt:b.toISOString()}:{...k,frequency:h,startTime:k.startTime||"18:00",endTime:k.endTime||"22:00",weekdays:h==="weekly"?(x=k.weekdays)!=null&&x.length?k.weekdays:[1,2,3,4,5]:[]}})},j=a.placements??["home"],v=h=>h==="home"||h==="movies"&&(n==null?void 0:n.type)==="Movie"||h==="tv_shows"&&(n==null?void 0:n.type)==="Series",u=!!(a.startAt&&a.endAt&&new Date(a.endAt)>new Date(a.startAt)),m=!!(a.startTime&&a.endTime&&a.startTime!==a.endTime&&(d!=="weekly"||(a.weekdays??[]).length>0)),f=d==="once"?u:m;return e.jsx("div",{className:"scrim",onPointerDown:h=>h.target===h.currentTarget&&o(),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)??a.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",t,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(h=>e.jsxs("button",{type:"button","aria-pressed":d===h,onClick:()=>l(h),children:[e.jsx("b",{children:h==="once"?"One time":h==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:h==="once"?"A date range":h==="daily"?"Same time daily":"Choose days"})]},h))}),d==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:a.startAt?ks(new Date(a.startAt)):"",onChange:h=>c(g=>({...g,startAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})}),e.jsx(q,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:a.endAt?ks(new Date(a.endAt)):"",onChange:h=>c(g=>({...g,endAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts each time",children:e.jsx("input",{type:"time",value:a.startTime??"",onChange:h=>c(g=>({...g,startTime:h.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:a.endTime??"",onChange:h=>c(g=>({...g,endTime:h.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:()=>c(h=>({...h,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>c(h=>({...h,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>c(h=>({...h,weekdays:ns.map(g=>g.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:ns.map(h=>{const g=(a.weekdays??[]).includes(h.value);return e.jsx("button",{type:"button","aria-pressed":g,title:h.label,onClick:()=>c(b=>({...b,weekdays:g?(b.weekdays??[]).filter(k=>k!==h.value):[...b.weekdays??[],h.value]})),children:h.short},h.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:we.map(h=>e.jsx(z,{label:h.label,checked:j.includes(h.id),disabled:!v(h.id),onChange:g=>c(b=>{const k=b.placements??["home"],x=g?[...k,h.id]:k.filter(y=>y!==h.id);return{...b,placements:x.length?[...new Set(x)]:k}})},h.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:a.priority,onChange:h=>c(g=>({...g,priority:Number(h.target.value)}))})})]}),e.jsx(z,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:a.enabled,onChange:h=>c(g=>({...g,enabled:h}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{variant:"quiet",onClick:o,children:"Cancel"}),e.jsx($,{variant:"primary",disabled:!f,onClick:()=>r(a),children:i?"Add rule":"Save rule"})]})]})})}function gt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(null),l=s==null?void 0:s.features,j=(l==null?void 0:l.features)??[],v=(s==null?void 0:s.clients)??[],u=(l==null?void 0:l.revision)??0,m=(b,k,x,y)=>a(k,async()=>{await r(()=>F.post("/admin/api/features",{action:b,expectedRevision:u,overrides:y??{}}),x),d(null),await i()}),f=v.filter(b=>(b.capabilities??[]).includes("server_features_v1")).length,h=!!(l!=null&&l.safeMode),g=(b,k)=>({...Object.fromEntries(j.filter(x=>x.source==="override").map(x=>[x.key,x.enabled])),[b]:k});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release."}),e.jsx(U,{message:n}),t||!l?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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($,{variant:h?void 0:"danger",busy:o==="safe",onClick:()=>h?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:h?"Leave safe mode":"Enable safe mode"}),children:e.jsx(sn,{tiles:[{label:"features active",value:`${j.filter(b=>b.enabled).length} / ${j.length}`},{label:"explicit overrides",value:w(j.filter(b=>b.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${v.length}`},{label:"published revision",value:`r${w(u)}`}]})}),e.jsx(he,{cols:"2",children:j.length===0?e.jsx(T,{title:"Nothing registered",icon:"sliders",children:e.jsx(Q,{children:"No server features are registered."})}):j.map(b=>e.jsxs(T,{title:b.name,intro:b.description,actions:e.jsx(M,{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:o===b.key,onChange:k=>d({action:"feature",key:b.key,enabled:k,title:`${k?"Turn on":"Turn off"} ${b.name}?`,body:`${k?"Enable":"Disable"} this feature for every compatible television. ${b.recovery}`,label:k?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(ae,{children:b.key}),e.jsxs(ae,{children:["protocol ",w(b.minimumProtocol),"+"]}),e.jsx(ae,{tone:b.compatible?"ok":"warn",children:b.compatible?"server compatible":"compatibility blocked"}),e.jsx(ae,{tone:"note",children:b.area})]})]},b.key))}),e.jsx(T,{children:e.jsxs("div",{className:"row",children:[e.jsx($,{disabled:!l.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($,{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"}),h?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["live · revision r",w(u)]})]})})]}),c?e.jsx(xe,{title:c.title,body:c.body,confirmLabel:c.label,destructive:c.action!=="rollback",busy:o===c.action,onConfirm:()=>void m(c.action==="feature"?"save":c.action,c.action==="feature"?c.key??"feature":c.action,`${c.label} done.`,c.action==="feature"&&c.key?g(c.key,!!c.enabled):void 0),onCancel:()=>d(null)}):null]})}function bt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=te(),{busy:a,run:c}=ee(),[d,l]=p.useState(!0),[j,v]=p.useState("6.5"),[u,m]=p.useState(!1);p.useEffect(()=>{var h,g;u||!s||(l(((h=s.playbackPolicy)==null?void 0:h.prerollEnabled)!==!1),v(String((((g=s.playbackPolicy)==null?void 0:g.prerollDurationMs)??6500)/1e3)))},[s,u]);const f=()=>c("save",async()=>{const h=Number(j);if(!Number.isFinite(h)||h<1||h>30){o("The preroll duration must be between 1 and 30 seconds.","bad");return}await r(()=>F.post("/admin/api/playback-policy",{prerollEnabled:d,prerollDurationMs:Math.round(h*1e3)}),"Playback policy saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(U,{message:n}),t?e.jsx(V,{rows:1}):e.jsxs(T,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:d?e.jsxs(M,{tone:"ok",children:["on · ",j,"s"]}):e.jsx(M,{children:"off"}),footer:e.jsx($,{variant:"primary",busy:a==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(z,{label:"Show the preroll before a title starts",checked:d,onChange:h=>{l(h),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:j,onChange:h=>{v(h.target.value),m(!0)}})})})]})]})}function ft(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(null),[l,j]=p.useState(null),[v,u]=p.useState(!1),m=s==null?void 0:s.subtitles,f=m==null?void 0:m.stored;p.useEffect(()=>{c||!m||d({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,c]);const h=x=>d(y=>y&&{...y,...x}),g=()=>a("save",async()=>{c&&(await r(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:c.bazarr,openSubtitlesEnabled:c.openSubtitles,openSubtitlesApiKey:c.key.trim(),clearOpenSubtitlesApiKey:c.clearKey,openSubtitlesUsername:c.username.trim(),openSubtitlesPassword:c.password,clearOpenSubtitlesLogin:c.clearLogin}),"Subtitle settings saved."),d(null),await i())}),b=()=>a("test",async()=>{j(null);const x=await r(()=>F.post("/admin/api/subtitle-test"));j((x==null?void 0:x.results)??[])}),k=()=>a("clear",async()=>{await r(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),u(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(U,{message:n}),t||!m||!c?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:w((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:w((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:P(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{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?c.bazarr?e.jsx(M,{tone:"ok",children:"on"}):e.jsx(M,{children:"off"}):e.jsx(M,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:m.bazarrConfigured?`Configured at ${m.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(z,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:c.bazarr,disabled:!m.bazarrConfigured,onChange:x=>h({bazarr:x})})}),e.jsxs(T,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:m.openSubtitlesEnabled?e.jsx(M,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(M,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(z,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:c.openSubtitles,onChange:x=>h({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:c.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:x=>h({key:x.target.value})})}),e.jsx(z,{label:"Remove the saved key",checked:c.clearKey,onChange:x=>h({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:c.username,placeholder:"Not signed in",onChange:x=>h({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:c.password,onChange:x=>h({password:x.target.value})})})]}),e.jsx(z,{label:"Sign out and forget the account",checked:c.clearLogin,onChange:x=>h({clearLogin:x})})]})]}),e.jsxs(T,{children:[e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void g(),children:"Save subtitle settings"}),e.jsx($,{busy:o==="test",icon:"pulse",onClick:()=>void b(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:m.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),l===null?null:l.length===0?e.jsx(Q,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:l.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(M,{tone:x.ok?"ok":"bad",children:x.ok?"reachable":"not reachable"})})]},x.provider))})]}),e.jsx(T,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:f!=null&&f.count?e.jsxs(M,{tone:"data",children:[w(f.count)," files · ",$e(f.bytes)]}):e.jsx(M,{children:"nothing held"}),footer:e.jsx($,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>u(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),v?e.jsx(xe,{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:o==="clear",onConfirm:()=>void k(),onCancel:()=>u(!1)}):null]})}function yt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(null),[l,j]=p.useState(!1),v=s==null?void 0:s.updatePolicy;p.useEffect(()=>{c||!v||d({version:v.latestVersion??"",url:v.downloadUrl??"",notes:v.notes??"",retireBelow:v.retireBelowVersion??"",required:!!v.minimumVersion&&v.minimumVersion===v.latestVersion,destructive:!!v.retireBelowVersion&&v.retireBelowVersion===v.latestVersion})},[v,c]);const u=g=>a(g?"save":"off",async()=>{c&&(await r(()=>F.post("/admin/api/update-policy",{enabled:g,latestVersion:c.version.trim(),downloadUrl:c.url.trim(),notes:c.notes.trim(),required:c.required,destructive:c.destructive,retireBelowVersion:c.retireBelow.trim()}),g?"Update policy saved.":"Update prompts turned off."),j(!1),d(null),await i())}),m=!!(v!=null&&v.minimumVersion)&&(v==null?void 0:v.minimumVersion)===(v==null?void 0:v.latestVersion),f=!!(v!=null&&v.retireBelowVersion)&&(v==null?void 0:v.retireBelowVersion)===(v==null?void 0:v.latestVersion),h=g=>d(b=>b&&{...b,...g});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(U,{message:n}),t||!c?e.jsx(V,{rows:1}):e.jsxs(T,{title:"Update policy",intro:"Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works.",icon:"download",tone:"info",actions:v!=null&&v.enabled?e.jsxs(M,{tone:m?"warn":"ok",children:[f?"sign-out · ":m?"required · ":"optional · ",v.latestVersion]}):e.jsx(M,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>c.required?j(!0):void u(!0),children:"Save policy"}),e.jsx($,{busy:o==="off",onClick:()=>void u(!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:c.version,placeholder:"0.2.63",onChange:g=>h({version:g.target.value})})}),e.jsx(q,{label:"APK URL",children:e.jsx("input",{type:"text",value:c.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:g=>h({url:g.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:c.notes,placeholder:"One line the viewer reads",onChange:g=>h({notes:g.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:c.retireBelow,placeholder:"0.2.44",onChange:g=>h({retireBelow:g.target.value})})}),e.jsx(z,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:c.required,onChange:g=>h({required:g})}),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:c.destructive,onChange:g=>h(g?{destructive:!0,required:!0,retireBelow:c.version.trim()}:{destructive:!1,retireBelow:c.retireBelow.trim()===c.version.trim()?"":c.retireBelow})})]}),l&&c?e.jsx(xe,{title:c.destructive?"Sign every older television out?":"Require this update?",body:c.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:c.destructive,busy:o==="save",onConfirm:()=>void u(!0),onCancel:()=>j(!1)}):null]})}const wt=2e4,kt=3e3,Nt=[{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 St(s){const n=[...Nt];for(const t of[s.defaultIntervalSeconds,s.intervalSeconds])t>0&&!n.some(i=>i.value===t)&&n.push({value:t,label:es(t).replace(/^every /,"Every ")});return n.sort((t,i)=>t.value-i.value)}function Cs(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Ct(){const{wrap:s}=te(),{busy:n,run:t}=ee(),[i,r]=p.useState(!1),{data:o,error:a,loading:c,reload:d}=J("/admin/api/tasks?limit=60",{pollMs:i?kt:wt}),l=(o==null?void 0:o.tasks)??[],j=l.some(y=>y.running);j!==i&&r(j);const v=y=>t(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await d()}),u=(y,R)=>t(`${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)=>t(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:R}),`${y.name} now runs ${es(R)}.`),await d()}),f=y=>t(`${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()}),h=l.filter(y=>{var R;return((R=y.lastRun)==null?void 0:R.status)==="failed"}).length,g=l.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,b=l.filter(y=>!y.enabled).length,k=(o==null?void 0:o.groups)??[],x=l.filter(y=>!y.group);return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Scheduled tasks",intro:"The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."}),e.jsx(U,{message:a}),c?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Tasks",value:w(l.length),icon:"clock",tone:"info"},{label:"Running now",value:w(l.filter(y=>y.running).length),icon:"pulse",tone:j?"ok":void 0},{label:"Last run failed",value:w(h),icon:"alert",tone:h>0?"bad":void 0},{label:"Switched off",value:w(b),icon:"power",tone:b>0?"warn":void 0},{label:"Retimed",value:w(g),icon:"clock",tone:g>0?"note":void 0}]}),h>0?e.jsx(fe,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...k,...x.length>0?[""]:[]].map(y=>{const R=l.filter(S=>S.group===y);return R.length===0?null:e.jsx(T,{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(S=>{var H;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[S.name," ",S.running?e.jsx(M,{tone:"info",children:"running"}):null,S.enabled?null:e.jsx(M,{tone:"warn",children:"off"}),S.defaultIntervalSeconds>0&&S.intervalSeconds!==S.defaultIntervalSeconds?e.jsx(M,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:S.description}),e.jsxs("p",{className:"quiet",children:[es(S.intervalSeconds),S.enabled&&S.nextRun?` · next ${je(S.nextRun).replace(" ago","")}`:"",S.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:P(S.lastRun.startedAt),children:je(S.lastRun.startedAt)}),` in ${ke(S.lastRun.durationMs)}`,S.lastRun.detail?` — ${S.lastRun.detail}`:""]}):" · never run"]}),(H=S.lastRun)!=null&&H.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(M,{tone:"bad",children:S.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[S.lastRun?e.jsx(M,{tone:Cs(S.lastRun.status),children:S.lastRun.status}):e.jsx(M,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${S.name} runs`,value:S.intervalSeconds,disabled:n===`${S.id}:interval`||S.running,onChange:E=>void m(S,Number(E.target.value)),children:St(S).map(E=>e.jsxs("option",{value:E.value,children:[E.label,E.value===S.defaultIntervalSeconds?" (default)":""]},E.value))}),S.defaultIntervalSeconds>0&&S.intervalSeconds!==S.defaultIntervalSeconds?e.jsx($,{size:"sm",icon:"refresh",busy:n===`${S.id}:interval`,onClick:()=>void f(S),children:"Default"}):null,e.jsx(z,{label:"",checked:S.enabled,disabled:n===`${S.id}:enabled`,onChange:E=>void u(S,E)}),e.jsx($,{size:"sm",icon:"play",busy:n===S.id,disabled:S.running,onClick:()=>void v(S),children:"Run now"})]})]},S.id)})})},y||"other")}),e.jsx(T,{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:((o==null?void 0:o.runs.length)??0)===0?e.jsx(X,{columns:6,children:"No task has run yet."}):o==null?void 0:o.runs.map(y=>{var R;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(y.startedAt),children:je(y.startedAt)}),e.jsx("td",{children:((R=l.find(S=>S.id===y.taskId))==null?void 0:R.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(M,{tone:Cs(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 Mt={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Et(){const{wrap:s,show:n}=te(),{busy:t,run:i}=ee(),{data:r,error:o,loading:a,reload:c}=J("/admin/api/integrations",{pollMs:6e4}),[d,l]=p.useState(null),[j,v]=p.useState(null),u=(r==null?void 0:r.catalogue)??[],m=(r==null?void 0:r.integrations)??[],f=k=>l({id:k.id,name:k.name,url:"",enabled:k.enabled,events:k.events??[]}),h=()=>i("save",async()=>{if(!d)return;await s(()=>F.post("/admin/api/integrations",d),d.id?"Integration saved.":"Integration added.")&&(l(null),await c())}),g=k=>i("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(k.id)}`),`${k.name} removed.`),v(null),await c()}),b=k=>i(`test:${k.id}`,async()=>{const x=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(k.id)}/test`));x&&n(x.message,x.ok?"ok":"bad"),await c()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{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($,{variant:"primary",icon:"plus",onClick:()=>l(Mt),children:"Add a webhook"})}),e.jsx(U,{message:o}),e.jsx(At,{}),e.jsx(Rt,{}),e.jsx(Tt,{}),((r==null?void 0:r.dropped)??0)>0?e.jsxs(fe,{tone:"warn",children:[w((r==null?void 0:r.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,a?e.jsx(V,{}):m.length===0&&!d?e.jsx(T,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(Q,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):m.map(k=>e.jsx($t,{integration:k,catalogue:u,busy:t,onEdit:()=>f(k),onTest:()=>void b(k),onRemove:()=>v(k)},k.id)),d?e.jsx(It,{draft:d,catalogue:u,busy:t==="save",onChange:l,onSave:()=>void h(),onCancel:()=>l(null)}):null,j?e.jsx(xe,{title:`Remove ${j.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:t==="remove",onConfirm:()=>void g(j),onCancel:()=>v(null)}):null]})}function At(){const{wrap:s}=te(),{busy:n,run:t}=ee(),{data:i,error:r,loading:o,reload:a}=J("/admin/api/arr-integrations"),c=d=>t("arr-integrations",async()=>{i&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:d.sonarrEnabled??i.sonarrEnabled,radarrEnabled:d.radarrEnabled??i.radarrEnabled}),"Integration settings saved."),await a())});return e.jsxs(T,{title:"Sonarr and Radarr",intro:"Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests.",icon:"plug",tone:"info",children:[e.jsx(U,{message:r??""}),o?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(z,{label:"Sonarr enabled",hint:i!=null&&i.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(i!=null&&i.sonarrEnabled),disabled:!(i!=null&&i.sonarrConfigured)||n==="arr-integrations",onChange:d=>void c({sonarrEnabled:d})}),e.jsx(z,{label:"Radarr enabled",hint:i!=null&&i.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(i!=null&&i.radarrEnabled),disabled:!(i!=null&&i.radarrConfigured)||n==="arr-integrations",onChange:d=>void c({radarrEnabled:d})})]})]})}function Rt(){const{wrap:s}=te(),{busy:n,run:t}=ee(),{data:i,error:r,loading:o,reload:a}=J("/admin/api/sonarr-request-policy"),[c,d]=p.useState(0),[l,j]=p.useState(!1);p.useEffect(()=>{i&&(d(i.qualityProfileId),j(i.searchImmediately))},[i]);const v=()=>t("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:c,searchImmediately:l}),"Sonarr TV request policy saved."),await a()}),u=i==null?void 0:i.profiles.find(m=>m.id===c);return e.jsxs(T,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx($,{variant:"primary",busy:n==="sonarr-request-policy",disabled:o||c<=0,onClick:()=>void v(),children:"Save Sonarr policy"}),children:[e.jsx(U,{message:r??(i==null?void 0:i.error)??""}),o?e.jsx(V,{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:c,onChange:m=>d(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(z,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:l,onChange:j}),u?e.jsxs(fe,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), be monitored using Memby’s existing all-episodes strategy, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Tt(){const{wrap:s}=te(),{busy:n,run:t}=ee(),{data:i,error:r,loading:o,reload:a}=J("/admin/api/radarr-request-policy"),[c,d]=p.useState(0),[l,j]=p.useState(!1);p.useEffect(()=>{i&&(d(i.qualityProfileId),j(i.searchImmediately))},[i]);const v=()=>t("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:c,searchImmediately:l}),"Radarr movie request policy saved."),await a()}),u=i==null?void 0:i.profiles.find(m=>m.id===c);return e.jsxs(T,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx($,{variant:"primary",busy:n==="radarr-request-policy",disabled:o||c<=0,onClick:()=>void v(),children:"Save Radarr policy"}),children:[e.jsx(U,{message:r??(i==null?void 0:i.error)??""}),o?e.jsx(V,{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:c,onChange:m=>d(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(z,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:l,onChange:j}),u?e.jsxs(fe,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), remain monitored, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function $t({integration:s,catalogue:n,busy:t,onEdit:i,onTest:r,onRemove:o}){const a=s.health,c=!a.lastFailure||a.lastSuccess&&a.lastSuccess>a.lastFailure,d=s.events??[];return e.jsxs(T,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(M,{tone:"ok",children:"on"}):e.jsx(M,{tone:"warn",children:"off"}),a.deliveries>0?e.jsx(M,{tone:c?"ok":"bad",children:c?"delivering":"failing"}):e.jsx(M,{children:"never used"}),e.jsx($,{size:"sm",icon:"pulse",busy:t===`test:${s.id}`,onClick:r,children:"Test"}),e.jsx($,{size:"sm",onClick:i,children:"Edit"}),e.jsx($,{size:"sm",variant:"danger",icon:"trash",onClick:o,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(l=>{var j;return((j=n.find(v=>v.type===l))==null?void 0:j.label)??l}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:a.lastSuccess?P(a.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:a.deliveries>0?e.jsxs("span",{className:"quiet",children:[w(a.deliveries)," attempts, ",w(a.failures)," failed"]}):null})]}),a.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[P(a.lastFailure),a.lastError?` — ${a.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(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(l.attemptedAt),children:je(l.attemptedAt)}),e.jsx("td",{className:"muted",children:l.eventType}),e.jsx("td",{children:l.success?e.jsx(M,{tone:"ok",children:l.statusCode||"ok"}):e.jsx(M,{tone:"bad",children:l.error||l.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:ke(l.durationMs)})]},l.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(X,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function It({draft:s,catalogue:n,busy:t,onChange:i,onSave:r,onCancel:o}){const a=[...new Set(n.map(d=>d.group))],c=(d,l)=>i({...s,events:l?[...s.events,d]:s.events.filter(j=>j!==d)});return e.jsxs(T,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:t,onClick:r,children:s.id?"Save":"Add"}),e.jsx($,{variant:"quiet",onClick:o,children:"Cancel"}),e.jsx("span",{className:"spacer"}),s.events.length===0?e.jsx("span",{className:"quiet",children:"Nothing selected — this destination would never post."}):e.jsxs("span",{className:"quiet",children:[s.events.length," events selected"]})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>i({...s,name:d.target.value})})}),e.jsx(q,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>i({...s,url:d.target.value})})})]}),e.jsx(z,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:d=>i({...s,enabled:d})}),a.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(l=>l.group===d).map(l=>e.jsx(z,{label:l.label,hint:l.description,checked:s.events.includes(l.type),onChange:j=>c(l.type,j)},l.type))]},d))]})}function Lt(){var G,se,ne,ue;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(""),[l,j]=p.useState(!1),[v,u]=p.useState(!1),[m,f]=p.useState(!1),[h,g]=p.useState("23:00"),[b,k]=p.useState("07:00"),[x,y]=p.useState(""),[R,S]=p.useState(!1),H=!!((G=s==null?void 0:s.maintenance)!=null&&G.enabled);p.useEffect(()=>{var A;!v&&s&&d(((A=s.maintenance)==null?void 0:A.message)??"")},[s,v]),p.useEffect(()=>{R||!(s!=null&&s.quietTime)||(f(s.quietTime.enabled),g(s.quietTime.startTime),k(s.quietTime.endTime),y(s.quietTime.message))},[s,R]);const E=A=>a(A?"on":"off",async()=>{await r(()=>F.post("/admin/api/maintenance",{enabled:A,message:c}),A?"Memby is offline for every television.":"Memby is back online."),j(!1),u(!1),await i()}),I=()=>a("quiet",async()=>{await r(()=>F.post("/admin/api/quiet-time",{enabled:m,startTime:h,endTime:b,message:x}),m?"Quiet time saved.":"Quiet time turned off."),S(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time."}),e.jsx(U,{message:n}),t?e.jsx(V,{rows:1}):e.jsx(T,{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:H?"bad":"warn",actions:H?e.jsx(M,{tone:"bad",children:"offline"}):e.jsx(M,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"danger",disabled:H,onClick:()=>j(!0),children:"Go offline"}),e.jsx($,{disabled:!H,busy:o==="off",onClick:()=>void E(!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:c,placeholder:"Back shortly — upgrading the server",onChange:A=>{d(A.target.value),u(!0)}})})}),t?null:e.jsxs(T,{title:"Quiet time",intro:`Pause new television requests and server background work every day in ${((se=s==null?void 0:s.quietTime)==null?void 0:se.timeZone)??"the household timezone"}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`,icon:"clock",tone:(ne=s==null?void 0:s.quietTime)!=null&&ne.active?"warn":"info",actions:(ue=s==null?void 0:s.quietTime)!=null&&ue.active?e.jsx(M,{tone:"warn",children:"active now"}):m?e.jsx(M,{tone:"ok",children:"scheduled"}):e.jsx(M,{children:"off"}),footer:e.jsx($,{variant:"primary",busy:o==="quiet",onClick:()=>void I(),children:"Save quiet time"}),children:[e.jsx(z,{label:"Pause server activity during quiet time",checked:m,onChange:A=>{f(A),S(!0)}}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",hint:"Uses the household's 24-hour clock.",children:e.jsx("input",{type:"time",value:h,onChange:A=>{g(A.target.value),S(!0)}})}),e.jsx(q,{label:"Ends",hint:"May be on the following day, for example 23:00 to 07:00.",children:e.jsx("input",{type:"time",value:b,onChange:A=>{k(A.target.value),S(!0)}})})]}),e.jsx(q,{label:"Message shown on the television",hint:"Shown when a television contacts Memby during quiet time.",children:e.jsx("input",{type:"text",value:x,placeholder:"Quiet time — try again after 7 am",onChange:A=>{y(A.target.value),S(!0)}})})]}),l?e.jsx(xe,{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:o==="on",onConfirm:()=>void E(!0),onCancel:()=>j(!1)}):null]})}const qt=-1;function Ae(s){return s===0?"":s<0?"off":String(s)}function Re(s,n){const t=s.trim().toLowerCase();if(t==="")return 0;if(n&&(t==="off"||t==="none"||t==="0"))return qt;const i=Number.parseInt(t,10);return Number.isFinite(i)?i:0}function Te(s,n){return s<=0?"off":`${s} ${n}${s===1?"":"s"}`}function Ye(s){return{timezone:s.timezone??"",logLevel:s.logLevel??"",sessionIdleDays:Ae(s.sessionIdleDays),sonarrAlertMinutes:Ae(s.sonarrAlertMinutes),radarrAlertMinutes:Ae(s.radarrAlertMinutes),embyHealthSeconds:Ae(s.embyHealthSeconds),librarySyncMinutes:Ae(s.librarySyncMinutes)}}function Dt(){const{data:s,error:n,loading:t,reload:i}=J("/admin/api/gateway-settings"),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=p.useState(null);p.useEffect(()=>{!c&&s&&d(Ye(s.settings))},[s,c]);const l=(h,g)=>d(b=>b&&{...b,[h]:g}),j=()=>a("save",async()=>{if(!c)return;const h={timezone:c.timezone.trim(),logLevel:c.logLevel.trim(),sessionIdleDays:Re(c.sessionIdleDays,!1),sonarrAlertMinutes:Re(c.sonarrAlertMinutes,!0),radarrAlertMinutes:Re(c.radarrAlertMinutes,!0),embyHealthSeconds:Re(c.embyHealthSeconds,!0),librarySyncMinutes:Re(c.librarySyncMinutes,!0)},g=await r(()=>F.post("/admin/api/gateway-settings",h),"Gateway settings saved.");g&&d(Ye(g.settings)),await i()}),v=()=>a("clear",async()=>{const h=await r(()=>F.post("/admin/api/gateway-settings",{timezone:"",logLevel:"",sessionIdleDays:0,sonarrAlertMinutes:0,radarrAlertMinutes:0,embyHealthSeconds:0,librarySyncMinutes:0}),"Every setting is back to what this container was deployed with.");h&&d(Ye(h.settings)),await i()}),u=s==null?void 0:s.deployed,m=s==null?void 0:s.effective,f=(s==null?void 0:s.logLevels)??[];return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Gateway settings",intro:"Server-level settings for this gateway, changeable without a redeployment."}),e.jsx(U,{message:n}),t||!c||!u||!m?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(T,{title:"This gateway",intro:"What the process is running and what it currently believes.",icon:"chip",tone:"info",actions:e.jsx(M,{tone:"info",children:(s==null?void 0:s.version)??"unknown"}),children:e.jsx(ss,{rows:[{label:"Household timezone",value:m.timezone||"not set"},{label:"Log level",value:m.logLevel},{label:"Sign-in expiry",value:Te(m.sessionIdleDays,"day")},{label:"Emby health probe",value:Te(m.embyHealthSeconds,"second")},{label:"Catalogue sweep",value:Te(m.librarySyncMinutes,"minute")},{label:"Episode alert window",value:Te(m.sonarrAlertMinutes,"minute")},{label:"Film alert window",value:Te(m.radarrAlertMinutes,"minute")}]})}),e.jsxs(T,{title:"Overrides",intro:"Leave a field empty to use the value this container was deployed with, shown beneath it. Changes take effect immediately — nothing here needs a restart.",icon:"sliders",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void j(),children:"Save settings"}),e.jsx($,{busy:o==="clear",onClick:()=>void v(),children:"Use deployed values"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Household timezone",hint:`Deployed: ${u.timezone||"not set"}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`,children:e.jsx("input",{type:"text",value:c.timezone,placeholder:u.timezone,onChange:h=>l("timezone",h.target.value)})}),e.jsx(q,{label:"Log level",hint:`Deployed: ${u.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`,children:e.jsxs("select",{value:c.logLevel,onChange:h=>l("logLevel",h.target.value),children:[e.jsxs("option",{value:"",children:["Deployed (",u.logLevel,")"]}),f.map(h=>e.jsx("option",{value:h,children:h},h))]})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Sign a television out after (days)",hint:`Deployed: ${u.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.sessionIdleDays,placeholder:String(u.sessionIdleDays),onChange:h=>l("sessionIdleDays",h.target.value)})}),e.jsx(q,{label:"Emby health probe (seconds)",hint:`Deployed: ${u.embyHealthSeconds||"off"}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.embyHealthSeconds,placeholder:String(u.embyHealthSeconds),onChange:h=>l("embyHealthSeconds",h.target.value)})})]}),e.jsx("div",{className:"fields",children:e.jsx(q,{label:"Catalogue sweep (minutes)",hint:`Deployed: ${u.librarySyncMinutes||"off"}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.librarySyncMinutes,placeholder:String(u.librarySyncMinutes),onChange:h=>l("librarySyncMinutes",h.target.value)})})}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Episode alert window (minutes)",hint:`Deployed: ${u.sonarrAlertMinutes||"off"}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.sonarrAlertMinutes,placeholder:String(u.sonarrAlertMinutes),onChange:h=>l("sonarrAlertMinutes",h.target.value)})}),e.jsx(q,{label:"Film alert window (minutes)",hint:`Deployed: ${u.radarrAlertMinutes||"off"}. The same, for a film Radarr has just imported.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.radarrAlertMinutes,placeholder:String(u.radarrAlertMinutes),onChange:h=>l("radarrAlertMinutes",h.target.value)})})]}),e.jsxs(fe,{tone:"note",children:["These override the deployed configuration in the database, so they survive a restart — but a deployment rewrites ",e.jsx("code",{children:".env"}),", not this, and the two can then disagree. Anything meant to be permanent belongs in ",e.jsx("code",{children:".env.example"})," ","as well."]}),s!=null&&s.settings.updatedBy?e.jsxs(fe,{children:["Last changed by ",s.settings.updatedBy,s.settings.updatedAt?` on ${new Date(s.settings.updatedAt).toLocaleString("en-NZ")}`:"","."]}):null]})]})]})}const Ft={done:"ok",pending:"warn",failed:"bad"};function Pt(s){const n=s.payload??{};if(n.series){const t=n.episode&&n.episode>0?` S${String(n.season??0).padStart(2,"0")}E${String(n.episode).padStart(2,"0")}`:"";return`${n.series}${t}`}return n.title?n.year?`${n.title} (${n.year})`:n.title:s.key}function Ot(){const{data:s,error:n,loading:t}=J("/admin/api/ingest",{pollMs:15e3});if(t)return e.jsx(V,{rows:1});const i=!!(s!=null&&s.sonarrConfigured||s!=null&&s.radarrConfigured),r=(s==null?void 0:s.recent)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{message:n}),e.jsxs(T,{title:"Webhook activity",intro:"Sonarr and Radarr are what put files on disk, so they are what the catalogue learns from. A notification is recorded the moment it arrives and read into the catalogue once the file has settled — which is why an import appears here before it appears in Emby.",icon:"plug",tone:"info",children:[e.jsx(le,{tiles:[{label:"Sonarr webhook",value:s!=null&&s.sonarrConfigured?"Configured":"Not configured",small:!0,tone:s!=null&&s.sonarrConfigured?"ok":"warn",icon:"tv"},{label:"Radarr webhook",value:s!=null&&s.radarrConfigured?"Configured":"Not configured",small:!0,tone:s!=null&&s.radarrConfigured?"ok":"warn",icon:"play"},{label:"Waiting",value:w((s==null?void 0:s.counts.pending)??0),icon:"clock",tone:"note"},{label:"Given up on",value:w((s==null?void 0:s.counts.failed)??0),icon:"alert",tone:"bad"},{label:"Settle delay",value:`${(s==null?void 0:s.settleSeconds)??0}s`,small:!0,icon:"history",tone:"data"}]}),i?null:e.jsxs("p",{className:"muted",children:["Neither hook has a token, so both answer 404 and nothing is recorded here. Set MEMBY_SONARR_WEBHOOK_TOKEN and MEMBY_RADARR_WEBHOOK_TOKEN, then point each *arr at"," ",e.jsx("code",{children:"/hooks/sonarr"})," and ",e.jsx("code",{children:"/hooks/radarr"}),". Until then the catalogue sweep below is the only way a new title is found."]}),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:"Source"}),e.jsx("th",{children:"What"}),e.jsx("th",{children:"Why"}),e.jsx("th",{children:"State"}),e.jsx("th",{children:"Outcome"}),e.jsx("th",{className:"num",children:"Tries"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:r.length===0?e.jsx(X,{columns:8,children:i?"Nothing has been imported, upgraded, renamed or deleted since this was switched on.":"No webhook is configured."}):r.map(o=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(o.updatedAt)}),e.jsx("td",{className:"muted",children:o.source||"—"}),e.jsx("td",{children:Pt(o)}),e.jsx("td",{className:"muted",children:o.reason}),e.jsx("td",{children:e.jsx(M,{tone:Ft[o.state]??"warn",children:o.state})}),e.jsx("td",{className:"muted",children:o.outcome||"—"}),e.jsx("td",{className:"num",children:o.attempts}),e.jsx("td",{className:"muted",children:o.lastError||""})]},o.key))})]})})]})]})}function _t(){const{status:s,error:n,loading:t}=oe(),i=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Imports",intro:"What Sonarr and Radarr said changed, and the catalogue sweep that reconciles everything they do not manage."}),e.jsx(U,{message:n}),e.jsx(Ot,{}),t?e.jsx(V,{rows:1}):e.jsx(T,{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. With both webhooks wired up this is reconciliation — media dropped in by hand, a title edited in Emby, a notification that never arrived — rather than how new titles are found.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(X,{columns:8,children:"Nothing has been imported yet."}):i.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(r.startedAt)}),e.jsx("td",{children:r.kind}),e.jsx("td",{className:"muted",children:r.trigger}),e.jsx("td",{children:e.jsx(M,{tone:r.status==="success"?"ok":r.status==="running"?"warn":"bad",children:r.status})}),e.jsx("td",{className:"num",children:w(r.itemsSeen)}),e.jsx("td",{className:"num",children:w(r.itemsUpserted)}),e.jsx("td",{className:"num",children:w(r.itemsRemoved)}),e.jsx("td",{className:"muted",children:r.error||""})]},r.id||r.startedAt))})]})})})]})}const Ut={gateway:"quiet",auth:"note",playback:"info",media:"info",emby:"data",library:"data",subtitles:"data",search:"note",tracearr:"idle",credits:"idle",integrations:"note",requests:"info",home:"quiet"},tn=s=>Ut[s]??"quiet",Wt={admin:["gateway","Gateway","Admin"],installer:["gateway","Gateway","Installer"],api:["gateway","Gateway","API"],health:["gateway","Gateway","Health"],status:["gateway","Gateway","Status"],maintenance:["gateway","Gateway","Maintenance"],"quiet-time":["gateway","Gateway","Quiet time"],webhooks:["gateway","Gateway","Webhooks"],scheduler:["gateway","Gateway","Scheduler"],settings:["gateway","Gateway","Settings"],updates:["gateway","Gateway","Updates"],analytics:["gateway","Gateway","Analytics"],auth:["auth","Auth","Session"],devices:["auth","Auth","Devices"],playback:["playback","Playback","Session"],screensaver:["media","Media","Screensaver"],artwork:["media","Media","Artwork"],details:["media","Media","Details"],search:["search","Search","Query"],home:["home","Home","Rows"],"my-shows":["home","Home","My shows"],recommendations:["tracearr","Tracearr","Recommendations"],"for-you":["tracearr","Tracearr","For you"],library:["library","Library","Sync"],credits:["credits","Credits","Scanner"],ratings:["media","Media","Ratings"],integrations:["integrations","Integrations","Arr"],requests:["requests","Requests","Media"],"emby-health":["emby","Emby","Health"]},Bt=[[/^emby |emby (reachable|unreachable|health)/,["emby","Emby","API"]],[/subtitle/,["subtitles","Subtitles","Provider"]],[/^sonarr|sonarr /,["integrations","Integrations","Sonarr"]],[/^radarr|radarr /,["integrations","Integrations","Radarr"]],[/^tracearr/,["tracearr","Tracearr","Signals"]],[/^credits/,["credits","Credits","Scanner"]],[/^library sync/,["library","Library","Sync"]],[/^(signed in|signed out|sign-in rejected)/,["auth","Auth","Session"]],[/^device /,["auth","Auth","Devices"]],[/^(playback (requested|started|stopped|progress))/,["playback","Playback","Session"]],[/^(next episode resolved|trailer playback|trickplay)/,["playback","Playback","Player"]],[/^scheduled task/,["gateway","Gateway","Scheduler"]],[/^(update offered|update policy)/,["gateway","Gateway","Updates"]]];function Vt(s,n){const t=n.toLowerCase();for(const[r,o]of Bt)if(r.test(t))return o;const i=Wt[s];return i||(s?["gateway","Gateway",Ke(s.replace(/[-_]/g," "))]:["gateway","Gateway","Server"])}const Ht={h:36e5,m:6e4,s:1e3,ms:1,us:.001,µs:.001,ns:1e-6};function ts(s){if(typeof s=="number")return Number.isFinite(s)?s:null;if(typeof s!="string"||!s)return null;const n=s.matchAll(/([0-9]*\.?[0-9]+)(ns|µs|us|ms|h|m|s)/g);let t=0,i=!1;for(const r of n){const o=r[2]?Ht[r[2]]:void 0;o!==void 0&&(t+=Number(r[1])*o,i=!0)}return i?t:null}function os(s){return s<1?"<1 ms":s<1e3?`${Math.round(s)} ms`:s<1e4?`${(s/1e3).toFixed(1)} s`:s<6e4?`${Math.round(s/1e3)} s`:`${Math.floor(s/6e4)}m ${Math.round(s%6e4/1e3)}s`}const zt=s=>s>=3e3?"bad":s>=1e3?"warn":null,Kt={200:"OK",201:"Created",202:"Accepted",204:"No Content",206:"Partial Content",301:"Moved Permanently",302:"Found",304:"Not Modified",400:"Bad Request",401:"Unauthorized",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",409:"Conflict",412:"Precondition Failed",418:"Client Closed Request",426:"Upgrade Required",429:"Too Many Requests",499:"Client Closed Request",500:"Internal Server Error",502:"Bad Gateway",503:"Service Unavailable",504:"Gateway Timeout"};function Gt(s){const n=Kt[s];return n||(s>=500?"Server Error":s>=400?"Client Error":s>=300?"Redirected":s>=200?"OK":"Response")}const Zt=s=>s>=500?"bad":s>=400?"warn":s>=300?"quiet":"ok",Jt={"/healthz":"Health probe","/readyz":"Readiness probe","/v1/status":"Status poll","/v1/home":"Home rows","/v1/features":"Features","/v1/preferences":"Preferences","/v1/theme":"Theme","/v1/magic":"Magic pick","/v1/calendar":"TV calendar","/v1/search":"Search","/v1/update":"Update check"},Yt=s=>/\d/.test(s)||s.length>24;function Ms(s){const n=Jt[s];if(n)return n;const t=s.split("/").filter(r=>r&&r!=="v1"&&r!=="api");t[0]==="admin"&&t.shift();const i=t.filter(r=>!Yt(r));return i.length===0?s:Ke(i.join(" ").replace(/[-_.]/g," ").replace(/\s+/g," ").trim())}const Ke=s=>s&&s.charAt(0).toUpperCase()+s.slice(1),Oe=s=>Ke(s.replace(/_/g," ")),de=s=>s==null?"":String(s),an=new Set(["","unknown","none","null","","0"]),ge=s=>!an.has(de(s).toLowerCase()),Qt=["title","series","name","query","item_title","file"],Xt={directplay:"ok",direct:"ok",directstream:"ok",transcode:"warn",transcoding:"warn"};function ei(s,n,t){if(t!==null)return{label:`${t} ${Gt(t)}`,short:String(t),tone:Zt(t)};if(ge(s.error))return{label:"Failed",short:"Failed",tone:n==="WARN"?"warn":"bad"};const i=de(s.play_method).toLowerCase().replace(/[\s_-]/g,"");if(i&&!an.has(i)){const r=Ke(de(s.play_method).replace(/([a-z])([A-Z])/g,"$1 $2"));return{label:r,short:r,tone:Xt[i]??"info"}}if(ge(s.cache)){const r=/hit|true|yes/i.test(de(s.cache));return{label:r?"Cached":"Cache miss",short:r?"Cached":"Miss",tone:r?"data":"quiet"}}return n==="ERROR"?{label:"Failed",short:"Failed",tone:"bad"}:n==="WARN"?{label:"Warning",short:"Warning",tone:"warn"}:null}function si(s){const n=[];ge(s.user)&&n.push(de(s.user)),ge(s.device)&&n.push(de(s.device));const t=ts(s.marker_ms);t!==null&&t>0&&n.push(`Start ${Es(t)}`);const i=ts(s.position);return i!==null&&i>0&&n.push(`At ${Es(i)}`),ge(s.watched)&&n.push(`${de(s.watched)} watched`),ge(s.reason)&&n.push(de(s.reason)),n.slice(0,3).join(" · ")}function Es(s){const n=Math.round(s/1e3),t=Math.floor(n/3600),i=Math.floor(n%3600/60),r=n%60,o=a=>String(a).padStart(2,"0");return t>0?`${t}:${o(i)}:${o(r)}`:`${i}:${o(r)}`}const rn=[{title:"Request",keys:["method","path","query_keys","status","cache","client","protocol","host"]},{title:"Context",keys:["user","user_id","device","device_id","item","title","series","type","play_method","play_session_id","media_source_id","position","resume","runtime","watched","subtitles","subtitle_track","subtitle_language","event_name"]},{title:"Diagnostics",keys:["error","stack","correlation","version","gateway_version","duration"]}],ni=new Set(rn.flatMap(s=>s.keys)),ti=s=>ni.has(s),ii=new Intl.DateTimeFormat("en-NZ",{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}),ai=new Intl.DateTimeFormat("en-NZ",{weekday:"short",day:"numeric",month:"short"}),As=new WeakMap;function Ie(s){const n=As.get(s);if(n)return n;const t=ri(s);return As.set(s,t),t}function ri(s){const n=s.attributes??{},t=s.message??"",[i,r,o]=Vt(de(n.component),t),a=de(n.path),c=a?de(n.method).toUpperCase():"",d=Number(n.status),l=a&&Number.isFinite(d)&&d>0?d:null,j=t==="request"&&!!a,v=Qt.find(R=>ge(n[R])),u=v?de(n[v]):"";let m,f;j?(m=c||"HTTP",f=Ms(a)):a&&c?(m=c,f=u?`${Oe(t)} · ${u}`:`${Oe(t)} — ${Ms(a)}`):(m="",f=u?`${Oe(t)} · ${u}`:Oe(t));const h=ts(n.duration??n.duration_ms??n.negotiation_duration),g=ge(n.error)?de(n.error):"",b=si(n),k=new Date(s.occurredAt),x=Object.entries(n);return{serviceKey:i,service:r,component:o,action:m,summary:f,context:b,detail:g,result:ei(n,s.level,l),durationMs:h,method:c,status:l,eventKey:t,level:s.level,time:`${ii.format(k)}.${String(k.getMilliseconds()).padStart(3,"0")}`,day:ai.format(k),dayKey:k.toDateString(),tall:!!g||!!b&&!j,haystack:[t,r,o,f,b,g,...x.flat().map(de)].join(" ").toLowerCase(),fields:x,attributes:n}}const Rs={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},Qe={level:"INFO",service:"",component:"",event:"",method:"",status:"",slower:0,text:""};function li(s,n){if(!s)return!0;if(n===null)return!1;if(s==="error")return n>=400;const t=Number(s[0]);return Math.floor(n/100)===t}function oi(s,n,t){if((Rs[s.level]??0)<(Rs[n.level]??20))return!1;const i=Ie(s);return!(n.service&&i.serviceKey!==n.service||n.component&&i.component!==n.component||n.event&&i.eventKey!==n.event||n.method&&i.method!==n.method||!li(n.status,i.status)||n.slower>0&&(i.durationMs??0)({key:o,label:a})).sort((o,a)=>o.label.localeCompare(a.label)),components:[...t].sort((o,a)=>o.localeCompare(a)),events:[...i].sort((o,a)=>o.localeCompare(a)),methods:[...r].sort((o,a)=>o.localeCompare(a))}}function di(s,n){var i;const t=[];if(s.service){const r=((i=n.find(o=>o.key===s.service))==null?void 0:i.label)??s.service;t.push({key:"service",label:`Service: ${r}`})}return s.component&&t.push({key:"component",label:`Component: ${s.component}`}),s.event&&t.push({key:"event",label:`Event: ${s.event}`}),s.method&&t.push({key:"method",label:`Method: ${s.method}`}),s.status&&t.push({key:"status",label:`Status: ${s.status==="error"?"≥400":s.status}`}),s.slower>0&&t.push({key:"slower",label:`Duration: >${os(s.slower)}`}),s.text&&t.push({key:"text",label:`Search: ${s.text}`}),t}const _e=2e4,hi=5e3,Ts=30,$s=48,Is=26,ui=31,Ls=10,mi=[{value:"TRACE",label:"Everything"},{value:"DEBUG",label:"Debug+"},{value:"INFO",label:"Info+"},{value:"WARN",label:"Warnings+"},{value:"ERROR",label:"Errors only"}],pi=[{value:"",label:"Any result"},{value:"2xx",label:"Success (2xx)"},{value:"3xx",label:"Redirect (3xx)"},{value:"4xx",label:"Client error (4xx)"},{value:"5xx",label:"Server error (5xx)"},{value:"error",label:"Failed (≥400)"}],xi=[{value:0,label:"Any duration"},{value:100,label:"Slower than 100 ms"},{value:500,label:"Slower than 500 ms"},{value:1e3,label:"Slower than 1 s"},{value:3e3,label:"Slower than 3 s"}],qs=s=>s.replace(/_/g," ");function Ue({onPick:s,className:n,title:t,children:i,...r}){return e.jsx("button",{type:"button",className:`logfacet ${n}`,title:t,onClick:s,...r,children:i})}const ji=p.memo(function({event:n,view:t,top:i,height:r,selected:o,onInspect:a,onFilter:c}){const d=t.durationMs!==null?zt(t.durationMs):null;return e.jsxs("div",{className:"logrow","data-level":t.level,"data-selected":o||void 0,style:{transform:`translateY(${i}px)`,height:`${r}px`},children:[e.jsx("time",{className:"logrow-time",title:n.occurredAt,children:t.time}),e.jsx(Ue,{className:"logrow-level","data-level":t.level,title:`Show ${t.level} and above`,onPick:()=>c({level:t.level}),children:t.level}),e.jsxs("span",{className:"logrow-place",children:[e.jsx(Ue,{className:"logrow-service","data-tone":tn(t.serviceKey),title:`Filter to ${t.service}`,onPick:()=>c({service:t.serviceKey,component:""}),children:t.service}),e.jsx("span",{className:"logrow-sep","aria-hidden":"true",children:"›"}),e.jsx(Ue,{className:"logrow-component",title:`Filter to ${t.component}`,onPick:()=>c({component:t.component}),children:t.component})]}),e.jsxs("button",{type:"button",className:"logrow-summary",title:t.detail||t.summary,onClick:()=>a(n.sequence),children:[e.jsxs("span",{className:"logrow-line",children:[t.action?e.jsx("b",{className:"logrow-action","data-method":t.method||void 0,children:t.action}):null,e.jsx("span",{className:"logrow-text",children:t.summary})]}),t.detail?e.jsxs("span",{className:"logrow-error",children:["↳ ",t.detail]}):t.context?e.jsx("span",{className:"logrow-context",children:t.context}):null]}),e.jsx("span",{className:"logrow-result",children:t.result?e.jsx(Ue,{className:"logrow-verdict","data-tone":t.result.tone,title:t.status!==null?`Filter to ${t.status}`:`Filter to ${t.eventKey}`,onPick:()=>t.status!==null?c({status:`${Math.floor(t.status/100)}xx`}):c({event:t.eventKey}),children:t.result.label}):null}),e.jsx("span",{className:"logrow-duration","data-tone":d??void 0,children:t.durationMs!==null?os(t.durationMs):""})]})});function vi({event:s,view:n,onClose:t}){const[i,r]=p.useState(!1),o=n.fields.filter(([d])=>!ti(d)&&d!=="component"),a=async()=>{try{await navigator.clipboard.writeText(JSON.stringify(s,null,2)),r(!0),window.setTimeout(()=>r(!1),1600)}catch{r(!1)}},c=rn.map(d=>({title:d.title,rows:d.keys.map(l=>[l,n.attributes[l]]).filter(([,l])=>l!=null&&String(l)!=="")})).filter(d=>d.rows.length>0);return e.jsxs("section",{className:"logdrawer","aria-label":`Log record ${s.sequence}`,children:[e.jsxs("header",{className:"logdrawer-head",children:[e.jsxs("div",{children:[e.jsxs("p",{className:"logdrawer-place",children:[e.jsx("span",{className:"logrow-service","data-tone":tn(n.serviceKey),children:n.service}),e.jsx("span",{className:"logrow-sep","aria-hidden":"true",children:"›"}),n.component]}),e.jsx("b",{children:n.summary}),n.detail?e.jsx("p",{className:"logdrawer-error",children:n.detail}):null]}),e.jsxs("div",{className:"logdrawer-actions",children:[e.jsx($,{size:"sm",variant:"quiet",onClick:a,icon:"download",children:i?"Copied":"Copy JSON"}),e.jsx($,{size:"sm",variant:"quiet",onClick:t,icon:"close",children:"Close"})]})]}),e.jsxs("div",{className:"logdrawer-grid",children:[e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Overview"}),e.jsxs("dl",{children:[e.jsx("dt",{children:"Time"}),e.jsxs("dd",{children:[n.day," ",n.time]}),e.jsx("dt",{children:"Level"}),e.jsx("dd",{children:n.level}),e.jsx("dt",{children:"Service"}),e.jsxs("dd",{children:[n.service," › ",n.component]}),e.jsx("dt",{children:"Event"}),e.jsx("dd",{children:n.eventKey}),n.result?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Result"}),e.jsx("dd",{children:n.result.label})]}):null,n.durationMs!==null?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Duration"}),e.jsx("dd",{children:os(n.durationMs)})]}):null,e.jsx("dt",{children:"Record"}),e.jsxs("dd",{children:["#",s.sequence]})]})]}),c.map(d=>e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:d.title}),e.jsx("dl",{children:d.rows.map(([l,j])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:qs(l)}),e.jsx("dd",{children:String(j)})]},l))})]},d.title)),o.length?e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Details"}),e.jsx("dl",{children:o.map(([d,l])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:qs(d)}),e.jsx("dd",{children:String(l)})]},d))})]}):null]}),e.jsxs("details",{className:"logdrawer-raw",children:[e.jsx("summary",{children:"Raw event"}),e.jsx("pre",{children:JSON.stringify(s,null,2)})]})]})}function gi(){var ds;const[s,n]=p.useState([]),[t,i]=p.useState(0),[r,o]=p.useState(!1),[a,c]=p.useState(0),[d,l]=p.useState(Qe),[j,v]=p.useState(""),[u,m]=p.useState({top:0,height:600}),[f,h]=p.useState(!0),[g,b]=p.useState(null),k=p.useDeferredValue(d.text.trim().toLowerCase()),x=p.useRef(0),y=p.useRef(!1),R=p.useRef(0),S=p.useRef(null),H=p.useRef(!0),E=p.useRef(void 0),I=p.useRef([]),G=p.useRef(r);p.useEffect(()=>{G.current=r},[r]);const se=p.useCallback(C=>{n(O=>{const K=O.concat(C);return K.length>_e?K.slice(K.length-_e):K})},[]),ne=p.useCallback(async()=>{if(y.current||document.hidden)return;y.current=!0;const C=R.current,O=[];let K=0;try{let ce=0,ve;do ve=await F.get(`/admin/api/events?after=${x.current}&limit=1000`),x.current=ve.next||x.current,K+=ve.dropped||0,O.push(...ve.events??[]),ce+=1;while(ve.hasMore&&ce<20);v("")}catch(ce){v(ce instanceof Error?ce.message:String(ce))}finally{O.length>0&&C===R.current&&(G.current?(I.current=I.current.concat(O),I.current.length>_e&&(I.current=I.current.slice(I.current.length-_e)),c(I.current.length)):se(O)),K>0&&C===R.current&&i(ce=>ce+K),y.current=!1}},[se]);p.useEffect(()=>{let C;const O=()=>{window.clearInterval(C),C=document.hidden?void 0:window.setInterval(()=>void ne(),hi)},K=()=>{O(),document.hidden||ne()};return ne(),O(),document.addEventListener("visibilitychange",K),()=>{window.clearInterval(C),document.removeEventListener("visibilitychange",K)}},[ne]);const ue=p.useCallback(()=>{G.current=!0,o(!0)},[]),A=p.useCallback(()=>{G.current=!1;const C=I.current;I.current=[],c(0),o(!1),C.length&&se(C)},[se]),L=p.useCallback(C=>{l(O=>({...O,...C}))},[]),N=p.useMemo(()=>s.filter(C=>oi(C,d,k)),[s,d,k]),D=p.useMemo(()=>{const C=new Float64Array(N.length+1),O=new Uint8Array(N.length),K=new Uint8Array(N.length);let ce=0,ve="";for(let Ne=0;Ne{let C=0,O=N.length;for(;C>1;(D.tops[K]??0)+(D.heights[K]??0)<=_?C=K+1:O=K}return Math.max(0,C-Ls)},[D,_,N.length]),pe=p.useMemo(()=>{const C=_+u.height;let O=ie;for(;O{const C=[];for(let O=ie;Os.find(C=>C.sequence===g),[s,g]),Ge=p.useCallback(()=>{const C=S.current;C&&(H.current=!0,C.scrollTop=C.scrollHeight,h(!0),m({top:C.scrollTop,height:C.clientHeight}))},[]);p.useLayoutEffect(()=>{const C=S.current;!C||!H.current||(C.scrollTop=C.scrollHeight,m({top:C.scrollTop,height:C.clientHeight}))},[Fe,D.total]),p.useEffect(()=>()=>window.cancelAnimationFrame(E.current??0),[]);const cn=()=>{const C=S.current;if(!C)return;const O=C.scrollHeight-C.scrollTop-C.clientHeight<$s;H.current=O,h(O),window.cancelAnimationFrame(E.current??0),E.current=window.requestAnimationFrame(()=>{m({top:C.scrollTop,height:C.clientHeight})})},dn=()=>{const C=new Blob([JSON.stringify(N,null,2)],{type:"application/json"}),O=document.createElement("a");O.href=URL.createObjectURL(C),O.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,O.click(),window.setTimeout(()=>URL.revokeObjectURL(O.href),1e3)},Me=p.useMemo(()=>ci(s),[s]),cs=di(d,Me.services);return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(U,{message:j}),e.jsxs(T,{children:[e.jsxs("div",{className:"logbar",children:[e.jsxs("div",{className:"logbar-filters",children:[e.jsxs("select",{"aria-label":"Service",value:d.service,onChange:C=>L({service:C.target.value,component:""}),children:[e.jsx("option",{value:"",children:"All services"}),Me.services.map(C=>e.jsx("option",{value:C.key,children:C.label},C.key))]}),e.jsxs("select",{"aria-label":"Component",value:d.component,onChange:C=>L({component:C.target.value}),children:[e.jsx("option",{value:"",children:"All components"}),Me.components.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Level",value:d.level,onChange:C=>L({level:C.target.value}),children:mi.map(C=>e.jsx("option",{value:C.value,children:C.label},C.value))}),e.jsxs("select",{"aria-label":"Event",value:d.event,onChange:C=>L({event:C.target.value}),children:[e.jsx("option",{value:"",children:"All events"}),Me.events.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Result",value:d.status,onChange:C=>L({status:C.target.value}),children:pi.map(C=>e.jsx("option",{value:C.value,children:C.label},C.value))}),e.jsxs("select",{"aria-label":"Method",value:d.method,onChange:C=>L({method:C.target.value}),children:[e.jsx("option",{value:"",children:"Any method"}),Me.methods.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Duration",value:String(d.slower),onChange:C=>L({slower:Number(C.target.value)}),children:xi.map(C=>e.jsx("option",{value:String(C.value),children:C.label},C.value))}),e.jsxs("label",{className:"logsearch",children:[e.jsx(Y,{name:"search"}),e.jsx("input",{type:"search",value:d.text,"aria-label":"Search logs",placeholder:"Search person, title, service, component, path, request ID…",onChange:C=>L({text:C.target.value})})]})]}),e.jsxs("div",{className:"logbar-actions",children:[e.jsx($,{size:"sm",variant:"quiet",onClick:()=>r?A():ue(),icon:r?"play":"clock",children:r?a?`Resume (${w(a)})`:"Resume":"Pause"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{R.current+=1,I.current=[],c(0),n([]),i(0),b(null)},children:"Clear view"}),e.jsx($,{size:"sm",variant:"quiet",onClick:dn,icon:"download",title:"Export the rows matching the current filters, as delivered by the gateway",children:"Export JSON"})]})]}),cs.length?e.jsxs("div",{className:"logchips",children:[cs.map(C=>e.jsxs("button",{type:"button",className:"logchip",onClick:()=>L({[C.key]:Qe[C.key]}),children:[C.label,e.jsx(Y,{name:"close"})]},C.key)),e.jsx("button",{type:"button",className:"logchip logchip-clear",onClick:()=>l(Qe),children:"Clear all"})]}):null,e.jsxs("div",{className:"logshell",children:[e.jsxs("div",{className:"logview",ref:S,onScroll:cn,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:"Service"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Result"}),e.jsx("span",{children:"Duration"})]}),N.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match these filters."}):e.jsx("div",{className:"logbody",style:{height:`${D.total}px`},children:De.map(({event:C,view:O,index:K})=>e.jsxs(p.Fragment,{children:[D.divider[K]?e.jsx("div",{className:"logday",style:{transform:`translateY(${(D.tops[K]??0)-Is}px)`},children:e.jsx("span",{children:O.day})}):null,e.jsx(ji,{event:C,view:O,top:D.tops[K]??0,height:D.heights[K]??Ts,selected:C.sequence===g,onInspect:b,onFilter:L})]},C.sequence))})]}),!f&&N.length>0?e.jsxs("button",{type:"button",className:"logtail",onClick:Ge,children:[e.jsx(Y,{name:"caret"}),"Jump to latest"]}):null]}),e.jsxs("p",{className:"hint",children:[w(s.length)," retained · ",w(N.length)," matching",N.length?` · ${w(De.length)} rows mounted`:"",t?` · ${w(t)} overwritten before delivery`:"",r?` · paused${a?`, ${w(a)} held`:""}`:""]}),me?e.jsx(vi,{event:me,view:Ie(me),onClose:()=>b(null)}):s.length?e.jsx(fe,{children:"Select a row to see the full record — request, context, diagnostics and raw event."}):null]})]})}const Ds=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","magic_movie","notifications","profiles","settings"];function ye(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function bi(){const[s,n]=p.useState(30),[t,i]=p.useState(""),r=is(),o=p.useMemo(()=>`/admin/api/journeys${qe({days:s,userId:t})}`,[s,t]),{data:a,error:c,loading:d}=J(o),l=a==null?void 0:a.stats,j=(a==null?void 0:a.users)??[],v=(a==null?void 0:a.actions)??[],u=(a==null?void 0:a.paths)??[],m=u[0],f=p.useMemo(()=>{const h=new Map(((a==null?void 0:a.features)??[]).map(b=>[b.feature,b])),g=new Map(Ds.map((b,k)=>[b,k]));return[...new Set([...Ds,...h.keys()])].map(b=>({name:b,stat:h.get(b)})).sort((b,k)=>{var y,R;const x=(((y=k.stat)==null?void 0:y.uses)??0)-(((R=b.stat)==null?void 0:R.uses)??0);return x||(g.get(b.name)??Number.MAX_SAFE_INTEGER)-(g.get(k.name)??Number.MAX_SAFE_INTEGER)})},[a==null?void 0:a.features]);return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"User journeys",intro:"How viewers move through Memby, use features and complete flows."}),e.jsx(U,{message:c}),e.jsx(T,{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:h=>n(Number(h.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(q,{label:"User",children:e.jsxs("select",{value:t,onChange:h=>{const g=h.target.value;i(g),g&&r(`/admin/journeys/${encodeURIComponent(g)}`)},children:[e.jsx("option",{value:"",children:"All users"}),j.map(h=>e.jsx("option",{value:h.userId,children:h.username||h.userId},h.userId))]})})]}),children:d?e.jsx(V,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"journeys",value:w(l==null?void 0:l.journeys),icon:"list",tone:"data"},{label:"viewers",value:w(l==null?void 0:l.viewers),icon:"people",tone:"info"},{label:"completion",value:We(l==null?void 0:l.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:w(l==null?void 0:l.abandoned),icon:"alert",tone:"note"},{label:"active now",value:w(l==null?void 0:l.active),icon:"pulse",tone:"info"},{label:"average steps",value:((l==null?void 0:l.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:ke(l==null?void 0:l.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(a==null?void 0:a.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:We(l==null?void 0:l.completionRate)})]}),e.jsx(qn,{value:(l==null?void 0:l.completed)??0,total:(l==null?void 0:l.journeys)??0}),e.jsxs("p",{children:[w(l==null?void 0:l.completed)," completed · ",w(l==null?void 0:l.abandoned)," abandoned ·"," ",w(l==null?void 0:l.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:m?`${ye(m.from)} → ${ye(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${w(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{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:v.length===0?e.jsx(X,{columns:3,children:"No significant actions in this window."}):v.map(h=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ye(h.action)}),e.jsx("span",{className:"table-sub",children:ye(h.category)})]}),e.jsx("td",{className:"num",children:w(h.events)}),e.jsx("td",{className:"num",children:w(h.journeys)})]},`${h.category}:${h.action}`))})]})})}),e.jsx(T,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(X,{columns:2,children:"No repeated paths in this window."}):u.map((h,g)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[ye(h.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",ye(h.to)]}),e.jsx("td",{className:"num",children:w(h.count)})]},`${h.from}:${h.to}:${g}`))})]})})})]}),e.jsx(T,{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:h,stat:g})=>{const b=(g==null?void 0:g.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:ye(h)}),e.jsx("td",{className:"num",children:w(b)}),e.jsx("td",{className:"muted nowrap",children:g?P(g.lastUsedAt):"—"}),e.jsx("td",{children:b===0?e.jsx(M,{tone:"warn",children:"not used"}):b<3?e.jsx(M,{tone:"note",children:"rare"}):e.jsx(M,{tone:"ok",children:"used"})})]},h)})})]})})}),t?null:e.jsx(T,{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 be=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},ln=s=>be((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),fi=s=>{const n=s.find(t=>t.category==="playback"&&t.action==="request");return n!=null&&n.source?be(n.source):ln(s[0])},Fs=s=>s.itemName?`${be(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${be(s.source)} → ${be(s.target)}`:ln(s),yi=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"?"Asked to watch":"Requested",stop:"Left the player",start:s.category==="playback"?s.outcome==="failure"?"Playback failed":"Started watching":"Started",complete:s.category==="playback"?s.outcome==="completed"?"Finished watching":"Stopped watching":"Completed"})[s.action]??be(s.action);function wi(s){var i;const n=[...s].reverse().find(r=>r.category==="playback"&&r.outcome);if((n==null?void 0:n.outcome)==="failure")return{label:"playback failed",tone:"warn"};if((n==null?void 0:n.outcome)==="completed")return{label:"watched",tone:"ok"};if((n==null?void 0:n.outcome)==="abandoned")return{label:"stopped part-way",tone:"note"};if((n==null?void 0:n.outcome)==="success")return{label:"watched",tone:"ok"};const t=(i=[...s].reverse().find(r=>r.outcome))==null?void 0:i.outcome;return t==="success"||t==="completed"?{label:be(t),tone:"ok"}:t==="failure"||t==="cancelled"||t==="abandoned"?{label:be(t),tone:"note"}:s.some(r=>r.action==="stop"&&r.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function ki(s){const n=s.reduce((t,i,r)=>(i.category==="playback"&&i.action==="request"&&t.push(r),t),[]);return n.length===0?[s]:n.map((t,i)=>s.slice(i===0?0:t,n[i+1]??s.length))}function Ni(){var d,l;const{userId:s=""}=Ve(),n=p.useMemo(()=>`/admin/api/journeys${qe({days:90,userId:s})}`,[s]),{data:t,error:i,loading:r}=J(n),o=((l=(d=t==null?void 0:t.users)==null?void 0:d.find(j=>j.userId===s))==null?void 0:l.username)||s,a=p.useMemo(()=>{const j=new Map;for(const v of(t==null?void 0:t.events)??[])j.set(v.journeyId,[...j.get(v.journeyId)??[],v]);return[...j.values()].map(v=>v.sort((u,m)=>u.sequence-m.sequence)).sort((v,u)=>{var m,f;return(((m=u[0])==null?void 0:m.occurredAt)??"").localeCompare(((f=v[0])==null?void 0:f.occurredAt)??"")})},[t==null?void 0:t.events]),c=a.flatMap(j=>ki(j).map((v,u)=>{var m;return{events:v,key:`${(m=j[0])==null?void 0:m.journeyId}:${u}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:`${o}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(re,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(U,{message:i}),r?e.jsx(V,{}):e.jsx(T,{title:"Viewing journeys",intro:`${a.length} app session${a.length===1?"":"s"} · ${c.length} viewing journey${c.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:c.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):c.map((j,v)=>{const u=j.events,m=u[0],f=[...u].reverse().find(g=>g.itemName||g.action==="select"||g.category==="playback"&&g.action==="request"),h=wi(u);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:P(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",v+1," · ",u.length," recorded steps"]})]}),e.jsx(M,{tone:h.tone,children:h.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(Y,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:fi(u)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(Y,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?Fs(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(Y,{name:h.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:h.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:u.map(g=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":g.action}),e.jsxs("div",{children:[e.jsx("b",{children:yi(g)}),e.jsx("span",{children:Fs(g)})]}),e.jsx("time",{children:P(g.occurredAt)})]},`${g.journeyId}:${g.sequence}`))})]},j.key)})})})]})}function Ps(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function Si(){const[s,n]=p.useState(30),{data:t,error:i,loading:r}=J(`/admin/api/analytics?days=${s}`),o=(t==null?void 0:t.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(U,{message:i}),e.jsx(T,{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:a=>n(Number(a.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:r?e.jsx(V,{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:o.length===0?e.jsx(X,{columns:8,children:"No events in this window."}):o.map(a=>e.jsxs("tr",{children:[e.jsx("td",{children:Ps(a.rowId)}),e.jsx("td",{className:"muted",children:Ps(a.rowKind)}),e.jsx("td",{className:"num",children:ke(a.dwellMs)}),e.jsx("td",{className:"num",children:w(a.impressions)}),e.jsx("td",{className:"num",children:w(a.focuses)}),e.jsx("td",{className:"num",children:w(a.selects)}),e.jsx("td",{className:"num",children:We(a.selectRate)}),e.jsx("td",{className:"num",children:w(a.viewers)})]},`${a.rowId}:${a.rowKind}`))})]})})})]})}function Ci(){const[s,n]=p.useState(7),{data:t,error:i,loading:r}=J(`/admin/api/searches?days=${s}`),o=(t==null?void 0:t.terms)??[],a=(t==null?void 0:t.recent)??[],c=t==null?void 0:t.totals;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Searches",intro:"What the household has been looking for, and what it searched just now."}),e.jsx(U,{message:i}),e.jsx(le,{tiles:[{label:"searches",value:w((c==null?void 0:c.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:w((c==null?void 0:c.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:w((c==null?void 0:c.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(t==null?void 0:t.retentionDays)??30} days`,small:!0,icon:"clock"}]}),r?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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:o.length===0?e.jsx(X,{columns:4,children:"Nothing searched in this window."}):o.map(d=>e.jsxs("tr",{children:[e.jsx("td",{children:d.query}),e.jsx("td",{className:"num",children:w(d.searches)}),e.jsx("td",{className:"num",children:w(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:P(d.lastAt)})]},d.query))})]})})}),e.jsx(T,{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:a.length===0?e.jsx(X,{columns:3,children:"No searches in this window."}):a.map((d,l)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:P(d.occurredAt)}),e.jsx("td",{children:d.username||e.jsx(M,{tone:"warn",children:d.userId||"unknown"})}),e.jsx("td",{children:d.query})]},`${d.occurredAt}:${l}`))})]})})})]})]})}function Os(s,n){if(n===0)return s>0?"new this week":"no change";const t=Math.round((s-n)/n*100);return`${t>0?"+":""}${t}% vs last week`}function Mi(){const{data:s,error:n,loading:t}=J("/admin/api/views",{pollMs:6e4}),i=(s==null?void 0:s.daily)??[],r=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(U,{message:n}),t?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:Os((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:w(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:Os((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:w(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(T,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(X,{columns:3,children:"No home-screen visits yet."}):i.map(o=>e.jsxs("tr",{children:[e.jsx("td",{children:o.label}),e.jsx("td",{className:"num",children:w(o.visits)}),e.jsx("td",{className:"num",children:w(o.viewers)})]},o.label))})]})})}),e.jsx(T,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:r.length===0?e.jsx(X,{columns:3,children:"No home-screen visits yet today."}):r.map(o=>e.jsxs("tr",{children:[e.jsx("td",{children:o.label}),e.jsx("td",{className:"num",children:w(o.visits)}),e.jsx("td",{className:"num",children:w(o.viewers)})]},o.label))})]})})})]})]})}const Ei=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function Ai(){var r;const s=J("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:t}=ee(),i=(o,a)=>t(`${o.id}-${a}`,async()=>{await F.post(`/admin/api/media-reports/${o.id}/status`,{status:a}),await s.reload()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Media reports",intro:"Viewer-reported problems and the individual replacement searches they asked Memby to start."}),s.loading?e.jsx(V,{rows:4}):e.jsx(T,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(r=s.data)!=null&&r.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(o=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:Ei(o)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.title})]}),e.jsxs("td",{children:[o.reason.replaceAll("_"," "),o.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.comment})]}):null]}),e.jsxs("td",{children:[o.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(M,{tone:o.replacementRequested?"note":void 0,children:o.replacementRequested?o.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(M,{tone:o.status==="resolved"?"ok":o.status==="dismissed"?void 0:"warn",children:o.status})}),e.jsx("td",{className:"nowrap muted",children:P(o.createdAt)}),e.jsxs("td",{children:[e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-acknowledged`,onClick:()=>void i(o,"acknowledged"),children:"Acknowledge"})," ",e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-resolved`,onClick:()=>void i(o,"resolved"),children:"Resolve"})," ",e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-dismissed`,onClick:()=>void i(o,"dismissed"),children:"Dismiss"})]})]},o.id))})]})}):e.jsx(Q,{children:"No media problems have been reported."})})]})}const Ri=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",Ti=s=>s==="no_match"?"no match":s,_s=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",Us=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function $i(){var h,g,b,k;const s=J("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=te(),{busy:t,run:i}=ee(),[r,o]=p.useState(),[a,c]=p.useState(!1);p.useEffect(()=>{!a&&s.data&&o(s.data.settings)},[s.data,a]);const d=x=>{o(y=>y&&{...y,...x}),c(!0)},l=()=>{r&&i("save",async()=>{const x=await n(()=>F.put("/admin/api/credits",r),"Credits scanning settings saved.");x&&(s.set(x),o(x.settings),c(!1))})},j=((h=s.data)==null?void 0:h.history)??[],v=((g=s.data)==null?void 0:g.pending)??[],u=j.filter(x=>x.outcome==="detected").length,m=j.filter(x=>x.outcome==="no_match").length,f=j.filter(x=>x.outcome==="failed").length;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Credits detection",intro:"Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."}),e.jsx(U,{message:s.error}),s.loading||!r?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[(b=s.data)!=null&&b.enabled?null:e.jsx(fe,{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(le,{tiles:[{label:"Waiting candidates",value:w((k=s.data)==null?void 0:k.queueDepth),icon:"clock",tone:v.length?"info":void 0},{label:"Detected in this history",value:w(u),icon:"check",tone:"ok"},{label:"No match",value:w(m),icon:"search",tone:m?"warn":void 0},{label:"Failed",value:w(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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($,{variant:"primary",icon:"check",busy:t==="save",disabled:!a,onClick:l,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{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:r.candidateLimit,onChange:x=>d({candidateLimit:Number(x.target.value)})})}),e.jsx(q,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:r.prefetchEpisodes,onChange:x=>d({prefetchEpisodes:Number(x.target.value)})})}),e.jsx(q,{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:r.prefetchEpisodes,max:20,value:r.maxPrefetch,onChange:x=>d({maxPrefetch:Number(x.target.value)})})}),e.jsx(q,{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:r.retryHours,onChange:x=>d({retryHours:Number(x.target.value)})})})]})}),e.jsxs(T,{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(T,{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:v.length===0?e.jsx(X,{columns:6,children:"No episodes are waiting to be scanned."}):v.map(x=>e.jsxs("tr",{children:[e.jsx("td",{children:Us(x.season,x.episode)}),e.jsx("td",{children:e.jsx(M,{tone:"info",children:_s(x.reason)})}),e.jsx("td",{className:"num",children:w(x.priority)}),e.jsx("td",{className:"num",children:w(x.userCount)}),e.jsx("td",{className:"nowrap muted",title:P(x.lastViewed),children:je(x.lastViewed)}),e.jsx("td",{className:"mono muted",children:x.itemId})]},x.itemId))})]})})}),e.jsx(T,{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:j.length===0?e.jsx(X,{columns:7,children:"No credits scans have completed yet."}):j.map(x=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(x.finishedAt),children:je(x.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:x.seriesName||x.itemName||x.itemId}),e.jsxs("span",{className:"table-sub",children:[Us(x.season,x.episode),x.itemName&&x.seriesName?` · ${x.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:"info",children:_s(x.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",x.priority]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:Ri(x.outcome),children:Ti(x.outcome)}),x.error?e.jsx("span",{className:"table-sub",children:x.error}):null]}),e.jsx("td",{className:"nowrap",children:x.markerMs>0?ke(x.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[x.method||"visual",x.confidence>0?` · ${We(x.confidence)}`:"",x.frames>0?` · ${x.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:ke(x.durationMs)})]},x.id))})]})})})]})]})}function Ii(){return e.jsx(W,{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 Li(){return e.jsx(mn,{children:e.jsx(En,{children:e.jsx(In,{children:e.jsx(On,{children:e.jsx(pn,{children:e.jsxs(B,{path:"/admin",element:e.jsx(Fn,{}),children:[e.jsx(B,{index:!0,element:e.jsx(_n,{})}),e.jsx(B,{path:"activity",element:e.jsx(Wn,{})}),e.jsx(B,{path:"accounts",element:e.jsx(Bn,{})}),e.jsx(B,{path:"accounts/:userId",element:e.jsx(Vn,{})}),e.jsx(B,{path:"accounts/:userId/settings",element:e.jsx(Jn,{})}),e.jsx(B,{path:"clients",element:e.jsx(Qn,{})}),e.jsx(B,{path:"logins",element:e.jsx(et,{})}),e.jsx(B,{path:"devices/:deviceId",element:e.jsx(it,{})}),e.jsx(B,{path:"library",element:e.jsx(at,{})}),e.jsx(B,{path:"ratings",element:e.jsx(lt,{})}),e.jsx(B,{path:"requests",element:e.jsx(ot,{})}),e.jsx(B,{path:"recommendations",element:e.jsx(ct,{})}),e.jsx(B,{path:"inspector",element:e.jsx(ut,{})}),e.jsx(B,{path:"hero",element:e.jsx(jt,{})}),e.jsx(B,{path:"features",element:e.jsx(gt,{})}),e.jsx(B,{path:"playback",element:e.jsx(bt,{})}),e.jsx(B,{path:"subtitles",element:e.jsx(ft,{})}),e.jsx(B,{path:"credits",element:e.jsx($i,{})}),e.jsx(B,{path:"updates",element:e.jsx(yt,{})}),e.jsx(B,{path:"tasks",element:e.jsx(Ct,{})}),e.jsx(B,{path:"integrations",element:e.jsx(Et,{})}),e.jsx(B,{path:"maintenance",element:e.jsx(Lt,{})}),e.jsx(B,{path:"settings",element:e.jsx(Dt,{})}),e.jsx(B,{path:"imports",element:e.jsx(_t,{})}),e.jsx(B,{path:"logs",element:e.jsx(gi,{})}),e.jsx(B,{path:"journeys",element:e.jsx(bi,{})}),e.jsx(B,{path:"journeys/:userId",element:e.jsx(Ni,{})}),e.jsx(B,{path:"views",element:e.jsx(Mi,{})}),e.jsx(B,{path:"engagement",element:e.jsx(Si,{})}),e.jsx(B,{path:"searches",element:e.jsx(Ci,{})}),e.jsx(B,{path:"media-reports",element:e.jsx(Ai,{})}),e.jsx(B,{path:"overview",element:e.jsx(xn,{to:"/admin",replace:!0})}),e.jsx(B,{path:"*",element:e.jsx(Ii,{})})]})})})})})})}const on=document.getElementById("root");if(!on)throw new Error("the console has no root element to render into");Hs(on).render(e.jsx(p.StrictMode,{children:e.jsx(Li,{})})); diff --git a/admin-ui/dist/assets/index-d9286FJI.js b/admin-ui/dist/assets/index-d9286FJI.js deleted file mode 100644 index dfafa1c..0000000 --- a/admin-ui/dist/assets/index-d9286FJI.js +++ /dev/null @@ -1,11 +0,0 @@ -import{r as p,a as cn,u as is,L as re,b as as,m as Xe,N as _s,O as dn,c as We,B as hn,R as un,d as B,e as mn}from"./router-D9WH5XEU.js";(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const a of o.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&i(a)}).observe(document,{childList:!0,subtree:!0});function t(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function i(r){if(r.ep)return;r.ep=!0;const o=t(r);fetch(r.href,o)}})();var Us={exports:{}},He={};/** - * @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 pn=p,xn=Symbol.for("react.element"),jn=Symbol.for("react.fragment"),vn=Object.prototype.hasOwnProperty,gn=pn.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,bn={key:!0,ref:!0,__self:!0,__source:!0};function Vs(s,n,t){var i,r={},o=null,a=null;t!==void 0&&(o=""+t),n.key!==void 0&&(o=""+n.key),n.ref!==void 0&&(a=n.ref);for(i in n)vn.call(n,i)&&!bn.hasOwnProperty(i)&&(r[i]=n[i]);if(s&&s.defaultProps)for(i in n=s.defaultProps,n)r[i]===void 0&&(r[i]=n[i]);return{$$typeof:xn,type:s,key:o,ref:a,props:r,_owner:gn.current}}He.Fragment=jn;He.jsx=Vs;He.jsxs=Vs;Us.exports=He;var e=Us.exports,Bs,ms=cn;Bs=ms.createRoot,ms.hydrateRoot;const Ws={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 J({name:s,className:n}){const t=Ws[s];return t?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:t})}):null}function ze({name:s,tone:n}){return Ws[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(J,{name:s})}):null}const Ce=[{id:"everyday",label:"Everyday",defaultCollapsed:!1,items:[{id:"overview",path:"/admin",label:"Overview",title:"Overview",intro:"What the gateway is doing right now.",icon:"overview"},{id:"activity",path:"/admin/activity",label:"Activity",title:"Activity",intro:"Every administrative event, newest first.",icon:"bell",badge:"notifications"},{id:"accounts",path:"/admin/accounts",label:"Users",title:"Memby users",intro:"Who uses Memby, and the devices they are signed in on.",icon:"people"},{id:"requests",path:"/admin/requests",label:"Media requests",title:"Media requests",intro:"Who can ask for something the library does not have.",icon:"inbox"},{id:"media-reports",path:"/admin/media-reports",label:"Media reports",title:"Media reports",intro:"Problems viewers reported with a film or episode.",icon:"alert"},{id:"updates",path:"/admin/updates",label:"App updates",title:"App updates",intro:"Publish an optional or a required client update.",icon:"upload"},{id:"logs",path:"/admin/logs",label:"Server logs",title:"Server logs",intro:"Structured gateway events as they happen.",icon:"list"}]},{id:"people",label:"Devices & access",defaultCollapsed:!0,items:[{id:"account",path:"/admin/accounts/:userId",label:"User",title:"User",intro:"Devices, recommendation setup and synced settings for one person.",icon:"person",hidden:!0},{id:"settings-history",path:"/admin/accounts/:userId/settings",label:"Settings history",title:"Settings history",intro:"Every change to one person's synced settings, and which devices took it.",icon:"sliders",hidden:!0},{id:"clients",path:"/admin/clients",label:"Devices",title:"Devices",intro:"Which sets have reported in, what they are running and what their build understands.",icon:"tv"},{id:"logins",path:"/admin/logins",label:"Sign-ins",title:"Sign-in history",intro:"Every connection attempt: who, which television, from where, and whether it got in.",icon:"key"},{id:"device",path:"/admin/devices/:deviceId",label:"Device",title:"Device",intro:"One television: how often it connects, at what times, and from which addresses.",icon:"tv",hidden:!0}]},{id:"content",label:"Content & discovery",defaultCollapsed:!0,items:[{id:"library",path:"/admin/library",label:"Library",title:"Library",intro:"Import and inspect the catalogue Memby ranks.",icon:"library"},{id:"hero",path:"/admin/hero",label:"Home hero",title:"Home hero",intro:"Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",icon:"star"},{id:"recommendations",path:"/admin/recommendations",label:"For You",title:"For You",intro:"The prepared pools personalised rows are drawn from.",icon:"sparkle"},{id:"ratings",path:"/admin/ratings",label:"Movie ratings",title:"Movie ratings",intro:"Optional MDBList scores on films and shows.",icon:"star"},{id:"inspector",path:"/admin/inspector",label:"Score inspector",title:"Score inspector",intro:"Re-run the ranker for one person and read every component.",icon:"search"}]},{id:"experience",label:"Viewing experience",defaultCollapsed:!0,items:[{id:"features",path:"/admin/features",label:"Features",title:"Features",intro:"Roll out, stop and recover optional behaviour with no app release.",icon:"sliders"},{id:"playback",path:"/admin/playback",label:"Playback",title:"Playback",intro:"Presentation policy sent with every playback launch.",icon:"play"},{id:"subtitles",path:"/admin/subtitles",label:"Subtitles",title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from.",icon:"captions"},{id:"credits",path:"/admin/credits",label:"Credits detection",title:"Credits detection",intro:"Control predictive scanning and review every completed credits scan.",icon:"clock"}]},{id:"operations",label:"Operations",defaultCollapsed:!0,items:[{id:"tasks",path:"/admin/tasks",label:"Scheduled tasks",title:"Scheduled tasks",intro:"What the gateway does in the background, when it last ran and whether it worked.",icon:"clock"},{id:"imports",path:"/admin/imports",label:"Imports",title:"Imports",intro:"Catalogue synchronisation history.",icon:"database"},{id:"maintenance",path:"/admin/maintenance",label:"Maintenance",title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time.",icon:"wrench"},{id:"gateway-settings",path:"/admin/settings",label:"Gateway settings",title:"Gateway settings",intro:"Timezone, logging and the other server-level settings for this gateway.",icon:"sliders",hidden:!0},{id:"integrations",path:"/admin/integrations",label:"Integrations",title:"Integrations",intro:"Send administrative events to Discord and, in time, elsewhere.",icon:"plug"}]},{id:"insights",label:"Insights",defaultCollapsed:!0,items:[{id:"views",path:"/admin/views",label:"Views",title:"App views",intro:"Home-screen visits, viewers and the times Memby is used.",icon:"overview"},{id:"searches",path:"/admin/searches",label:"Searches",title:"Searches",intro:"What the household has been looking for, and what it searched just now.",icon:"search"},{id:"journeys",path:"/admin/journeys",label:"Journeys",title:"User journeys",intro:"How viewers move through Memby, use features and complete flows.",icon:"journey"},{id:"engagement",path:"/admin/engagement",label:"Row engagement",title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row.",icon:"chart"}]}],fn=Ce.flatMap(s=>s.items),yn=Ce.flatMap(s=>s.items.filter(n=>!n.path.includes(":")).map(n=>({...n,group:s.label??""})));class ps extends Error{constructor(n,t){super(n),this.status=t,this.name="ApiError"}}const wn=5*60*1e3;let Hs=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{Hs=Date.now()},{passive:!0});const kn=()=>Date.now()-Hs({}));throw new ps(i.error??`Request failed (${t.status})`,t.status)}if(t.status!==204)return await t.json()}function Te(s){const n=new URLSearchParams;for(const[i,r]of Object.entries(s))r==null||r===""||r===!1||n.set(i,String(r));const t=n.toString();return t?`?${t}`:""}const F={get:s=>qe(s),post:(s,n)=>qe(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>qe(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>qe(s,{method:"DELETE"})},Sn=3e4,zs=p.createContext(null);function Cn({children:s}){var h;const[n,t]=p.useState(),[i,r]=p.useState(!1),[o,a]=p.useState(""),[d,c]=p.useState(""),[l,v]=p.useState(!0),g=p.useRef(0),u=p.useCallback(async()=>{const j=++g.current;try{const b=await F.get("/admin/api/status");if(j!==g.current)return;t(b),r(!0),c("")}catch(b){if(j!==g.current)return;r(!1),c(b instanceof Error?b.message:String(b))}finally{j===g.current&&(a(new Date().toISOString()),v(!1))}},[]),m=p.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 u()},[u,(h=n==null?void 0:n.maintenance)==null?void 0:h.message]);p.useEffect(()=>{u();let j;const b=()=>{window.clearInterval(j),j=document.hidden?void 0:window.setInterval(()=>void u(),Sn)},k=()=>{b(),document.hidden||u()};return b(),document.addEventListener("visibilitychange",k),()=>{window.clearInterval(j),document.removeEventListener("visibilitychange",k)}},[u]);const f=p.useMemo(()=>{var j;return{status:n,version:(n==null?void 0:n.serverVersion)??"",currentUser:((j=n==null?void 0:n.currentUser)==null?void 0:j.trim())||"Administrator",online:i,checkedAt:o,error:d,loading:l,reload:u,setMaintenance:m}},[n,i,o,d,l,u,m]);return e.jsx(zs.Provider,{value:f,children:s})}function oe(){const s=p.useContext(zs);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function Mn(s,n){const t=s.label.toLowerCase(),i=s.group.toLowerCase();return t.startsWith(n)?4:t.includes(n)?3:i.includes(n)?2:`${s.title} ${s.intro}`.toLowerCase().includes(n)?1:0}function En(){const s=is(),{status:n}=oe(),[t,i]=p.useState(!1),[r,o]=p.useState(""),[a,d]=p.useState(0),c=p.useRef(null),l=p.useRef(null),v=p.useMemo(()=>{const u=r.trim().toLowerCase();return[...yn,...((n==null?void 0:n.requestUsers)??[]).map(f=>({id:`user-${f.id}`,path:`/admin/accounts/${encodeURIComponent(f.id)}`,label:f.username||"Unnamed user",title:`User: ${f.username||"Unnamed user"}`,intro:"Open this user’s devices and settings.",group:"Users",icon:"people"})),...((n==null?void 0:n.clients)??[]).map(f=>({id:`device-${f.deviceId}`,path:`/admin/devices/${encodeURIComponent(f.deviceId)}`,label:f.deviceName||"Unnamed device",title:`Device: ${f.deviceName||"Unnamed device"}`,intro:`${f.username||"Unknown user"} · ${f.version||"unknown version"}`,group:"Devices",icon:"tv"}))].map((f,h)=>({item:f,rank:u?Mn(f,u):1,index:h})).filter(f=>f.rank>0).sort((f,h)=>h.rank-f.rank||f.index-h.index).map(({item:f,rank:h})=>({item:f,rank:h}))},[r,n]);p.useEffect(()=>d(0),[r]),p.useEffect(()=>{const u=m=>{var f;(f=c.current)!=null&&f.contains(m.target)||i(!1)};return document.addEventListener("pointerdown",u),()=>document.removeEventListener("pointerdown",u)},[]),p.useEffect(()=>{const u=m=>{var h,j;if(m.key!=="S"||!m.shiftKey||m.ctrlKey||m.metaKey||m.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(m.preventDefault(),(h=l.current)==null||h.focus(),(j=l.current)==null||j.select())};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[]);const g=u=>{var m;i(!1),o(""),(m=l.current)==null||m.blur(),s(u)};return e.jsxs("div",{className:"omni",ref:c,"data-open":t||void 0,children:[e.jsxs("div",{className:"omni-input",children:[e.jsx(J,{name:"search"}),e.jsx("input",{ref:l,type:"search",value:r,placeholder:"Search pages, users and devices…","aria-label":"Search pages, users and devices","aria-expanded":t,onFocus:()=>i(!0),onChange:u=>{o(u.target.value),i(!0)},onKeyDown:u=>{var m;if(u.key==="Escape")o(""),i(!1),(m=l.current)==null||m.blur();else if(u.key==="ArrowDown")u.preventDefault(),d(f=>Math.min(f+1,v.length-1));else if(u.key==="ArrowUp")u.preventDefault(),d(f=>Math.max(f-1,0));else if(u.key==="Enter"){const f=v[a];if(!f)return;u.preventDefault(),g(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),t?e.jsx("div",{className:"omni-panel",role:"listbox",children:v.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):v.map((u,m)=>e.jsxs("a",{className:m===a?"omni-item on":"omni-item",href:u.item.path,role:"option","aria-selected":m===a,onPointerEnter:()=>d(m),onClick:f=>{f.preventDefault(),g(u.item.path)},children:[u.item.icon?e.jsx(J,{name:u.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:u.item.label}),e.jsx("small",{children:u.item.intro})]}),e.jsx("span",{className:"omni-group",children:u.item.group})]},u.item.id))}):null]})}const w=s=>(s??0).toLocaleString(),P=s=>s?new Date(s).toLocaleString():"—";function Ne(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 t=Math.floor(n/60);return t<60?`${t}m ${n%60}s`:`${Math.floor(t/60)}h ${t%60}m`}function ke(s){const n=Math.round(Math.max(0,s??0)/6e4);if(n<=0)return"none";if(n<60)return`${n} min`;const t=Math.floor(n/60),i=n%60;return i===0?t===1?"1 hour":`${t} hours`:`${t}h ${i}m`}function es(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 t=Math.round(n/60);return t<48?t===1?"hourly":`every ${t} hours`:`every ${Math.round(t/24)} days`}function Ae(s){const n=["B","KB","MB","GB"];let t=Number(s??0),i=0;for(;t>=1024&&iString(s??"?").trim().split(/\s+/).slice(0,2).map(n=>n[0]??"").join("").toUpperCase(),Ve=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 t=Math.floor(n/1e3);if(t<45)return"just now";const i=Math.floor(t/60);if(i<60)return`${i} min ago`;const r=Math.floor(i/60);if(r<24)return`${r}h ago`;const o=Math.floor(r/24);return o<30?`${o}d ago`:new Date(s).toLocaleDateString()}const Gs=15*60*1e3,An=3*60*60*1e3,$e=s=>!!s&&Date.now()-new Date(s).getTime(){var b;try{const k=await F.get(`/admin/api/notifications?limit=${js}`);t(k.events),r(k.unread),a(k.types),g.current=Math.max(g.current,((b=k.events[0])==null?void 0:b.id)??0),v("")}catch(k){v(k instanceof Error?k.message:String(k))}},[]),m=p.useCallback(b=>{g.current=Math.max(g.current,b.id),t(k=>k.some(x=>x.id===b.id)?k:[b,...k].sort((x,y)=>y.id-x.id).slice(0,js)),b.readAt||r(k=>k+1),a(k=>k.some(x=>x.type===b.type)?k.map(x=>x.type===b.type?{...x,count:x.count+1}:x):[...k,{type:b.type,count:1}])},[]);p.useEffect(()=>{u()},[u]),p.useEffect(()=>{let b=null,k,x=!1;return(()=>{x||(b=new EventSource(`/admin/api/notifications/stream?after=${g.current}`),b.addEventListener("open",()=>{c(!0),window.clearInterval(k),k=void 0}),b.addEventListener("admin",R=>{try{m(JSON.parse(R.data))}catch{}}),b.addEventListener("error",()=>{c(!1),k===void 0&&(k=window.setInterval(()=>void u(),Rn))}))})(),()=>{x=!0,b==null||b.close(),window.clearInterval(k)}},[m,u]);const f=p.useCallback(async b=>{const k=b.filter(x=>x>0);if(k.length!==0){t(x=>x.map(y=>k.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const x=await F.post("/admin/api/notifications/read",{ids:k});r(x.unread)}catch{u()}}},[u]),h=p.useCallback(async()=>{r(0),t(b=>b.map(k=>k.readAt?k:{...k,readAt:new Date().toISOString()}));try{const b=await F.post("/admin/api/notifications/read",{all:!0});r(b.unread)}catch{u()}},[u]),j=p.useMemo(()=>({events:n,unread:i,types:o,connected:d,error:l,markRead:f,markAllRead:h,reload:u}),[n,i,o,d,l,f,h,u]);return e.jsx(Zs.Provider,{value:j,children:s})}function ls(){const s=p.useContext(Zs);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function Js(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function Ys(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 vs(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 gs=99;function Tn(){const{events:s,unread:n,connected:t,markRead:i,markAllRead:r}=ls(),[o,a]=p.useState(!1),d=p.useRef(null);return p.useEffect(()=>{const c=v=>{var g;(g=d.current)!=null&&g.contains(v.target)||a(!1)},l=v=>{v.key==="Escape"&&a(!1)};return document.addEventListener("pointerdown",c),document.addEventListener("keydown",l),()=>{document.removeEventListener("pointerdown",c),document.removeEventListener("keydown",l)}},[]),p.useEffect(()=>{if(!o)return;const c=s.filter(l=>!l.readAt).map(l=>l.id);c.length>0&&i(c)},[o]),e.jsxs("div",{className:"bell",ref:d,"data-open":o||void 0,children:[e.jsxs("button",{type:"button",className:"bell-button","aria-label":n>0?`Activity, ${n} unread`:"Activity","aria-expanded":o,onClick:()=>a(c=>!c),children:[e.jsx(J,{name:"bell"}),n>0?e.jsx("span",{className:"bell-badge",children:n>gs?`${gs}+`:n}):null]}),o?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:[t?null:e.jsx("span",{className:"tag","data-tone":"warn",children:"reconnecting"}),n>0?e.jsx("button",{type:"button","data-variant":"quiet","data-size":"sm",onClick:()=>void r(),children:"Mark all read"}):null]})]}),e.jsx("div",{className:"bell-list",children:s.length===0?e.jsx("p",{className:"empty",children:"Nothing has happened yet."}):s.slice(0,20).map(c=>{const l=e.jsxs(e.Fragment,{children:[e.jsx(ze,{name:Ys(c.type),tone:Js(c)}),e.jsxs("span",{className:"bell-body",children:[e.jsx("b",{children:c.title||c.type}),c.summary?e.jsx("p",{children:c.summary}):null,e.jsx("time",{dateTime:c.occurredAt,children:be(c.occurredAt)})]})]});return c.link?e.jsx(re,{className:"bell-item","data-unread":!c.readAt||void 0,to:c.link,onClick:()=>a(!1),children:l},c.id):e.jsx("div",{className:"bell-item","data-unread":!c.readAt||void 0,children:l},c.id)})}),e.jsx("div",{className:"bell-foot",children:e.jsx(re,{to:"/admin/activity",onClick:()=>a(!1),children:"All activity"})})]}):null]})}function U({title:s,intro:n,actions:t,crumbs:i,icon:r}){var d;const o=as(),a=r??((d=fn.find(c=>Xe({path:c.path,end:!0},o.pathname)))==null?void 0:d.icon);return e.jsxs("header",{className:"page-head",children:[i?e.jsx("nav",{className:"crumbs",children:i}):null,e.jsxs("div",{className:"page-head-row",children:[e.jsxs("div",{className:"page-head-title",children:[a?e.jsx("span",{className:"page-head-icon","aria-hidden":"true",children:e.jsx(J,{name:a})}):null,e.jsxs("div",{className:"page-head-text",children:[e.jsx("h1",{children:s}),n?e.jsx("p",{children:n}):null]})]}),t?e.jsx("div",{className:"page-head-actions",children:t}):null]})]})}function T({title:s,intro:n,icon:t,tone:i,actions:r,footer:o,children:a}){return e.jsxs("section",{className:"card",children:[s?e.jsxs("div",{className:"card-head",children:[t?e.jsx(ze,{name:t,tone:i}):null,e.jsxs("div",{className:"card-head-text",children:[e.jsx("h2",{children:s}),n?e.jsx("p",{children:n}):null]}),r?e.jsx("div",{className:"card-head-actions",children:r}):null]}):null,a,o?e.jsx("div",{className:"card-foot",children:o}):null]})}function he({cols:s,children:n}){return e.jsx("div",{className:"grid","data-cols":s,children:n})}function le({tiles:s}){return e.jsx("div",{className:"tiles",children:s.map(n=>e.jsxs("div",{className:"tile",children:[n.icon?e.jsx(ze,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function M({children:s,tone:n}){return e.jsx("span",{className:"tag","data-tone":n,children:s})}function ie({children:s,tone:n}){return e.jsx("span",{className:"chip","data-tone":n,children:s})}function Y({children:s}){return e.jsx("p",{className:"empty",children:s})}function ae({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 je({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function $({children:s,onClick:n,variant:t,size:i,disabled:r,busy:o,icon:a,type:d="button",title:c}){return e.jsxs("button",{type:d,className:"","data-variant":t,"data-size":i,disabled:r||o,onClick:n,title:c,children:[o?e.jsx("span",{className:"spinner"}):a?e.jsx(J,{name:a}):null,s]})}function q({label:s,hint:n,children:t,grow:i}){return e.jsxs("label",{className:i?"field grow":"field",children:[e.jsx("span",{children:s}),t,n?e.jsx("small",{children:n}):null]})}function z({label:s,hint:n,checked:t,onChange:i,disabled:r}){return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:t,disabled:r,onChange:o=>i(o.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 Be({value:s,options:n,onChange:t}){return e.jsx("div",{className:"segments",role:"group",children:n.map(i=>e.jsx("button",{type:"button","aria-pressed":i.value===s,onClick:()=>t(i.value),children:i.label},String(i.value)))})}function Z({children:s}){return e.jsx("div",{className:"table-wrap",children:s})}function Qs({data:s,labelOf:n,valueOf:t,toneOf:i,title:r}){if(s.length===0)return e.jsx(Y,{children:"Nothing in this window."});const o=s.map(d=>t(d)),a=Math.max(1,...o);return e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"bars",children:s.map((d,c)=>{const l=t(d);return e.jsx("div",{className:"bar","data-tone":i==null?void 0:i(d),"data-empty":l===0||void 0,style:{height:`${Math.max(2,l/a*100)}%`},title:r?r(d):`${n(d,c)}: ${l}`},c)})}),e.jsxs("div",{className:"bars-axis",children:[e.jsx("span",{children:n(s[0],0)}),e.jsx("span",{children:n(s[s.length-1],s.length-1)})]})]})}function In({value:s,total:n,tone:t}){const i=n>0?Math.min(1,s/n):0;return e.jsx("div",{className:"meter","data-tone":t,children:e.jsx("div",{style:{width:`${i*100}%`}})})}function V({message:s,onDismiss:n}){return s?e.jsxs("div",{className:"banner",role:"alert",children:[e.jsx(J,{name:"alert"}),e.jsx("span",{children:s}),n?e.jsx("button",{type:"button",onClick:n,"aria-label":"Dismiss",children:e.jsx(J,{name:"close"})}):null]}):null}function W({rows:s=3}){return e.jsxs("div",{className:"loading-page","aria-busy":"true","aria-label":"Loading",children:[e.jsxs("div",{className:"loading-heading",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]}),e.jsx("div",{className:"loading-tiles",children:Array.from({length:4},(n,t)=>e.jsx("span",{className:"skeleton"},t))}),Array.from({length:s},(n,t)=>e.jsxs("section",{className:"loading-card",children:[e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"}),e.jsx("span",{className:"skeleton"})]},t))]})}function xe({title:s,body:n,confirmLabel:t="Confirm",destructive:i,busy:r,onConfirm:o,onCancel:a}){const d=p.useId(),c=p.useRef(null);return p.useEffect(()=>{var v;(v=c.current)==null||v.focus();const l=g=>{g.key==="Escape"&&a()};return document.addEventListener("keydown",l),()=>document.removeEventListener("keydown",l)},[a]),e.jsx("div",{className:"scrim",onPointerDown:l=>l.target===l.currentTarget&&a(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true","aria-labelledby":d,ref:c,tabIndex:-1,children:[e.jsx("h2",{id:d,children:s}),e.jsx("p",{children:n}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{onClick:a,variant:"quiet",children:"Cancel"}),e.jsx($,{onClick:o,variant:i?"danger":"primary",busy:r,children:t})]})]})})}function ss({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 Xs({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 Ln({open:s,onNavigate:n}){const{unread:t}=ls(),i=as(),[r,o]=p.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),a=c=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(c))}catch{}};p.useEffect(()=>{const c=Ce.find(l=>l.items.some(v=>Xe({path:v.path,end:!0},i.pathname)));c&&o(l=>{const v={...l};return Ce.forEach(g=>{g.collapsible!==!1&&(v[g.id]=g.id!==c.id)}),a(v),v})},[i.pathname]);const d=(c,l=!1)=>{o(v=>{const g={...v},u=!(v[c]??l);return Ce.forEach(m=>{m.collapsible!==!1&&(g[m.id]=m.id===c?u:!0)}),a(g),g})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:Ce.map(c=>{const l=c.items.filter(m=>!m.hidden);if(l.length===0)return null;const v=l.some(m=>Xe({path:m.path,end:!0},i.pathname)),g=c.collapsible!==!1,u=v||!g||!(r[c.id]??c.defaultCollapsed??!1);return e.jsxs("div",{className:"rail-group",children:[c.label&&g?e.jsxs("button",{type:"button",className:"rail-head","aria-expanded":u,onClick:()=>d(c.id,c.defaultCollapsed),children:[c.label,e.jsx(J,{name:"caret",className:"ico caret"})]}):c.label?e.jsx("div",{className:"rail-head rail-head-static",children:c.label}):null,u?l.map(m=>e.jsx(_s,{to:m.path,end:m.path==="/admin",onClick:n,className:({isActive:f})=>f?"on":"","aria-current":void 0,children:({isActive:f})=>e.jsxs("span",{style:{display:"contents"},ref:h=>{const j=h==null?void 0:h.parentElement;j&&(f?j.setAttribute("aria-current","page"):j.removeAttribute("aria-current"))},children:[m.icon?e.jsx(J,{name:m.icon}):null,m.label,m.badge==="notifications"&&t>0?e.jsx("span",{className:"rail-badge",children:t>99?"99+":t}):null]})},m.id)):null]},c.id)})})}function qn(){var R,S,H;const{version:s,currentUser:n,online:t,loading:i,status:r,setMaintenance:o}=oe(),[a,d]=p.useState(!1),[c,l]=p.useState(!1),[v,g]=p.useState(!1),[u,m]=p.useState(!1),f=as(),h=!!((R=r==null?void 0:r.maintenance)!=null&&R.enabled),j=!!((S=r==null?void 0:r.quietTime)!=null&&S.active),b=p.useRef(null),k=((H=Array.from(n.trim())[0])==null?void 0:H.toLocaleUpperCase("en-NZ"))||"A",x=r&&t&&!h&&!j?"ok":r||!i?"bad":"checking",y=async()=>{if(!(v||!r)){g(!0);try{await o(!h)}finally{g(!1)}}};return p.useEffect(()=>d(!1),[f.pathname]),p.useEffect(()=>{if(!c)return;const E=G=>{var ee;(ee=b.current)!=null&&ee.contains(G.target)||l(!1)},I=G=>{G.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",E),window.addEventListener("keydown",I),()=>{document.removeEventListener("mousedown",E),window.removeEventListener("keydown",I)}},[c]),p.useEffect(()=>{if(!a)return;const E=document.body.style.overflow;document.body.style.overflow="hidden";const I=G=>{G.key==="Escape"&&d(!1)};return window.addEventListener("keydown",I),()=>{document.body.style.overflow=E,window.removeEventListener("keydown",I)}},[a]),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":a,"aria-controls":"rail",onClick:()=>d(E=>!E),children:e.jsx(J,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(En,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsx("button",{type:"button",className:"topbar-status","data-tone":x,"aria-pressed":h,disabled:!r||!t||v||j,title:r?j?"Memby quiet time is active":h?"Bring Memby back online":t?"Take Memby offline":"Memby is not responding":"Checking Memby status","aria-label":j?"Memby quiet time is active":h?"Memby is offline. Bring it online":r&&t?"Memby is online. Take it offline":i?"Checking Memby status":"Memby is not responding",onClick:()=>h?void y():m(!0),children:e.jsx("span",{className:"dot","aria-hidden":"true"})}),e.jsx(Tn,{}),e.jsxs("div",{className:"account-menu","data-open":c||void 0,ref:b,children:[e.jsxs("button",{type:"button",className:"account-trigger","aria-haspopup":"menu","aria-expanded":c,"aria-label":`Signed in as ${n}`,onClick:()=>l(E=>!E),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:k}),e.jsx("span",{className:"account-name",children:n}),e.jsx(J,{name:"caret",className:"ico account-caret"})]}),c?e.jsxs("div",{className:"account-panel",role:"menu",children:[e.jsxs("div",{className:"account-identity",children:[e.jsx("span",{className:"account-avatar account-avatar-large","aria-hidden":"true",children:k}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsxs(_s,{to:"/admin/settings",role:"menuitem",onClick:()=>l(!1),children:[e.jsx(J,{name:"sliders"}),"Gateway settings"]}),e.jsx("form",{method:"post",action:"/admin/logout",children:e.jsxs("button",{type:"submit",role:"menuitem",children:[e.jsx(J,{name:"logout"}),"Log out"]})})]}):null]})]})]}),a?e.jsx("button",{type:"button",className:"rail-scrim","aria-label":"Close sections",onClick:()=>d(!1)}):null,e.jsx(Ln,{open:a,onNavigate:()=>d(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(dn,{})}),u?e.jsx(xe,{title:"Take Memby offline?",body:"Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available.",confirmLabel:"Go offline",destructive:!0,busy:v,onConfirm:()=>{m(!1),y()},onCancel:()=>m(!1)}):null]})}const en=p.createContext(null),Dn=5e3;function Fn({children:s}){const[n,t]=p.useState([]),i=p.useRef(1),r=p.useCallback(c=>{t(l=>l.filter(v=>v.id!==c))},[]),o=p.useCallback((c,l="ok")=>{const v=i.current++;t(g=>[...g,{id:v,message:c,tone:l}]),window.setTimeout(()=>r(v),Dn)},[r]),a=p.useCallback(async(c,l)=>{try{const v=await c();return l&&o(l,"ok"),v}catch(v){o(v instanceof Error?v.message:String(v),"bad");return}},[o]),d=p.useMemo(()=>({show:o,wrap:a}),[o,a]);return e.jsxs(en.Provider,{value:d,children:[s,e.jsx("div",{className:"toasts",role:"status","aria-live":"polite",children:n.map(c=>e.jsxs("div",{className:"toast","data-tone":c.tone,children:[e.jsx(J,{name:c.tone==="bad"?"alert":"check"}),e.jsx("span",{children:c.message}),e.jsx("button",{type:"button",onClick:()=>r(c.id),"aria-label":"Dismiss",children:e.jsx(J,{name:"close"})})]},c.id))})]})}function ne(){const s=p.useContext(en);if(!s)throw new Error("useToast used outside ToastProvider");return s}function Q(s,n={}){const{pollMs:t,enabled:i=!0}=n,[r,o]=p.useState(),[a,d]=p.useState(""),[c,l]=p.useState(i),[v,g]=p.useState(!1),u=p.useRef(0),m=p.useRef(!1),f=p.useCallback(async()=>{if(!i)return;const h=++u.current;m.current&&g(!0);try{const j=await F.get(s);if(h!==u.current)return;o(j),d(""),m.current=!0}catch(j){if(h!==u.current)return;d(j instanceof Error?j.message:String(j))}finally{h===u.current&&(l(!1),g(!1))}},[s,i]);return p.useEffect(()=>(m.current=!1,l(!0),f(),()=>{u.current+=1}),[f]),p.useEffect(()=>{if(!t||!i)return;let h;const j=()=>{window.clearInterval(h),h=document.hidden?void 0:window.setInterval(()=>void f(),t)},b=()=>{j(),document.hidden||f()};return j(),document.addEventListener("visibilitychange",b),()=>{window.clearInterval(h),document.removeEventListener("visibilitychange",b)}},[t,i,f]),{data:r,error:a,loading:c,refreshing:v,reload:f,set:o}}function X(){const[s,n]=p.useState(null),t=p.useRef(!0);p.useEffect(()=>()=>{t.current=!1},[]);const i=p.useCallback(async(r,o)=>{n(r);try{return await o(),!0}finally{t.current&&n(null)}},[]);return{busy:s,run:i}}function Pn(){var j,b,k,x,y,R;const{status:s,error:n,loading:t}=oe(),i=Q("/admin/api/runtime",{pollMs:3e4}),r=Q("/admin/api/views",{pollMs:6e4});if(t||!s)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(V,{message:n}),e.jsx(W,{})]});const o=s.features??{features:[],revision:0,safeMode:!1},a=o.features??[],d=s.clients??[],c=d.filter(S=>$e(S.lastSeen)).length,l=s.updatePolicy??{},v=!!l.minimumVersion&&l.minimumVersion===l.latestVersion,g=s.playbackPolicy,u=s.mdblist,m=s.forYou,f=(s.runs??[]).slice(0,5),h=i.data;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Overview",intro:"What the gateway is doing right now."}),e.jsx(V,{message:n}),e.jsx(le,{tiles:[{label:"items in the library",value:w(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:w((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${c} active now`,value:w(d.length),icon:"tv",tone:"info"},{label:`visits today · ${((j=r.data)==null?void 0:j.lastWeek.visits)??0} this time last week`,value:w((b=r.data)==null?void 0:b.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((k=r.data)==null?void 0:k.lastWeek.viewers)??0} this time last week`,value:w((x=r.data)==null?void 0:x.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${a.filter(S=>S.enabled).length} / ${a.length}`,icon:"sliders",tone:"ok"},{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(ss,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(M,{tone:"bad",children:"offline for maintenance"}):(R=s.quietTime)!=null&&R.active?e.jsx(M,{tone:"warn",children:"quiet time active"}):e.jsx(M,{tone:"ok",children:"online"})},{label:"Feature control plane",value:o.safeMode?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["revision r",w(o.revision)]})},{label:"App update prompt",value:l.enabled?e.jsxs(M,{tone:v?"warn":"ok",children:[v?"required · ":"optional · ",l.latestVersion]}):e.jsx(M,{children:"off"})},{label:"Catalogue import",value:s.syncRunning?e.jsx(M,{tone:"warn",children:"running"}):e.jsxs(M,{children:["every ",s.syncEvery]})},{label:"Playback preroll",value:(g==null?void 0:g.prerollEnabled)===!1?e.jsx(M,{children:"off"}):e.jsxs(M,{tone:"ok",children:[((g==null?void 0:g.prerollDurationMs)??6500)/1e3,"s"]})}]})}),e.jsx(T,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(ss,{rows:[{label:"Movies (Radarr)",value:s.radarrReady?e.jsx(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"Series (Sonarr)",value:s.sonarrReady?e.jsx(M,{tone:"ok",children:"ready"}):e.jsx(M,{children:"not configured"})},{label:"MDBList ratings",value:u!=null&&u.enabled?e.jsxs(M,{tone:"ok",children:[w(u.cachedTitles)," titles stored"]}):e.jsx(M,{children:u!=null&&u.apiKeyConfigured?"off · key saved":"off · no key"})},{label:"For You pools",value:s.forYouRunning?e.jsx(M,{tone:"warn",children:"rebuilding"}):e.jsxs(M,{children:[w((m==null?void 0:m.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:w((m==null?void 0:m.profiles)??0)})}]})})]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{title:"Latest imports",intro:"The last few catalogue synchronisations.",icon:"sync",tone:"data",actions:e.jsx(re,{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(ae,{columns:4,children:"No imports have run yet."}):f.map(S=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(S.startedAt)}),e.jsx("td",{children:S.kind}),e.jsx("td",{children:e.jsx(M,{tone:S.status==="success"?"ok":S.status==="running"?"warn":"bad",children:S.status})}),e.jsx("td",{className:"num",children:w(S.itemsUpserted)})]},S.id||S.startedAt))})]})})}),e.jsx(T,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:h?e.jsxs(e.Fragment,{children:[e.jsx(Xs,{tiles:[{label:"goroutines",value:w(h.goroutines)},{label:"heap in use",value:Ae(h.heapInuse)},{label:"reserved",value:Ae(h.sys)},{label:"collections",value:w(h.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",Ae(h.nextGc)," · memory limit"," ",h.memoryLimit>0&&h.memoryLimit`/admin/api/notifications${Te({days:r,type:a,severity:c,unread:v,limit:f,offset:u*f})}`,[r,a,c,v,u]),{data:j,error:b,loading:k,reload:x}=Q(h),y=async()=>{await t(),await x()};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($,{onClick:()=>void y(),icon:"check",children:"Mark all read"}):void 0}),e.jsx(V,{message:b}),e.jsx(le,{tiles:[{label:"Events in window",value:w((j==null?void 0:j.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:w(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:w((j==null?void 0:j.types.length)??0),icon:"list",tone:"note"},{label:"Live feed",value:n?"connected":"reconnecting",small:!0,icon:"pulse",tone:n?"ok":"warn"}]}),e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Be,{value:r,options:On.map(R=>({value:R.value,label:R.label})),onChange:R=>{o(R),m(0)}})}),e.jsx(q,{label:"Kind",children:e.jsxs("select",{value:a,onChange:R=>{d(R.target.value),m(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((j==null?void 0:j.types)??[]).map(R=>e.jsxs("option",{value:R.type,children:[vs(R.type)," (",R.count,")"]},R.type))]})}),e.jsx(q,{label:"Severity",children:e.jsxs("select",{value:c,onChange:R=>{l(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=>{g(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($,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{x(),i()},children:"Refresh"})})]}),k?e.jsx(W,{}):e.jsx(T,{title:"Events",icon:"bell",tone:"info",footer:((j==null?void 0:j.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx($,{size:"sm",disabled:u===0,onClick:()=>m(u-1),children:"Newer"}),e.jsx($,{size:"sm",disabled:(u+1)*f>=((j==null?void 0:j.total)??0),onClick:()=>m(u+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"What happened"}),e.jsx("th",{children:"Who"}),e.jsx("th",{children:"What"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:((j==null?void 0:j.events.length)??0)===0?e.jsx(ae,{columns:6,children:"Nothing has happened in this window."}):j==null?void 0:j.events.map(R=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(R.occurredAt),children:be(R.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(ze,{name:Ys(R.type),tone:Js(R)}),vs(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(M,{tone:"ok",children:"new"}),R.link?e.jsx(re,{className:"table-row-link",to:R.link,children:"Open"}):null]})]},R.id))})]})})})]})}function Un(){const{data:s,error:n,loading:t}=Q("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],r=i.flatMap(l=>l.devices??[]),o=i.filter(l=>{var v;return(v=l.recommendations)==null?void 0:v.completed}).length,a=i.filter(l=>{var v,g;return((v=l.recommendations)==null?void 0:v.prompted)&&!((g=l.recommendations)!=null&&g.completed)}).length,d=i.filter(l=>{var v;return(v=l.watchTime)==null?void 0:v.matched}),c=d.reduce((l,v)=>{var g;return l+(((g=v.watchTime)==null?void 0:g.weekMs)??0)},0);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(V,{message:n}),e.jsx(je,{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."}),t?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Memby users",value:w(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:w(r.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(r.filter(l=>$e(l.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:w(o),icon:"check",tone:"ok"},{label:"setup prompts queued",value:w(a),icon:"sparkle",tone:"note"},...d.length?[{label:"watched by the household this week",value:ke(c),icon:"pulse",tone:"data"}]:[]]}),e.jsx("section",{className:"card flush",children:i.length===0?e.jsx(Y,{children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):i.map(l=>{var h,j;const v=l.devices??[],g=v.filter(b=>$e(b.lastSeen)).length,u=(h=l.recommendations)!=null&&h.completed?{label:"personalised",tone:"ok"}:(j=l.recommendations)!=null&&j.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},m=rs(l.lastSeen),f=l.watchTime;return e.jsxs(re,{className:"list-row",to:`/admin/accounts/${encodeURIComponent(l.id)}`,children:[e.jsxs("span",{className:"list-main",children:[e.jsx("span",{className:"avatar",children:l.initials||Ks(l.username)}),e.jsxs("span",{children:[e.jsxs("span",{className:"list-title",children:[l.username||"Unnamed user",e.jsx("span",{className:"dot-state","data-tone":m.tone,title:m.label})]}),e.jsxs("span",{className:"list-meta",children:[w(v.length)," device",v.length===1?"":"s",g?` · ${g} active now`:""," · last seen ",P(l.lastSeen),f!=null&&f.matched?` · watched ${ke(f.weekMs)} this week, ${ke(f.monthMs)} this month`:""]})]})]}),e.jsxs("span",{className:"list-actions",children:[f!=null&&f.matched?e.jsx(M,{tone:"data",children:ke(f.weekMs)}):null,e.jsx(M,{tone:u.tone,children:u.label}),e.jsx("span",{className:"crumb",children:"Manage"})]})]},l.id)})})]})]})}function Je(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function Vn(){var ue;const{userId:s=""}=We(),n=is(),{wrap:t}=ne(),{busy:i,run:r}=X(),o=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:a,error:d,loading:c,reload:l}=Q("/admin/api/accounts",{pollMs:3e4}),[v,g]=p.useState(null),[u,m]=p.useState(null),[f,h]=p.useState(null),[j,b]=p.useState(null),[k,x]=p.useState(null),y=((a==null?void 0:a.accounts)??[]).find(A=>A.id===s),R=(a==null?void 0:a.catalogue)??[],S=(a==null?void 0:a.themes)??[];p.useEffect(()=>{var A;v===null&&y&&g({...((A=y.settings)==null?void 0:A.preferences)??{}})},[y,v]),p.useEffect(()=>{f===null&&y&&h({...y.notifications})},[y,f]),p.useEffect(()=>{if(u!==null||!y)return;const A=y.themes??[];m(A.length===0?S.map(L=>L.id):A)},[y,u,S]);const H=p.useMemo(()=>{const A=[];for(const L of R){let N=A.find(D=>D.name===L.area);N||A.push(N={name:L.area,definitions:[]}),N.definitions.push(L)}return A},[R]),E=(A,L,N,D)=>r(A,async()=>{const _=await t(L,N);b(null),_!==void 0&&(D==null||D()),await l()});if(c)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"User",crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"})}),e.jsx(W,{})]});if(!y)return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"User",crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"})}),e.jsx(V,{message:d}),e.jsx(T,{children:e.jsx(Y,{children:"This user is no longer signed in to Memby."})})]});const I=y.devices??[],G=I.filter(A=>$e(A.lastSeen)).length,ee=y.settings??{},se=y.recommendations??{};return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:y.username||"Unnamed user",intro:`Memby user · ${w(I.length)} device${I.length===1?"":"s"} · last seen ${P(y.lastSeen)}`,crumbs:e.jsx(re,{to:"/admin/accounts",children:"← All users"}),actions:e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"avatar",children:y.initials||Ks(y.username)}),G?e.jsxs(M,{tone:"ok",children:[G," active now"]}):e.jsx(M,{children:"idle"}),e.jsx(ie,{children:y.id})]})}),e.jsx(V,{message:d}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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:I.length===0?e.jsx(Y,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:I.map(A=>{const L=rs(A.lastSeen);return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":L.tone,title:L.label})," ",e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(A.id)}`,children:A.name||"Memby TV"})]}),e.jsxs("p",{children:[A.version?`Memby ${A.version}`:"Legacy Memby client"," · ",L.label," · last seen ",P(A.lastSeen)," · signed in ",P(A.signedInAt)]}),(A.versions??[]).length>0?e.jsx("div",{className:"chips",children:(A.versions??[]).map(N=>e.jsxs(ie,{tone:N.version===A.version?"ok":void 0,children:[N.version,N.version===A.version?" · now":""]},N.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx($,{size:"sm",disabled:!A.id,onClick:()=>x({id:A.id,name:A.name}),children:"Rename"}),e.jsx($,{size:"sm",variant:"danger",disabled:!A.id,onClick:()=>b({kind:"remove-device",deviceId:A.id,name:A.name}),children:"Sign out"})]})]},A.id||A.name)})})}),e.jsxs(T,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:se.completed?e.jsx($,{busy:i==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):se.prompted?e.jsx($,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx($,{variant:"primary",busy:i==="prompt",onClick:()=>void E("prompt",()=>F.put(`${o}/recommendations/prompt`),"Setup prompt queued."),children:"Send setup prompt"}),children:[e.jsx("div",{className:"row tight",children:se.completed?e.jsx(M,{tone:"ok",children:"completed"}):se.prompted?e.jsx(M,{tone:"warn",children:"prompt queued"}):e.jsx(M,{children:"not invited"})}),e.jsx(Bn,{prompt:se})]})]}),(ue=y.watchTime)!=null&&ue.matched?e.jsx(T,{title:"Watch time",intro:"From Tracearr, for this person across every client — not only Memby. The week runs from Monday and the month from the first, both in the household's own time.",icon:"pulse",tone:"data",actions:y.watchTime.tracearrUsername?e.jsx(ie,{children:y.watchTime.tracearrUsername}):null,children:e.jsx(le,{tiles:[{label:`this week · ${w(y.watchTime.weekSessions)} session${y.watchTime.weekSessions===1?"":"s"}`,value:ke(y.watchTime.weekMs),icon:"pulse",tone:"data"},{label:`this month · ${w(y.watchTime.monthSessions)} session${y.watchTime.monthSessions===1?"":"s"}`,value:ke(y.watchTime.monthMs),icon:"calendar",tone:"info"},{label:"since Tracearr started recording",value:ke(y.watchTime.totalMs),icon:"clock",tone:"note"},{label:"last watched",value:P(y.watchTime.lastWatchedAt),icon:"history",tone:void 0,small:!0}]})}):null,e.jsx(T,{title:"Notifications",intro:"Choose what this person sees across every television. Changes apply through the gateway within a few seconds and do not require an app release.",icon:"bell",tone:"note",actions:f!=null&&f.enabled?e.jsx(M,{tone:"ok",children:"enabled"}):e.jsx(M,{children:"muted"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="notifications",onClick:()=>void E("notifications",()=>F.put(`${o}/notifications`,f),"Notification settings saved.",()=>h(null)),children:"Save notifications"}),e.jsx($,{onClick:()=>{h(null),l()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(z,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:A=>h(L=>L&&{...L,enabled:A})}),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:A=>h(L=>L&&{...L,showReturnAlerts:A})}),e.jsx(z,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,sonarrAlerts:A})}),e.jsx(z,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,radarrAlerts:A})}),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:A=>h(L=>L&&{...L,updateAlerts:A})}),e.jsx(z,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,libraryAlerts:A})}),e.jsx(z,{label:"Weekly watch-time summary",hint:"Send this person their week-to-date and month-to-date viewing on Sunday evening, and a summary of the month just gone once it ends. Needs Tracearr.",checked:f.watchTimeDigest,disabled:!f.enabled,onChange:A=>h(L=>L&&{...L,watchTimeDigest:A})}),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:A=>h(L=>L&&{...L,systemAlerts:A})})]}):null}),e.jsx(T,{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:ee.saved?e.jsxs(M,{tone:ee.source==="admin"?"warn":"ok",children:["r",w(ee.revision)," · ",ee.source||"device"," · ",P(ee.updatedAt)]}):e.jsx(M,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="push",onClick:()=>void E("push",()=>F.put(`${o}/preferences`,{preferences:v??{}}),"Pushed to their televisions.",()=>g(null)),children:"Push to their televisions"}),e.jsx($,{onClick:()=>{g(null),l()},children:"Discard changes"}),e.jsx($,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(re,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:H.map(A=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:A.name}),A.definitions.map(L=>e.jsx(Wn,{definition:L,value:v==null?void 0:v[L.key],onChange:N=>g(D=>({...D??{},[L.key]:N}))},L.key))]},A.name))}),e.jsx(T,{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(M,{children:"all schemes"}):e.jsxs(M,{tone:"note",children:[w((y.themes??[]).length)," of ",w(S.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:i==="themes",onClick:()=>(u??[]).length===0?b({kind:"no-themes"}):void E("themes",()=>F.put(`${o}/themes`,{themes:u??[]}),"Colour schemes saved.",()=>m(null)),children:"Save colour schemes"}),e.jsx($,{onClick:()=>m(S.map(A=>A.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:S.map(A=>{var L,N,D;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(u??[]).includes(A.id),onChange:_=>m(te=>_.target.checked?[...te??[],A.id]:(te??[]).filter(pe=>pe!==A.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":Je((L=A.palette)==null?void 0:L.surface),"--swatch-accent":Je((N=A.palette)==null?void 0:N.accent),"--swatch-hairline":Je((D=A.palette)==null?void 0:D.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:A.name}),e.jsx("p",{children:A.description})]})]},A.id)})})}),e.jsx(T,{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($,{variant:"danger",onClick:()=>b({kind:"remove-account"}),children:"Remove Memby access"})}),k?e.jsx(Hn,{initial:k.name,busy:i==="rename",onCancel:()=>x(null),onConfirm:A=>void E("rename",()=>F.put(`${o}/devices/${encodeURIComponent(k.id)}`,{deviceName:A}),"Device renamed.",()=>x(null))}):null,j?e.jsx(zn,{pending:j,busy:i,username:y.username,onCancel:()=>b(null),onConfirm:()=>{switch(j.kind){case"remove-device":return void E("remove-device",()=>F.del(`${o}/devices/${encodeURIComponent(j.deviceId)}`),"Device signed out.");case"remove-account":return void E("remove-account",()=>F.del(`${o}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void E("reset-rec",()=>F.del(`${o}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void E("reset-prefs",()=>F.del(`${o}/preferences`),"Defaults restored.",()=>g(null));case"no-themes":return void E("themes",()=>F.put(`${o}/themes`,{themes:[]}),"Colour schemes saved.",()=>m(null))}}}):null]})}function Bn({prompt:s}){const n=s.ratings??[],t=[["Genres",s.genres],["Studios",s.studios],["Actors",s.actors],["Actresses",s.actresses],["Directors",s.directors],["Types",s.contentTypes]],i=[...n.map(r=>e.jsxs(ie,{tone:"warn",children:[r.title," · ",w(r.rating)," ★"]},`r:${r.title}`)),...t.flatMap(([r,o])=>(o??[]).map(a=>e.jsxs(ie,{children:[r,": ",a]},`${r}:${a}`)))];return i.length===0?e.jsx(Y,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function Wn({definition:s,value:n,onChange:t}){if(s.kind==="toggle")return e.jsx(z,{label:s.name,hint:s.description,checked:!!n,onChange:t});if(s.kind==="choice"||s.kind==="number"){const r=s.unit??"",o=s.kind==="number"?(s.numbers??[]).map(a=>({value:String(a),label:a===0?"No limit":r?`${a} ${r}`:String(a)})):s.options??[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("select",{value:String(n??""),onChange:a=>t(s.kind==="number"?Number(a.target.value):a.target.value),children:o.map(a=>e.jsx("option",{value:a.value,children:a.label},a.value))})})}if(s.kind==="multi"){const r=Array.isArray(n)?n:[],o=[...r,...(s.options??[]).map(a=>a.value).filter(a=>!r.includes(a))];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:o.map(a=>{const d=(s.options??[]).find(c=>c.value===a);return d?e.jsx(z,{label:d.label,checked:r.includes(a),onChange:c=>t(c?[...r,a]:r.filter(l=>l!==a))},a):null})})]})}if(s.kind==="text")return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("input",{type:"text",value:String(n??""),maxLength:s.maxLength,placeholder:"Generated from their name",onChange:r=>t(r.target.value.toLocaleUpperCase("en-NZ"))})});const i=Array.isArray(n)?n:[];return e.jsx(q,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` -`),onChange:r=>t(r.target.value.split(` -`).map(o=>o.trim()).filter(Boolean))})})}function Hn({initial:s,busy:n,onConfirm:t,onCancel:i}){const[r,o]=p.useState(s||"Memby TV");return e.jsx("div",{className:"scrim",onPointerDown:a=>a.target===a.currentTarget&&i(),children:e.jsxs("div",{className:"dialog",role:"dialog","aria-modal":"true",children:[e.jsx("h2",{children:"Name this device"}),e.jsx("p",{children:"The name a viewer sees in Settings → Devices, and what the console calls it."}),e.jsx(q,{label:"Device name",children:e.jsx("input",{type:"text",value:r,autoFocus:!0,maxLength:80,onChange:a=>o(a.target.value)})}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{variant:"quiet",onClick:i,children:"Cancel"}),e.jsx($,{variant:"primary",busy:n,disabled:!r.trim(),onClick:()=>t(r.trim()),children:"Rename"})]})]})})}function zn({pending:s,busy:n,username:t,onConfirm:i,onCancel:r}){const a={"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 ${t||"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(xe,{title:a.title,body:a.body,confirmLabel:a.label,destructive:a.destructive,busy:!!n,onConfirm:i,onCancel:r})}function Kn(s,n){if(s.kind==="toggle")return n?"On":"Off";if(s.kind==="choice"){const i=(s.options??[]).find(r=>r.value===n);return i?i.label:String(n??"")}if(s.kind==="number")return Number(n)===0&&s.unit?"No limit":s.unit?`${n} ${s.unit}`:String(n??"");const t=Array.isArray(n)?n:[];return t.length===0?"None":t.map(i=>{var r;return((r=(s.options??[]).find(o=>o.value===i))==null?void 0:r.label)??i}).join(", ")}function Gn(){const{userId:s=""}=We(),{wrap:n}=ne(),{busy:t,run:i}=X(),r=`/admin/api/accounts/${encodeURIComponent(s)}`,{data:o,error:a,loading:d,reload:c}=Q(`${r}/preferences/history`,{pollMs:3e4}),[l,v]=p.useState(new Set),[g,u]=p.useState(null),m=(o==null?void 0:o.username)||"this account",f=(o==null?void 0:o.devices)??[],h=(o==null?void 0:o.revisions)??[],j=(o==null?void 0:o.catalogue)??[],b=x=>v(y=>{const R=new Set(y);return R.has(x)?R.delete(x):R.add(x),R}),k=x=>i("restore",async()=>{await n(()=>F.post(`${r}/preferences/revisions/${x}/restore`),`Restored r${x}.`),u(null),await c()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Settings history",intro:`Every change to ${m}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(re,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",m]})}),e.jsx(V,{message:a}),d?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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:o!=null&&o.saved?e.jsxs(M,{tone:o.currentSource==="admin"?"warn":"ok",children:["now on r",w(o.currentRevision)," · ",o.currentSource||"device"]}):e.jsx(M,{children:"defaults · never synced"}),children:f.length===0?e.jsx(Y,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.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(M,{children:"signed out"}):null]}),e.jsxs("p",{children:[x.never?"Has not fetched these settings yet":`Holding r${w(x.revision)} · taken ${P(x.ackedAt)}`,x.clientVersion?` · Memby ${x.clientVersion}`:"",x.signedOut?"":` · last seen ${P(x.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:x.never?e.jsx(M,{children:"never taken one"}):x.behind?e.jsxs(M,{tone:"bad",children:[w(x.behind)," behind"]}):e.jsx(M,{tone:"ok",children:"up to date"})})]},x.deviceId||x.name)})})}),e.jsx(T,{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:h.length===0?e.jsx(ae,{columns:6,children:"Nothing has been changed on this account yet."}):h.flatMap(x=>{const y=l.has(x.revision),R=x.acks??[],S=x.changes??[],H=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(x.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",w(x.revision)," ",x.current?e.jsx(M,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(M,{tone:x.source==="admin"?"warn":"ok",children:x.author}),x.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",w(x.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:x.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):S.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:S.map((E,I)=>e.jsxs(ie,{children:[E.name,": ",E.before," → ",E.after]},`${E.name}:${I}`))})}),e.jsx("td",{className:"num",children:R.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:R.map(E=>E.deviceName||E.deviceId).join(", "),children:w(R.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx($,{size:"sm",onClick:()=>b(x.revision),children:y?"Hide":"Show"}),x.current?null:e.jsx($,{size:"sm",onClick:()=>u(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:j.map(E=>{var I;return e.jsxs(ie,{children:[E.name,":"," ",Kn(E,(I=x.preferences)==null?void 0:I[E.key])]},E.key)})})})},`${x.revision}:detail`)),H})})]})})})]}),g!==null?e.jsx(xe,{title:`Restore revision ${g}?`,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:t==="restore",onConfirm:()=>void k(g),onCancel:()=>u(null)}):null]})}function Zn({client:s}){const n=s.versions??[];return n.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{className:"versions",children:n.map(t=>e.jsx(ie,{tone:t.version===s.version?"ok":void 0,children:t.version},t.version))})}function Jn(){const{status:s,error:n,loading:t}=oe(),i=(s==null?void 0:s.clients)??[],r=i.filter(a=>(a.capabilities??[]).includes("server_features_v1")),o=new Set(i.map(a=>a.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(V,{message:n}),t?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"devices known",value:w(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:w(i.filter(a=>$e(a.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:w(r.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:w(o.size),icon:"download",tone:"note"}]}),e.jsx(T,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(ae,{columns:6,children:"No devices have signed in yet."}):i.map(a=>{const d=(a.capabilities??[]).includes("server_features_v1"),c=rs(a.lastSeen);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsxs("span",{className:"row tight",children:[e.jsx("span",{className:"dot-state","data-tone":c.tone,title:c.label}),e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(a.deviceId)}`,children:a.deviceName||"Memby TV"})]})}),e.jsx("td",{className:"muted",children:a.username}),e.jsx("td",{className:"mono",children:a.version||"legacy"}),e.jsx("td",{children:e.jsx(Zn,{client:a})}),e.jsx("td",{children:e.jsx(M,{tone:d?"ok":"warn",children:d?"reported":"missing"})}),e.jsx("td",{className:"nowrap muted",children:P(a.lastSeen)})]},`${a.deviceId}:${a.username}`)})})]})})})]})]})}const bs={user:"",q:"",ip:"",outcome:"",from:"",to:""},Yn=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function Qn(){var x,y,R,S,H,E;const[s,n]=p.useState("log"),[t,i]=p.useState(7),[r,o]=p.useState(bs),[a,d]=p.useState(0),c=100,l=p.useMemo(()=>Te({...r,days:r.from?void 0:t||void 0,limit:c,offset:a*c}),[r,t,a]),v=Q(`/admin/api/logins${l}`,{enabled:s==="log"}),g=Q(`/admin/api/logins/devices${l}`,{enabled:s==="devices"}),u=((x=v.data)==null?void 0:x.users)??((y=g.data)==null?void 0:y.users)??[],m=((R=v.data)==null?void 0:R.totals)??((S=g.data)==null?void 0:S.totals),f=((H=v.data)==null?void 0:H.retentionDays)??((E=g.data)==null?void 0:E.retentionDays)??90,h=s==="log"?v.loading:g.loading,j=s==="log"?v.error:g.error,b=I=>{o(G=>({...G,...I})),d(0)},k=Object.entries(r).some(([,I])=>I!=="")||!!r.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(Be,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(V,{message:j}),m?e.jsx(le,{tiles:[{label:"Successful sign-ins",value:w(m.logins),icon:"key",tone:"ok"},{label:"Refused",value:w(m.failures),icon:"shield",tone:m.failures>0?"warn":void 0},{label:"Televisions",value:w(m.devices),icon:"tv",tone:"info"},{label:"People",value:w(m.users),icon:"people",tone:"note"},{label:"Addresses",value:w(m.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(q,{label:"Window",children:e.jsx(Be,{value:r.from?-1:t,options:Yn.map(I=>({value:I.value,label:I.label})),onChange:I=>{i(I),b({from:"",to:""})}})}),e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r.user,onChange:I=>b({user:I.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),u.map(I=>e.jsx("option",{value:I.id,children:I.username||I.id},I.id))]})}),e.jsx(q,{label:"Outcome",children:e.jsxs("select",{value:r.outcome,onChange:I=>b({outcome:I.target.value}),children:[e.jsx("option",{value:"",children:"Both"}),e.jsx("option",{value:"success",children:"Got in"}),e.jsx("option",{value:"failure",children:"Refused"})]})}),e.jsx(q,{label:"Address",children:e.jsx("input",{type:"text",value:r.ip,placeholder:"10.0.0.4",onChange:I=>b({ip:I.target.value})})}),e.jsx(q,{label:"From",children:e.jsx("input",{type:"date",value:r.from,onChange:I=>b({from:I.target.value})})}),e.jsx(q,{label:"To",children:e.jsx("input",{type:"date",value:r.to,onChange:I=>b({to:I.target.value})})}),e.jsx(q,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:r.q,placeholder:"Name, device or address",onChange:I=>b({q:I.target.value})})}),e.jsx("div",{className:"filter-actions",children:k?e.jsx($,{variant:"quiet",size:"sm",onClick:()=>{o(bs),d(0)},children:"Clear"}):null})]}),h?e.jsx(W,{}):s==="log"?e.jsx(Xn,{data:v.data,page:a,limit:c,onPage:d}):e.jsx(et,{data:g.data})]})}function Xn({data:s,page:n,limit:t,onPage:i}){if(!s)return null;const r=s.events.length,o=s.total===0?0:n*t+1;return e.jsxs(e.Fragment,{children:[e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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(Qs,{data:s.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} refused, ${a.devices} televisions`})}),e.jsx(T,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(Y,{children:"No addresses in this window."}):e.jsx("div",{className:"list",children:s.addresses.slice(0,8).map(a=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:a.ipAddress}),e.jsxs("p",{children:[w(a.logins)," in",a.failures>0?` · ${w(a.failures)} refused`:""]})]}),a.failures>0&&a.logins===0?e.jsx(M,{tone:"bad",children:"only refused"}):null]},a.ipAddress))})})]}),e.jsx(T,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${w(o)}–${w(o+r-1)} of ${w(s.total)}`}),footer:s.total>t?e.jsxs(e.Fragment,{children:[e.jsx($,{size:"sm",disabled:n===0,onClick:()=>i(n-1),children:"Newer"}),e.jsx($,{size:"sm",disabled:(n+1)*t>=s.total,onClick:()=>i(n+1),children:"Older"})]}):void 0,children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"Television"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:s.events.length===0?e.jsx(ae,{columns:6,children:"No sign-in attempts match these filters."}):s.events.map(a=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(a.occurredAt)}),e.jsx("td",{children:a.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{children:a.deviceId?e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(a.deviceId)}`,children:a.deviceName||a.deviceId}):e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"mono nowrap",children:a.ipAddress||"—"}),e.jsx("td",{className:"mono",children:a.clientVersion||"—"}),e.jsx("td",{className:"nowrap",children:a.success?a.newDevice?e.jsx(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:a.failureReason||"refused"})})]},a.id))})]})})})]})}function et({data:s}){return s?e.jsx(T,{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(ae,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(n.deviceId)}`,children:n.deviceName||n.deviceId})}),e.jsx("td",{className:"muted",children:n.username||"—"}),e.jsx("td",{className:"num",children:n.loginsToday>0?w(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:w(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:w(n.failures)}):"—"}),e.jsx("td",{className:"num",children:w(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?P(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const st=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function nt(){const{deviceId:s=""}=We(),[n,t]=p.useState(7),i=p.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Te({days:n||void 0,limit:200})}`,[s,n]),{data:r,error:o,loading:a}=Q(i,{enabled:!!s}),d=r==null?void 0:r.summary,c=(d==null?void 0:d.deviceName)||s;return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:c,intro:"One television's whole relationship with the gateway.",crumbs:e.jsxs(e.Fragment,{children:[e.jsx(re,{to:"/admin/clients",children:"Devices"}),e.jsx("span",{children:"/"}),e.jsx(re,{to:"/admin/logins",children:"Sign-ins"}),e.jsx("span",{children:"/"}),e.jsx("span",{children:c})]}),actions:e.jsx(Be,{value:n,options:st.map(l=>({value:l.value,label:l.label})),onChange:t})}),e.jsx(V,{message:o}),a?e.jsx(W,{}):r?e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Sign-ins today",value:w((d==null?void 0:d.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:w((d==null?void 0:d.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:w((d==null?void 0:d.failures)??0),icon:"shield",tone:((d==null?void 0:d.failures)??0)>0?"warn":void 0},{label:"Addresses seen",value:w((d==null?void 0:d.distinctIps)??0),icon:"globe",tone:"data"},{label:"First seen",value:d!=null&&d.firstLogin?P(d.firstLogin):"—",small:!0,icon:"history"},{label:"Last seen",value:d!=null&&d.lastLogin?P(d.lastLogin):"—",small:!0,icon:"pulse",tone:"note"}]}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(Qs,{data:r.days,labelOf:l=>l.day,valueOf:l=>l.logins+l.failures,toneOf:l=>l.failures>l.logins?"bad":void 0,title:l=>`${l.day}: ${l.logins} in${l.failures?`, ${l.failures} refused`:""}`})}),e.jsxs("div",{className:"stack",children:[e.jsx(T,{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:(d==null?void 0:d.username)||"unknown"})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Device id"}),e.jsx("p",{className:"mono",children:r.deviceId})]})}),e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Running"}),e.jsx("p",{className:"mono",children:(d==null?void 0:d.clientVersion)||"unknown"})]})})]})}),e.jsx(T,{title:"Builds",intro:"Kept per television rather than per session, so it survives a sign-out.",icon:"upload",tone:"note",children:r.versions.length===0?e.jsx(Y,{children:"No build history for this television."}):e.jsx("div",{className:"list",children:r.versions.map(l=>e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{className:"mono",children:l.version}),e.jsxs("p",{children:[P(l.firstSeen)," → ",P(l.lastSeen)]})]})},l.version))})})]})]}),e.jsx(T,{title:"Addresses",icon:"globe",tone:"data",children:r.addresses.length===0?e.jsx(Y,{children:"No addresses recorded in this window."}):e.jsx("div",{className:"chips",children:r.addresses.map(l=>e.jsxs(ie,{tone:l.failures>0?"warn":"data",children:[l.ipAddress," · ",w(l.logins),l.failures>0?` (+${w(l.failures)} refused)`:""]},l.ipAddress))})}),e.jsx(T,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[w(r.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:r.events.length===0?e.jsx(ae,{columns:6,children:"This television has not connected in the selected window."}):r.events.map(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(l.occurredAt)}),e.jsx("td",{children:l.username||e.jsx("span",{className:"quiet",children:"unknown"})}),e.jsx("td",{className:"mono nowrap",children:l.ipAddress||"—"}),e.jsx("td",{className:"mono",children:l.clientVersion||"—"}),e.jsx("td",{className:"muted",children:l.method}),e.jsx("td",{className:"nowrap",children:l.success?l.newDevice?e.jsx(M,{tone:"info",children:"first sign-in"}):e.jsx(M,{tone:"ok",children:"got in"}):e.jsx(M,{tone:"bad",children:l.failureReason||"refused"})})]},l.id))})]})})})]}):null]})}function tt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(!1),l=(s==null?void 0:s.library.byType)??{},v=!!(s!=null&&s.syncRunning),g=u=>a(u,async()=>{await r(()=>F.post("/admin/api/sync",{kind:u}),u==="full"?"Full re-import started.":"Import started."),c(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Library",intro:"Import and inspect the catalogue Memby ranks."}),e.jsx(V,{message:n}),t||!s?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"items",value:w(s.library.total),icon:"library",tone:"data"},...Object.keys(l).sort().map(u=>({label:u,value:w(l[u]),icon:"list"})),{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx(T,{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($,{variant:"primary",icon:"sync",disabled:v,busy:o==="incremental",onClick:()=>void g("incremental"),children:"Sync new items"}),e.jsx($,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full re-import"})]})})]}),d?e.jsx(xe,{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:o==="full",onConfirm:()=>void g("full"),onCancel:()=>c(!1)}):null]})}const it={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 at(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(!1),[l,v]=p.useState(""),[g,u]=p.useState(!1),[m,f]=p.useState([]),[h,j]=p.useState(!1),b=s==null?void 0:s.mdblist;p.useEffect(()=>{h||!b||(c(b.enabled),f(b.sources??[]))},[b,h]);const k=()=>a("save",async()=>{await r(()=>F.post("/admin/api/mdblist-settings",{enabled:d,apiKey:l.trim(),clearApiKey:g,sources:m}),"Ratings settings saved."),v(""),u(!1),j(!1),await i()}),x=(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(V,{message:n}),t||!b?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"titles stored",value:w(x),icon:"database",tone:"data"},{label:"due to be re-checked",value:w(b.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:w((b.sources??[]).length),icon:"star",tone:"note"},{label:"API key",value:b.apiKeyConfigured?"saved":"not set",small:!0,icon:"key",tone:b.apiKeyConfigured?"ok":void 0}]}),e.jsxs(he,{cols:"2",children:[e.jsxs(T,{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:d?e.jsxs(M,{tone:"ok",children:["on · ",m.length," sources"]}):e.jsx(M,{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:d,onChange:y=>{c(y),j(!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:l,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:g,onChange:u})]}),e.jsx(T,{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(Y,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(b.availableSources??[]).map(y=>e.jsx(z,{label:it[y]??y,checked:m.includes(y),onChange:R=>{j(!0),f(S=>R?[...S,y]:S.filter(H=>H!==y))}},y))})})]}),e.jsx(T,{children:e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void k(),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 rt(){var g;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),d=(s==null?void 0:s.requestUsers)??[],c=((g=s==null?void 0:s.requestPolicy)==null?void 0:g.allowedUserIds)??[],l=p.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(u=>[u.userId,u])),[s==null?void 0:s.requestUsage]),v=u=>a(`access-${u}`,async()=>{const m=c.includes(u)?c.filter(f=>f!==u):[...c,u];await r(()=>F.post("/admin/api/request-policy",{allowedUserIds:m}),m.includes(u)?"Request access granted.":"Request access removed."),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Media requests",intro:"Who can ask for something the library does not have."}),e.jsx(V,{message:n}),t?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(T,{title:"Where a request goes",intro:"Movies follow Radarr’s policy. Series follow the dedicated Sonarr request policy: monitored as normal, with backlog searching only when an operator enables it under Integrations.",icon:"inbox",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsxs(M,{tone:s!=null&&s.radarrReady?"ok":"bad",children:["Movies ",s!=null&&s.radarrReady?"ready":"not configured"]}),e.jsxs(M,{tone:s!=null&&s.sonarrReady?"ok":"bad",children:["Series ",s!=null&&s.sonarrReady?"ready":"not configured"]})]}),children:!(s!=null&&s.radarrReady)&&!(s!=null&&s.sonarrReady)?e.jsx(Y,{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(T,{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:d.length===0?e.jsx(Y,{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:d.map(u=>{const m=l.get(u.id),f=c.includes(u.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:u.username})}),e.jsx("td",{className:"muted nowrap",children:P(u.lastSeen)}),e.jsx("td",{className:"num",children:(m==null?void 0:m.requests)??0}),e.jsx("td",{className:"muted nowrap",children:m!=null&&m.lastRequest?P(m.lastRequest):"—"}),e.jsx("td",{children:e.jsx($,{size:"sm",variant:f?"quiet":"primary",busy:o===`access-${u.id}`,onClick:()=>void v(u.id),children:f?"Remove access":"Give access"})})]},u.id)})})]})})})]})]})}function lt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(!1),l=s==null?void 0:s.forYou,v=!!(s!=null&&s.forYouRunning),g=(u,m,f)=>a(m,async()=>{await r(()=>F.post("/admin/api/for-you",{action:u}),f),c(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"For You",intro:"The prepared pools personalised rows are drawn from."}),e.jsx(V,{message:n}),t?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Tracearr sessions",value:w((l==null?void 0:l.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:w((l==null?void 0:l.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:w((l==null?void 0:l.candidates)??0),icon:"sparkle",tone:"note"},{label:"last full import",value:P(l==null?void 0:l.lastFullImport),small:!0,icon:"clock"}]}),e.jsx(T,{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($,{variant:"primary",icon:"download",disabled:v,busy:o==="import",onClick:()=>void g("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx($,{icon:"database",disabled:v,onClick:()=>c(!0),children:"Full Tracearr backfill"}),e.jsx($,{icon:"sync",disabled:v,busy:o==="rebuild",onClick:()=>void g("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx(T,{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(re,{to:"/admin/inspector",children:"Open the inspector"}),children:e.jsx(e.Fragment,{})})]}),d?e.jsx(xe,{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:o==="full",onConfirm:()=>void g("full-import","full","Backfill started."),onCancel:()=>c(!1)}):null]})}const ot=[["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 ct(s){return s?ot.flatMap(([n,t])=>Object.entries(s[t]??{}).map(([i,r])=>({dimension:n,name:i,weight:r.weight??0,evidence:r.evidence??0}))).sort((n,t)=>Math.abs(t.weight)-Math.abs(n.weight)):[]}const fs=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function dt(){const{status:s}=oe(),{wrap:n}=ne(),{busy:t,run:i}=X(),[r,o]=p.useState(""),[a,d]=p.useState("default"),[c,l]=p.useState("0"),[v,g]=p.useState(""),[u,m]=p.useState(null),[f,h]=p.useState("Choose a person to inspect their recommendations."),[j,b]=p.useState(""),k=(s==null?void 0:s.requestUsers)??[],x=()=>i("run",async()=>{if(!r){b("Choose a person to pressure-test.");return}b(""),h("Running the permission check and the scorer…");const E=new URLSearchParams({userId:r,context:a,minutes:c||"0",limit:"100"});v&&E.set("at",new Date(v).toISOString());const I=await n(()=>F.get(`/admin/api/recommendations?${E.toString()}`));I?(m(I),h(`Scored at ${new Date().toLocaleTimeString()}.`)):h("Pressure test failed.")}),y=ct((u==null?void 0:u.profile)??null).slice(0,24),R=(u==null?void 0:u.actions)??[],S=(u==null?void 0:u.items)??[],H=(u==null?void 0:u.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(V,{message:j}),e.jsx(T,{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($,{variant:"primary",busy:t==="run",onClick:()=>void x(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Person",children:e.jsxs("select",{value:r,onChange:E=>o(E.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),k.map(E=>e.jsx("option",{value:E.id,children:E.username},E.id))]})}),e.jsx(q,{label:"Context",children:e.jsxs("select",{value:a,onChange:E=>d(E.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:c,onChange:E=>l(E.target.value)})}),e.jsx(q,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:v,onChange:E=>g(E.target.value)})})]})}),u?e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"prepared pool",value:w(u.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:w(u.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:w(S.length),icon:"sparkle",tone:"note"},{label:"source events",value:w(H.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:H.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:P(H.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs(T,{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(Y,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(E=>e.jsxs(ie,{tone:E.weight<0?"bad":void 0,children:[E.dimension,": ",E.name," ",fs(E.weight)," · n=",w(E.evidence)]},`${E.dimension}:${E.name}`))}),R.length===0?e.jsx(Y,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:R.map((E,I)=>e.jsxs(ie,{tone:"ok",children:[E.action,": ",E.title||E.itemId]},`${E.action}:${I}`))})]}),S.length===0?e.jsx(T,{children:e.jsx(Y,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(he,{children:S.map((E,I)=>{const G=E.explanation??{},ee=Object.entries(G.components??{}).sort((A,L)=>Math.abs(L[1])-Math.abs(A[1])),se=E.exposure??{},ue=[E.type,E.year,E.runtimeMinutes?`${E.runtimeMinutes} min`:null,...E.genres??[]].filter(Boolean).join(" · ");return e.jsxs(T,{title:`#${I+1} · ${E.title}`,intro:ue,actions:e.jsx(ie,{tone:"ok",children:Number(G.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[E.preparedReason||"No legacy prepared explanation",E.compatibilityLabel?` · ${E.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(G.reasonCodes??[]).map(A=>e.jsx(ie,{tone:"ok",children:A},A)),ee.map(([A,L])=>e.jsxs(ie,{tone:L<0?"bad":void 0,children:[A,"=",fs(L)]},A))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",w(E.baseRank??0)," · base ",Number(E.baseScore??0).toFixed(3)," · affinity ",Number(E.affinityScore??0).toFixed(3)," · compatibility"," ",Number(E.compatibilityScore??0).toFixed(3)," · impressions"," ",w(se.impressions??0)," · focuses ",w(se.focuses??0)," · selects"," ",w(se.selects??0)]}),e.jsx("div",{className:"chips",children:(E.eligibleRows??[]).map(A=>e.jsx(ie,{tone:"ok",children:A},A))}),E.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",E.preparedEvidenceTitle]}):null]})]},`${I}:${E.title}`)})})]}):null]})}const ht=4,we=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],Ee=()=>({pinnedItems:[],primeSubtitle:""}),ns=[{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 ys(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function ws(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function ut(s){return s.frequency??"once"}function mt(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=ns.filter(i=>(s.weekdays??[]).includes(i.value));return`${(n.length===7?"Every day":n.map(i=>i.short).join(", "))||"No days selected"} · ${s.startTime}–${s.endTime}`}return`${s.startAt?new Date(s.startAt).toLocaleString():"Start missing"} → ${s.endAt?new Date(s.endAt).toLocaleString():"End missing"}`}function ks(s,n){const t=new Date;t.setMinutes(Math.ceil(t.getMinutes()/30)*30,0,0);const i=new Date(t.getTime()+2*60*60*1e3),r=n==="home"||n==="movies"&&s.type==="Movie"||n==="tv_shows"&&s.type==="Series";return{id:crypto.randomUUID(),itemId:s.id,startAt:t.toISOString(),endAt:i.toISOString(),priority:0,enabled:!0,placements:[r?n:"home"]}}function pt(){var ue,A,L;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=ne(),{busy:a,run:d}=X(),[c,l]=p.useState("home"),[v,g]=p.useState({home:Ee(),movies:Ee(),tv_shows:Ee()}),[u,m]=p.useState(!1),[f,h]=p.useState(""),[j,b]=p.useState(null),[k,x]=p.useState([]),[y,R]=p.useState(null),S=s==null?void 0:s.heroPolicy;p.useEffect(()=>{var N,D,_;u||!S||(g({home:((N=S.placements)==null?void 0:N.home)??{pinnedItems:S.pinnedItems??[],primeSubtitle:S.primeSubtitle??""},movies:((D=S.placements)==null?void 0:D.movies)??Ee(),tv_shows:((_=S.placements)==null?void 0:_.tv_shows)??Ee()}),x(S.schedules??[]))},[S,u]);const H=()=>d("search",async()=>{const N=f.trim();if(!N)return;const D=await r(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(N)}`));D&&b(D.items??[])}),E=v[c],I=E.pinnedItems??[],G=N=>{g(D=>({...D,[c]:{...D[c],...N}})),m(!0)},ee=N=>{if(!I.some(D=>D.id===N.id)){if(I.length>=ht){o("Remove a pinned title before adding another.","bad");return}G({pinnedItems:[...I,N]})}},se=()=>d("save",async()=>{await r(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(v).map(([N,D])=>[N,{pinnedItemIds:(D.pinnedItems??[]).map(_=>_.id),primeSubtitle:D.primeSubtitle.trim()}])),schedules:k}),"Hero saved."),m(!1),await i()});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(V,{message:n}),t?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(he,{children:we.map(N=>{var Le;const D=v[N.id],_=k.filter(me=>me.enabled&&(me.placements??["home"]).includes(N.id)).sort((me,Ge)=>Ge.priority-me.priority)[0],te=(D.pinnedItems??[]).length?"Manual":_?"Schedule ready":"Automatic",pe=(Le=S==null?void 0:S.items)==null?void 0:Le.find(me=>me.id===(_==null?void 0:_.itemId)),Ie=(D.pinnedItems??[]).map(me=>me.name).join(", ")||(pe==null?void 0:pe.name)||(_==null?void 0:_.itemId)||"Resolved for each viewer";return e.jsx(T,{title:N.label,intro:`${te} · ${Ie}`,tone:N.id===c?"info":void 0,children:e.jsxs($,{size:"sm",variant:"quiet",onClick:()=>l(N.id),children:["Manage ",N.label]})},N.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:we.map(N=>e.jsx($,{variant:c===N.id?"primary":"quiet",onClick:()=>l(N.id),children:N.label},N.id))}),e.jsxs(T,{title:`${(ue=we.find(N=>N.id===c))==null?void 0:ue.label} hero`,intro:`Pinned ${(A=we.find(N=>N.id===c))==null?void 0:A.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($,{variant:"primary",busy:a==="save",onClick:()=>void se(),children:"Save hero"}),e.jsx($,{onClick:()=>{G({pinnedItems:[]})},children:"Clear pins"}),u?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[I.length===0?e.jsx(Y,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:I.map((N,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:N.name}),e.jsxs("small",{children:[N.type,N.year?` · ${N.year}`:""]})]}),e.jsx($,{size:"sm",icon:"clock",onClick:()=>R(ks(N,c)),children:"Schedule"}),e.jsx($,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${N.name}`,onClick:()=>G({pinnedItems:I.filter(_=>_.id!==N.id)})})]},N.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:E.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:N=>{G({primeSubtitle:N.target.value})}})})]}),e.jsx(T,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(M,{tone:"info",children:(S==null?void 0:S.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:a==="save",onClick:()=>void se(),children:"Save schedule"}),e.jsx("span",{className:"hint",children:"Daily and weekly rules repeat until you switch them off."})]}),children:k.length===0?e.jsx(Y,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...k].sort((N,D)=>Number(D.enabled)-Number(N.enabled)||D.priority-N.priority).map(N=>{const D=[...(S==null?void 0:S.items)??[],...I,...j??[]].find(_=>_.id===N.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":N.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:N.frequency==="weekly"?"Weekly":N.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:N.frequency?N.startTime:N.startAt?new Date(N.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)??N.itemId}),e.jsx(M,{tone:N.enabled?"ok":void 0,children:N.enabled?"enabled":"paused"})]}),e.jsx("p",{children:mt(N)}),e.jsxs("div",{className:"chips",children:[(N.placements??["home"]).map(_=>{var te;return e.jsx("span",{className:"chip",children:(te=we.find(pe=>pe.id===_))==null?void 0:te.label},_)}),N.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",N.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx($,{size:"sm",onClick:()=>R({...N}),children:"Edit"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{x(_=>_.map(te=>te.id===N.id?{...te,enabled:!te.enabled}:te)),m(!0)},children:N.enabled?"Pause":"Enable"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{x(_=>_.filter(te=>te.id!==N.id)),m(!0)},children:"Remove"})]})]},N.id)})})}),e.jsxs(T,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(L=we.find(N=>N.id===c))==null?void 0:L.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:f,placeholder:"Search films and television shows",onChange:N=>h(N.target.value),onKeyDown:N=>{N.key==="Enter"&&H()}})}),e.jsx($,{busy:a==="search",icon:"search",onClick:()=>void H(),children:"Search"})]}),j===null?null:j.length===0?e.jsx(Y,{children:"No playable films or series matched that search."}):e.jsx(he,{children:j.map(N=>e.jsx(T,{title:N.name,intro:`${N.type||"Title"} · ${N.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx($,{size:"sm",icon:"plus",disabled:I.some(D=>D.id===N.id)||c==="movies"&&N.type!=="Movie"||c==="tv_shows"&&N.type!=="Series",onClick:()=>ee(N),children:I.some(D=>D.id===N.id)?"Pinned":"Add to hero"}),e.jsx($,{size:"sm",icon:"clock",onClick:()=>R(ks(N,c)),children:"Schedule"})]})},N.id))})]})]}),y?e.jsx(xt,{schedule:y,item:[...(S==null?void 0:S.items)??[],...I,...j??[]].find(N=>N.id===y.itemId),timeZone:(S==null?void 0:S.timeZone)||"server local time",isNew:!k.some(N=>N.id===y.id),onCancel:()=>R(null),onSave:N=>{x(D=>D.some(_=>_.id===N.id)?D.map(_=>_.id===N.id?N:_):[...D,N]),R(null),m(!0)}}):null]})}function xt({schedule:s,item:n,timeZone:t,isNew:i,onSave:r,onCancel:o}){const[a,d]=p.useState({...s,weekdays:[...s.weekdays??[]]}),c=ut(a),l=h=>{const j=new Date,b=new Date(j.getTime()+2*60*60*1e3);d(k=>{var x;return h==="once"?{...k,frequency:void 0,startAt:ws(k.startAt)?k.startAt:j.toISOString(),endAt:ws(k.endAt)?k.endAt:b.toISOString()}:{...k,frequency:h,startTime:k.startTime||"18:00",endTime:k.endTime||"22:00",weekdays:h==="weekly"?(x=k.weekdays)!=null&&x.length?k.weekdays:[1,2,3,4,5]:[]}})},v=a.placements??["home"],g=h=>h==="home"||h==="movies"&&(n==null?void 0:n.type)==="Movie"||h==="tv_shows"&&(n==null?void 0:n.type)==="Series",u=!!(a.startAt&&a.endAt&&new Date(a.endAt)>new Date(a.startAt)),m=!!(a.startTime&&a.endTime&&a.startTime!==a.endTime&&(c!=="weekly"||(a.weekdays??[]).length>0)),f=c==="once"?u:m;return e.jsx("div",{className:"scrim",onPointerDown:h=>h.target===h.currentTarget&&o(),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)??a.itemId}),e.jsxs("p",{children:["Choose exactly when this title can lead the selected sections. Times use ",t,"."]})]}),e.jsx("div",{className:"schedule-frequency",role:"group","aria-label":"Schedule frequency",children:["once","daily","weekly"].map(h=>e.jsxs("button",{type:"button","aria-pressed":c===h,onClick:()=>l(h),children:[e.jsx("b",{children:h==="once"?"One time":h==="daily"?"Every day":"Weekly"}),e.jsx("span",{children:h==="once"?"A date range":h==="daily"?"Same time daily":"Choose days"})]},h))}),c==="once"?e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:a.startAt?ys(new Date(a.startAt)):"",onChange:h=>d(j=>({...j,startAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})}),e.jsx(q,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:a.endAt?ys(new Date(a.endAt)):"",onChange:h=>d(j=>({...j,endAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts each time",children:e.jsx("input",{type:"time",value:a.startTime??"",onChange:h=>d(j=>({...j,startTime:h.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:a.endTime??"",onChange:h=>d(j=>({...j,endTime:h.target.value}))})})]}),c==="weekly"?e.jsxs("div",{className:"schedule-days",children:[e.jsxs("div",{className:"schedule-days-head",children:[e.jsx("b",{children:"Days"}),e.jsxs("div",{children:[e.jsx("button",{type:"button",onClick:()=>d(h=>({...h,weekdays:[1,2,3,4,5]})),children:"Weekdays"}),e.jsx("button",{type:"button",onClick:()=>d(h=>({...h,weekdays:[6,0]})),children:"Weekend"}),e.jsx("button",{type:"button",onClick:()=>d(h=>({...h,weekdays:ns.map(j=>j.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:ns.map(h=>{const j=(a.weekdays??[]).includes(h.value);return e.jsx("button",{type:"button","aria-pressed":j,title:h.label,onClick:()=>d(b=>({...b,weekdays:j?(b.weekdays??[]).filter(k=>k!==h.value):[...b.weekdays??[],h.value]})),children:h.short},h.value)})})]}):null]}),e.jsxs("div",{className:"schedule-options",children:[e.jsxs("div",{children:[e.jsx("span",{className:"schedule-option-label",children:"Show in"}),e.jsx("div",{className:"schedule-placement-grid",children:we.map(h=>e.jsx(z,{label:h.label,checked:v.includes(h.id),disabled:!g(h.id),onChange:j=>d(b=>{const k=b.placements??["home"],x=j?[...k,h.id]:k.filter(y=>y!==h.id);return{...b,placements:x.length?[...new Set(x)]:k}})},h.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:a.priority,onChange:h=>d(j=>({...j,priority:Number(h.target.value)}))})})]}),e.jsx(z,{label:"Schedule enabled",hint:"Pause it without losing its days and times.",checked:a.enabled,onChange:h=>d(j=>({...j,enabled:h}))}),e.jsxs("div",{className:"dialog-actions",children:[e.jsx($,{variant:"quiet",onClick:o,children:"Cancel"}),e.jsx($,{variant:"primary",disabled:!f,onClick:()=>r(a),children:i?"Add rule":"Save rule"})]})]})})}function jt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(null),l=s==null?void 0:s.features,v=(l==null?void 0:l.features)??[],g=(s==null?void 0:s.clients)??[],u=(l==null?void 0:l.revision)??0,m=(b,k,x,y)=>a(k,async()=>{await r(()=>F.post("/admin/api/features",{action:b,expectedRevision:u,overrides:y??{}}),x),c(null),await i()}),f=g.filter(b=>(b.capabilities??[]).includes("server_features_v1")).length,h=!!(l!=null&&l.safeMode),j=(b,k)=>({...Object.fromEntries(v.filter(x=>x.source==="override").map(x=>[x.key,x.enabled])),[b]:k});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(V,{message:n}),t||!l?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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($,{variant:h?void 0:"danger",busy:o==="safe",onClick:()=>h?void m("leave-safe-mode","safe","Safe mode ended."):c({action:"safe-mode",title:"Enable safe mode?",body:"Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.",label:"Enable safe mode"}),children:h?"Leave safe mode":"Enable safe mode"}),children:e.jsx(Xs,{tiles:[{label:"features active",value:`${v.filter(b=>b.enabled).length} / ${v.length}`},{label:"explicit overrides",value:w(v.filter(b=>b.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${g.length}`},{label:"published revision",value:`r${w(u)}`}]})}),e.jsx(he,{cols:"2",children:v.length===0?e.jsx(T,{title:"Nothing registered",icon:"sliders",children:e.jsx(Y,{children:"No server features are registered."})}):v.map(b=>e.jsxs(T,{title:b.name,intro:b.description,actions:e.jsx(M,{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:o===b.key,onChange:k=>c({action:"feature",key:b.key,enabled:k,title:`${k?"Turn on":"Turn off"} ${b.name}?`,body:`${k?"Enable":"Disable"} this feature for every compatible television. ${b.recovery}`,label:k?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(ie,{children:b.key}),e.jsxs(ie,{children:["protocol ",w(b.minimumProtocol),"+"]}),e.jsx(ie,{tone:b.compatible?"ok":"warn",children:b.compatible?"server compatible":"compatibility blocked"}),e.jsx(ie,{tone:"note",children:b.area})]})]},b.key))}),e.jsx(T,{children:e.jsxs("div",{className:"row",children:[e.jsx($,{disabled:!l.canRollback,onClick:()=>c({action:"rollback",title:"Roll back one revision?",body:"The previous published feature revision is restored on every television.",label:"Roll back"}),children:"Roll back one revision"}),e.jsx($,{onClick:()=>c({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),h?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["live · revision r",w(u)]})]})})]}),d?e.jsx(xe,{title:d.title,body:d.body,confirmLabel:d.label,destructive:d.action!=="rollback",busy:o===d.action,onConfirm:()=>void m(d.action==="feature"?"save":d.action,d.action==="feature"?d.key??"feature":d.action,`${d.label} done.`,d.action==="feature"&&d.key?j(d.key,!!d.enabled):void 0),onCancel:()=>c(null)}):null]})}function vt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=ne(),{busy:a,run:d}=X(),[c,l]=p.useState(!0),[v,g]=p.useState("6.5"),[u,m]=p.useState(!1);p.useEffect(()=>{var h,j;u||!s||(l(((h=s.playbackPolicy)==null?void 0:h.prerollEnabled)!==!1),g(String((((j=s.playbackPolicy)==null?void 0:j.prerollDurationMs)??6500)/1e3)))},[s,u]);const f=()=>d("save",async()=>{const h=Number(v);if(!Number.isFinite(h)||h<1||h>30){o("The preroll duration must be between 1 and 30 seconds.","bad");return}await r(()=>F.post("/admin/api/playback-policy",{prerollEnabled:c,prerollDurationMs:Math.round(h*1e3)}),"Playback policy saved."),m(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Playback",intro:"Presentation policy sent with every playback launch."}),e.jsx(V,{message:n}),t?e.jsx(W,{rows:1}):e.jsxs(T,{title:"Upcoming-show preroll",intro:"Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required.",icon:"play",tone:"info",actions:c?e.jsxs(M,{tone:"ok",children:["on · ",v,"s"]}):e.jsx(M,{children:"off"}),footer:e.jsx($,{variant:"primary",busy:a==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(z,{label:"Show the preroll before a title starts",checked:c,onChange:h=>{l(h),m(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(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:h=>{g(h.target.value),m(!0)}})})})]})]})}function gt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(null),[l,v]=p.useState(null),[g,u]=p.useState(!1),m=s==null?void 0:s.subtitles,f=m==null?void 0:m.stored;p.useEffect(()=>{d||!m||c({bazarr:m.bazarrEnabled,openSubtitles:m.openSubtitlesEnabled,key:"",clearKey:!1,username:m.openSubtitlesUsername??"",password:"",clearLogin:!1})},[m,d]);const h=x=>c(y=>y&&{...y,...x}),j=()=>a("save",async()=>{d&&(await r(()=>F.post("/admin/api/subtitle-settings",{bazarrEnabled:d.bazarr,openSubtitlesEnabled:d.openSubtitles,openSubtitlesApiKey:d.key.trim(),clearOpenSubtitlesApiKey:d.clearKey,openSubtitlesUsername:d.username.trim(),openSubtitlesPassword:d.password,clearOpenSubtitlesLogin:d.clearLogin}),"Subtitle settings saved."),c(null),await i())}),b=()=>a("test",async()=>{v(null);const x=await r(()=>F.post("/admin/api/subtitle-test"));v((x==null?void 0:x.results)??[])}),k=()=>a("clear",async()=>{await r(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),u(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Subtitles",intro:"Which providers a viewer may fetch a missing subtitle from."}),e.jsx(V,{message:n}),t||!m||!d?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"offered on televisions",value:m.available?"yes":"no",small:!0,icon:"captions",tone:m.available?"ok":void 0},{label:"providers on",value:w((m.bazarrEnabled&&m.bazarrConfigured?1:0)+(m.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:w((f==null?void 0:f.count)??0),icon:"database",tone:"data"},{label:"last fetched",value:P(f==null?void 0:f.latest),small:!0,icon:"clock"}]}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{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?d.bazarr?e.jsx(M,{tone:"ok",children:"on"}):e.jsx(M,{children:"off"}):e.jsx(M,{children:"not configured"}),footer:e.jsx("span",{className:"hint",children:m.bazarrConfigured?`Configured at ${m.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(z,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:d.bazarr,disabled:!m.bazarrConfigured,onChange:x=>h({bazarr:x})})}),e.jsxs(T,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:m.openSubtitlesEnabled?e.jsx(M,{tone:m.openSubtitlesAccount?"ok":"warn",children:m.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(M,{children:m.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(z,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:d.openSubtitles,onChange:x=>h({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:d.key,placeholder:m.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:x=>h({key:x.target.value})})}),e.jsx(z,{label:"Remove the saved key",checked:d.clearKey,onChange:x=>h({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:d.username,placeholder:"Not signed in",onChange:x=>h({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:d.password,onChange:x=>h({password:x.target.value})})})]}),e.jsx(z,{label:"Sign out and forget the account",checked:d.clearLogin,onChange:x=>h({clearLogin:x})})]})]}),e.jsxs(T,{children:[e.jsxs("div",{className:"row",children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void j(),children:"Save subtitle settings"}),e.jsx($,{busy:o==="test",icon:"pulse",onClick:()=>void b(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:m.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),l===null?null:l.length===0?e.jsx(Y,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:l.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(M,{tone:x.ok?"ok":"bad",children:x.ok?"reachable":"not reachable"})})]},x.provider))})]}),e.jsx(T,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:f!=null&&f.count?e.jsxs(M,{tone:"data",children:[w(f.count)," files · ",Ae(f.bytes)]}):e.jsx(M,{children:"nothing held"}),footer:e.jsx($,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>u(!0),children:"Delete every stored subtitle"}),children:e.jsx(e.Fragment,{})})]}),g?e.jsx(xe,{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:o==="clear",onConfirm:()=>void k(),onCancel:()=>u(!1)}):null]})}function bt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(null),[l,v]=p.useState(!1),g=s==null?void 0:s.updatePolicy;p.useEffect(()=>{d||!g||c({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,d]);const u=j=>a(j?"save":"off",async()=>{d&&(await r(()=>F.post("/admin/api/update-policy",{enabled:j,latestVersion:d.version.trim(),downloadUrl:d.url.trim(),notes:d.notes.trim(),required:d.required,destructive:d.destructive,retireBelowVersion:d.retireBelow.trim()}),j?"Update policy saved.":"Update prompts turned off."),v(!1),c(null),await i())}),m=!!(g!=null&&g.minimumVersion)&&(g==null?void 0:g.minimumVersion)===(g==null?void 0:g.latestVersion),f=!!(g!=null&&g.retireBelowVersion)&&(g==null?void 0:g.retireBelowVersion)===(g==null?void 0:g.latestVersion),h=j=>c(b=>b&&{...b,...j});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"App updates",intro:"Publish an optional or a required client update."}),e.jsx(V,{message:n}),t||!d?e.jsx(W,{rows:1}):e.jsxs(T,{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(M,{tone:m?"warn":"ok",children:[f?"sign-out · ":m?"required · ":"optional · ",g.latestVersion]}):e.jsx(M,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>d.required?v(!0):void u(!0),children:"Save policy"}),e.jsx($,{busy:o==="off",onClick:()=>void u(!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:d.version,placeholder:"0.2.63",onChange:j=>h({version:j.target.value})})}),e.jsx(q,{label:"APK URL",children:e.jsx("input",{type:"text",value:d.url,placeholder:"https://nas/memby/memby-0.2.63.apk",onChange:j=>h({url:j.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:d.notes,placeholder:"One line the viewer reads",onChange:j=>h({notes:j.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:d.retireBelow,placeholder:"0.2.44",onChange:j=>h({retireBelow:j.target.value})})}),e.jsx(z,{label:"Require this update",hint:"Blocks the home screen on every television below this version.",checked:d.required,onChange:j=>h({required:j})}),e.jsx(z,{label:"Set the destructive floor to this update",hint:"Deletes sessions on every older television when it next uses Memby, then shows the required update screen.",checked:d.destructive,onChange:j=>h(j?{destructive:!0,required:!0,retireBelow:d.version.trim()}:{destructive:!1,retireBelow:d.retireBelow.trim()===d.version.trim()?"":d.retireBelow})})]}),l&&d?e.jsx(xe,{title:d.destructive?"Sign every older television out?":"Require this update?",body:d.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:d.destructive,busy:o==="save",onConfirm:()=>void u(!0),onCancel:()=>v(!1)}):null]})}const ft=2e4,yt=3e3,wt=[{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 kt(s){const n=[...wt];for(const t of[s.defaultIntervalSeconds,s.intervalSeconds])t>0&&!n.some(i=>i.value===t)&&n.push({value:t,label:es(t).replace(/^every /,"Every ")});return n.sort((t,i)=>t.value-i.value)}function Ns(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function Nt(){const{wrap:s}=ne(),{busy:n,run:t}=X(),[i,r]=p.useState(!1),{data:o,error:a,loading:d,reload:c}=Q("/admin/api/tasks?limit=60",{pollMs:i?yt:ft}),l=(o==null?void 0:o.tasks)??[],v=l.some(y=>y.running);v!==i&&r(v);const g=y=>t(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await c()}),u=(y,R)=>t(`${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 c()}),m=(y,R)=>t(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:R}),`${y.name} now runs ${es(R)}.`),await c()}),f=y=>t(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:0}),`${y.name} back to its default cadence.`),await c()}),h=l.filter(y=>{var R;return((R=y.lastRun)==null?void 0:R.status)==="failed"}).length,j=l.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,b=l.filter(y=>!y.enabled).length,k=(o==null?void 0:o.groups)??[],x=l.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(V,{message:a}),d?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"Tasks",value:w(l.length),icon:"clock",tone:"info"},{label:"Running now",value:w(l.filter(y=>y.running).length),icon:"pulse",tone:v?"ok":void 0},{label:"Last run failed",value:w(h),icon:"alert",tone:h>0?"bad":void 0},{label:"Switched off",value:w(b),icon:"power",tone:b>0?"warn":void 0},{label:"Retimed",value:w(j),icon:"clock",tone:j>0?"note":void 0}]}),h>0?e.jsx(je,{tone:"bad",children:"A failed task publishes an administrative event, so the failure is in the activity feed and wherever your integrations send it — you did not have to be looking at this page."}):null,[...k,...x.length>0?[""]:[]].map(y=>{const R=l.filter(S=>S.group===y);return R.length===0?null:e.jsx(T,{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(S=>{var H;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[S.name," ",S.running?e.jsx(M,{tone:"info",children:"running"}):null,S.enabled?null:e.jsx(M,{tone:"warn",children:"off"}),S.defaultIntervalSeconds>0&&S.intervalSeconds!==S.defaultIntervalSeconds?e.jsx(M,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:S.description}),e.jsxs("p",{className:"quiet",children:[es(S.intervalSeconds),S.enabled&&S.nextRun?` · next ${be(S.nextRun).replace(" ago","")}`:"",S.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:P(S.lastRun.startedAt),children:be(S.lastRun.startedAt)}),` in ${Ne(S.lastRun.durationMs)}`,S.lastRun.detail?` — ${S.lastRun.detail}`:""]}):" · never run"]}),(H=S.lastRun)!=null&&H.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(M,{tone:"bad",children:S.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[S.lastRun?e.jsx(M,{tone:Ns(S.lastRun.status),children:S.lastRun.status}):e.jsx(M,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${S.name} runs`,value:S.intervalSeconds,disabled:n===`${S.id}:interval`||S.running,onChange:E=>void m(S,Number(E.target.value)),children:kt(S).map(E=>e.jsxs("option",{value:E.value,children:[E.label,E.value===S.defaultIntervalSeconds?" (default)":""]},E.value))}),S.defaultIntervalSeconds>0&&S.intervalSeconds!==S.defaultIntervalSeconds?e.jsx($,{size:"sm",icon:"refresh",busy:n===`${S.id}:interval`,onClick:()=>void f(S),children:"Default"}):null,e.jsx(z,{label:"",checked:S.enabled,disabled:n===`${S.id}:enabled`,onChange:E=>void u(S,E)}),e.jsx($,{size:"sm",icon:"play",busy:n===S.id,disabled:S.running,onClick:()=>void g(S),children:"Run now"})]})]},S.id)})})},y||"other")}),e.jsx(T,{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:((o==null?void 0:o.runs.length)??0)===0?e.jsx(ae,{columns:6,children:"No task has run yet."}):o==null?void 0:o.runs.map(y=>{var R;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(y.startedAt),children:be(y.startedAt)}),e.jsx("td",{children:((R=l.find(S=>S.id===y.taskId))==null?void 0:R.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(M,{tone:Ns(y.status),children:y.status})}),e.jsx("td",{className:"num muted",children:Ne(y.durationMs)}),e.jsx("td",{className:"muted",children:y.error||y.detail||"—"})]},y.id)})})]})})})]})]})}const St={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Ct(){const{wrap:s,show:n}=ne(),{busy:t,run:i}=X(),{data:r,error:o,loading:a,reload:d}=Q("/admin/api/integrations",{pollMs:6e4}),[c,l]=p.useState(null),[v,g]=p.useState(null),u=(r==null?void 0:r.catalogue)??[],m=(r==null?void 0:r.integrations)??[],f=k=>l({id:k.id,name:k.name,url:"",enabled:k.enabled,events:k.events??[]}),h=()=>i("save",async()=>{if(!c)return;await s(()=>F.post("/admin/api/integrations",c),c.id?"Integration saved.":"Integration added.")&&(l(null),await d())}),j=k=>i("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(k.id)}`),`${k.name} removed.`),g(null),await d()}),b=k=>i(`test:${k.id}`,async()=>{const x=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(k.id)}/test`));x&&n(x.message,x.ok?"ok":"bad"),await d()});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($,{variant:"primary",icon:"plus",onClick:()=>l(St),children:"Add a webhook"})}),e.jsx(V,{message:o}),e.jsx(Mt,{}),e.jsx(Et,{}),e.jsx(At,{}),((r==null?void 0:r.dropped)??0)>0?e.jsxs(je,{tone:"warn",children:[w((r==null?void 0:r.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,a?e.jsx(W,{}):m.length===0&&!c?e.jsx(T,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(Y,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):m.map(k=>e.jsx(Rt,{integration:k,catalogue:u,busy:t,onEdit:()=>f(k),onTest:()=>void b(k),onRemove:()=>g(k)},k.id)),c?e.jsx($t,{draft:c,catalogue:u,busy:t==="save",onChange:l,onSave:()=>void h(),onCancel:()=>l(null)}):null,v?e.jsx(xe,{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:t==="remove",onConfirm:()=>void j(v),onCancel:()=>g(null)}):null]})}function Mt(){const{wrap:s}=ne(),{busy:n,run:t}=X(),{data:i,error:r,loading:o,reload:a}=Q("/admin/api/arr-integrations"),d=c=>t("arr-integrations",async()=>{i&&(await s(()=>F.post("/admin/api/arr-integrations",{sonarrEnabled:c.sonarrEnabled??i.sonarrEnabled,radarrEnabled:c.radarrEnabled??i.radarrEnabled}),"Integration settings saved."),await a())});return e.jsxs(T,{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(V,{message:r??""}),o?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(z,{label:"Sonarr enabled",hint:i!=null&&i.sonarrConfigured?"Off stops Memby sending or looking up TV requests through Sonarr.":"Sonarr is not configured.",checked:!!(i!=null&&i.sonarrEnabled),disabled:!(i!=null&&i.sonarrConfigured)||n==="arr-integrations",onChange:c=>void d({sonarrEnabled:c})}),e.jsx(z,{label:"Radarr enabled",hint:i!=null&&i.radarrConfigured?"Off stops Memby sending or looking up film requests through Radarr.":"Radarr is not configured.",checked:!!(i!=null&&i.radarrEnabled),disabled:!(i!=null&&i.radarrConfigured)||n==="arr-integrations",onChange:c=>void d({radarrEnabled:c})})]})]})}function Et(){const{wrap:s}=ne(),{busy:n,run:t}=X(),{data:i,error:r,loading:o,reload:a}=Q("/admin/api/sonarr-request-policy"),[d,c]=p.useState(0),[l,v]=p.useState(!1);p.useEffect(()=>{i&&(c(i.qualityProfileId),v(i.searchImmediately))},[i]);const g=()=>t("sonarr-request-policy",async()=>{await s(()=>F.post("/admin/api/sonarr-request-policy",{qualityProfileId:d,searchImmediately:l}),"Sonarr TV request policy saved."),await a()}),u=i==null?void 0:i.profiles.find(m=>m.id===d);return e.jsxs(T,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx($,{variant:"primary",busy:n==="sonarr-request-policy",disabled:o||d<=0,onClick:()=>void g(),children:"Save Sonarr policy"}),children:[e.jsx(V,{message:r??(i==null?void 0:i.error)??""}),o?e.jsx(W,{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:d,onChange:m=>c(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(z,{label:"Search for episodes immediately after request",hint:"Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search.",checked:l,onChange:v}),u?e.jsxs(je,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), be monitored using Memby’s existing all-episodes strategy, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function At(){const{wrap:s}=ne(),{busy:n,run:t}=X(),{data:i,error:r,loading:o,reload:a}=Q("/admin/api/radarr-request-policy"),[d,c]=p.useState(0),[l,v]=p.useState(!1);p.useEffect(()=>{i&&(c(i.qualityProfileId),v(i.searchImmediately))},[i]);const g=()=>t("radarr-request-policy",async()=>{await s(()=>F.post("/admin/api/radarr-request-policy",{qualityProfileId:d,searchImmediately:l}),"Radarr movie request policy saved."),await a()}),u=i==null?void 0:i.profiles.find(m=>m.id===d);return e.jsxs(T,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx($,{variant:"primary",busy:n==="radarr-request-policy",disabled:o||d<=0,onClick:()=>void g(),children:"Save Radarr policy"}),children:[e.jsx(V,{message:r??(i==null?void 0:i.error)??""}),o?e.jsx(W,{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:d,onChange:m=>c(Number(m.target.value)),disabled:!(i!=null&&i.profiles.length),children:[e.jsx("option",{value:0,children:"Choose a quality profile…"}),i==null?void 0:i.profiles.map(m=>e.jsxs("option",{value:m.id,children:[m.name,m.recommended?" — recommended (720p)":""]},m.id))]})})}),e.jsx(z,{label:"Search for the film immediately after request",hint:"Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search.",checked:l,onChange:v}),u?e.jsxs(je,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:u.name})," (profile ID ",u.id,"), remain monitored, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Rt({integration:s,catalogue:n,busy:t,onEdit:i,onTest:r,onRemove:o}){const a=s.health,d=!a.lastFailure||a.lastSuccess&&a.lastSuccess>a.lastFailure,c=s.events??[];return e.jsxs(T,{title:s.name,intro:s.hint?`Discord webhook ${s.hint}`:"Discord webhook",icon:"plug",tone:s.enabled?"ok":"warn",actions:e.jsxs(e.Fragment,{children:[s.enabled?e.jsx(M,{tone:"ok",children:"on"}):e.jsx(M,{tone:"warn",children:"off"}),a.deliveries>0?e.jsx(M,{tone:d?"ok":"bad",children:d?"delivering":"failing"}):e.jsx(M,{children:"never used"}),e.jsx($,{size:"sm",icon:"pulse",busy:t===`test:${s.id}`,onClick:r,children:"Test"}),e.jsx($,{size:"sm",onClick:i,children:"Edit"}),e.jsx($,{size:"sm",variant:"danger",icon:"trash",onClick:o,title:"Remove"})]}),children:[e.jsxs("div",{className:"list",children:[e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Events sent"}),e.jsx("p",{children:c.length===0?"None selected — this destination is configured but will never post anything.":c.map(l=>{var v;return((v=n.find(g=>g.type===l))==null?void 0:v.label)??l}).join(", ")})]})}),e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last delivered"}),e.jsx("p",{children:a.lastSuccess?P(a.lastSuccess):"never"})]}),e.jsx("div",{className:"list-actions",children:a.deliveries>0?e.jsxs("span",{className:"quiet",children:[w(a.deliveries)," attempts, ",w(a.failures)," failed"]}):null})]}),a.lastFailure?e.jsx("div",{className:"list-item",children:e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:"Last failure"}),e.jsxs("p",{children:[P(a.lastFailure),a.lastError?` — ${a.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(l=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(l.attemptedAt),children:be(l.attemptedAt)}),e.jsx("td",{className:"muted",children:l.eventType}),e.jsx("td",{children:l.success?e.jsx(M,{tone:"ok",children:l.statusCode||"ok"}):e.jsx(M,{tone:"bad",children:l.error||l.statusCode||"failed"})}),e.jsx("td",{className:"num muted",children:Ne(l.durationMs)})]},l.id))})]})}):e.jsx(Z,{children:e.jsx("table",{children:e.jsx("tbody",{children:e.jsx(ae,{columns:4,children:"Nothing has been delivered through this webhook yet."})})})})]})}function $t({draft:s,catalogue:n,busy:t,onChange:i,onSave:r,onCancel:o}){const a=[...new Set(n.map(c=>c.group))],d=(c,l)=>i({...s,events:l?[...s.events,c]:s.events.filter(v=>v!==c)});return e.jsxs(T,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:t,onClick:r,children:s.id?"Save":"Add"}),e.jsx($,{variant:"quiet",onClick:o,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:c=>i({...s,name:c.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:c=>i({...s,url:c.target.value})})})]}),e.jsx(z,{label:"Enabled",hint:"Off keeps the configuration and stops the posts.",checked:s.enabled,onChange:c=>i({...s,enabled:c})}),a.map(c=>e.jsxs("div",{children:[e.jsx("div",{className:"card-head",style:void 0,children:e.jsx("div",{className:"card-head-text",children:e.jsx("h2",{children:c})})}),n.filter(l=>l.group===c).map(l=>e.jsx(z,{label:l.label,hint:l.description,checked:s.events.includes(l.type),onChange:v=>d(l.type,v)},l.type))]},c))]})}function Tt(){var G,ee,se,ue;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(""),[l,v]=p.useState(!1),[g,u]=p.useState(!1),[m,f]=p.useState(!1),[h,j]=p.useState("23:00"),[b,k]=p.useState("07:00"),[x,y]=p.useState(""),[R,S]=p.useState(!1),H=!!((G=s==null?void 0:s.maintenance)!=null&&G.enabled);p.useEffect(()=>{var A;!g&&s&&c(((A=s.maintenance)==null?void 0:A.message)??"")},[s,g]),p.useEffect(()=>{R||!(s!=null&&s.quietTime)||(f(s.quietTime.enabled),j(s.quietTime.startTime),k(s.quietTime.endTime),y(s.quietTime.message))},[s,R]);const E=A=>a(A?"on":"off",async()=>{await r(()=>F.post("/admin/api/maintenance",{enabled:A,message:d}),A?"Memby is offline for every television.":"Memby is back online."),v(!1),u(!1),await i()}),I=()=>a("quiet",async()=>{await r(()=>F.post("/admin/api/quiet-time",{enabled:m,startTime:h,endTime:b,message:x}),m?"Quiet time saved.":"Quiet time turned off."),S(!1),await i()});return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Maintenance",intro:"Take Memby offline now or schedule daily quiet time."}),e.jsx(V,{message:n}),t?e.jsx(W,{rows:1}):e.jsx(T,{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:H?"bad":"warn",actions:H?e.jsx(M,{tone:"bad",children:"offline"}):e.jsx(M,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"danger",disabled:H,onClick:()=>v(!0),children:"Go offline"}),e.jsx($,{disabled:!H,busy:o==="off",onClick:()=>void E(!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:d,placeholder:"Back shortly — upgrading the server",onChange:A=>{c(A.target.value),u(!0)}})})}),t?null:e.jsxs(T,{title:"Quiet time",intro:`Pause new television requests and server background work every day in ${((ee=s==null?void 0:s.quietTime)==null?void 0:ee.timeZone)??"the household timezone"}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`,icon:"clock",tone:(se=s==null?void 0:s.quietTime)!=null&&se.active?"warn":"info",actions:(ue=s==null?void 0:s.quietTime)!=null&&ue.active?e.jsx(M,{tone:"warn",children:"active now"}):m?e.jsx(M,{tone:"ok",children:"scheduled"}):e.jsx(M,{children:"off"}),footer:e.jsx($,{variant:"primary",busy:o==="quiet",onClick:()=>void I(),children:"Save quiet time"}),children:[e.jsx(z,{label:"Pause server activity during quiet time",checked:m,onChange:A=>{f(A),S(!0)}}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Starts",hint:"Uses the household's 24-hour clock.",children:e.jsx("input",{type:"time",value:h,onChange:A=>{j(A.target.value),S(!0)}})}),e.jsx(q,{label:"Ends",hint:"May be on the following day, for example 23:00 to 07:00.",children:e.jsx("input",{type:"time",value:b,onChange:A=>{k(A.target.value),S(!0)}})})]}),e.jsx(q,{label:"Message shown on the television",hint:"Shown when a television contacts Memby during quiet time.",children:e.jsx("input",{type:"text",value:x,placeholder:"Quiet time — try again after 7 am",onChange:A=>{y(A.target.value),S(!0)}})})]}),l?e.jsx(xe,{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:o==="on",onConfirm:()=>void E(!0),onCancel:()=>v(!1)}):null]})}const It=-1;function De(s){return s===0?"":s<0?"off":String(s)}function Fe(s,n){const t=s.trim().toLowerCase();if(t==="")return 0;if(n&&(t==="off"||t==="none"||t==="0"))return It;const i=Number.parseInt(t,10);return Number.isFinite(i)?i:0}function Pe(s,n){return s<=0?"off":`${s} ${n}${s===1?"":"s"}`}function Ye(s){return{timezone:s.timezone??"",logLevel:s.logLevel??"",sessionIdleDays:De(s.sessionIdleDays),sonarrAlertMinutes:De(s.sonarrAlertMinutes),radarrAlertMinutes:De(s.radarrAlertMinutes),embyHealthSeconds:De(s.embyHealthSeconds)}}function Lt(){const{data:s,error:n,loading:t,reload:i}=Q("/admin/api/gateway-settings"),{wrap:r}=ne(),{busy:o,run:a}=X(),[d,c]=p.useState(null);p.useEffect(()=>{!d&&s&&c(Ye(s.settings))},[s,d]);const l=(h,j)=>c(b=>b&&{...b,[h]:j}),v=()=>a("save",async()=>{if(!d)return;const h={timezone:d.timezone.trim(),logLevel:d.logLevel.trim(),sessionIdleDays:Fe(d.sessionIdleDays,!1),sonarrAlertMinutes:Fe(d.sonarrAlertMinutes,!0),radarrAlertMinutes:Fe(d.radarrAlertMinutes,!0),embyHealthSeconds:Fe(d.embyHealthSeconds,!0)},j=await r(()=>F.post("/admin/api/gateway-settings",h),"Gateway settings saved.");j&&c(Ye(j.settings)),await i()}),g=()=>a("clear",async()=>{const h=await r(()=>F.post("/admin/api/gateway-settings",{timezone:"",logLevel:"",sessionIdleDays:0,sonarrAlertMinutes:0,radarrAlertMinutes:0,embyHealthSeconds:0}),"Every setting is back to what this container was deployed with.");h&&c(Ye(h.settings)),await i()}),u=s==null?void 0:s.deployed,m=s==null?void 0:s.effective,f=(s==null?void 0:s.logLevels)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Gateway settings",intro:"Server-level settings for this gateway, changeable without a redeployment."}),e.jsx(V,{message:n}),t||!d||!u||!m?e.jsx(W,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx(T,{title:"This gateway",intro:"What the process is running and what it currently believes.",icon:"chip",tone:"info",actions:e.jsx(M,{tone:"info",children:(s==null?void 0:s.version)??"unknown"}),children:e.jsx(ss,{rows:[{label:"Household timezone",value:m.timezone||"not set"},{label:"Log level",value:m.logLevel},{label:"Sign-in expiry",value:Pe(m.sessionIdleDays,"day")},{label:"Emby health probe",value:Pe(m.embyHealthSeconds,"second")},{label:"Episode alert window",value:Pe(m.sonarrAlertMinutes,"minute")},{label:"Film alert window",value:Pe(m.radarrAlertMinutes,"minute")}]})}),e.jsxs(T,{title:"Overrides",intro:"Leave a field empty to use the value this container was deployed with, shown beneath it. Changes take effect immediately — nothing here needs a restart.",icon:"sliders",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx($,{variant:"primary",busy:o==="save",onClick:()=>void v(),children:"Save settings"}),e.jsx($,{busy:o==="clear",onClick:()=>void g(),children:"Use deployed values"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Household timezone",hint:`Deployed: ${u.timezone||"not set"}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`,children:e.jsx("input",{type:"text",value:d.timezone,placeholder:u.timezone,onChange:h=>l("timezone",h.target.value)})}),e.jsx(q,{label:"Log level",hint:`Deployed: ${u.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`,children:e.jsxs("select",{value:d.logLevel,onChange:h=>l("logLevel",h.target.value),children:[e.jsxs("option",{value:"",children:["Deployed (",u.logLevel,")"]}),f.map(h=>e.jsx("option",{value:h,children:h},h))]})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Sign a television out after (days)",hint:`Deployed: ${u.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:d.sessionIdleDays,placeholder:String(u.sessionIdleDays),onChange:h=>l("sessionIdleDays",h.target.value)})}),e.jsx(q,{label:"Emby health probe (seconds)",hint:`Deployed: ${u.embyHealthSeconds||"off"}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:d.embyHealthSeconds,placeholder:String(u.embyHealthSeconds),onChange:h=>l("embyHealthSeconds",h.target.value)})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(q,{label:"Episode alert window (minutes)",hint:`Deployed: ${u.sonarrAlertMinutes||"off"}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:d.sonarrAlertMinutes,placeholder:String(u.sonarrAlertMinutes),onChange:h=>l("sonarrAlertMinutes",h.target.value)})}),e.jsx(q,{label:"Film alert window (minutes)",hint:`Deployed: ${u.radarrAlertMinutes||"off"}. The same, for a film Radarr has just imported.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:d.radarrAlertMinutes,placeholder:String(u.radarrAlertMinutes),onChange:h=>l("radarrAlertMinutes",h.target.value)})})]}),e.jsxs(je,{tone:"note",children:["These override the deployed configuration in the database, so they survive a restart — but a deployment rewrites ",e.jsx("code",{children:".env"}),", not this, and the two can then disagree. Anything meant to be permanent belongs in ",e.jsx("code",{children:".env.example"})," ","as well."]}),s!=null&&s.settings.updatedBy?e.jsxs(je,{children:["Last changed by ",s.settings.updatedBy,s.settings.updatedAt?` on ${new Date(s.settings.updatedAt).toLocaleString("en-NZ")}`:"","."]}):null]})]})]})}function qt(){const{status:s,error:n,loading:t}=oe(),i=(s==null?void 0:s.runs)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Imports",intro:"Catalogue synchronisation history."}),e.jsx(V,{message:n}),t?e.jsx(W,{rows:1}):e.jsx(T,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs.",icon:"sync",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Started"}),e.jsx("th",{children:"Kind"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Status"}),e.jsx("th",{className:"num",children:"Seen"}),e.jsx("th",{className:"num",children:"Written"}),e.jsx("th",{className:"num",children:"Removed"}),e.jsx("th",{children:"Notes"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(ae,{columns:8,children:"Nothing has been imported yet."}):i.map(r=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(r.startedAt)}),e.jsx("td",{children:r.kind}),e.jsx("td",{className:"muted",children:r.trigger}),e.jsx("td",{children:e.jsx(M,{tone:r.status==="success"?"ok":r.status==="running"?"warn":"bad",children:r.status})}),e.jsx("td",{className:"num",children:w(r.itemsSeen)}),e.jsx("td",{className:"num",children:w(r.itemsUpserted)}),e.jsx("td",{className:"num",children:w(r.itemsRemoved)}),e.jsx("td",{className:"muted",children:r.error||""})]},r.id||r.startedAt))})]})})})]})}const Dt={gateway:"quiet",auth:"note",playback:"info",media:"info",emby:"data",library:"data",subtitles:"data",search:"note",tracearr:"idle",credits:"idle",integrations:"note",requests:"info",home:"quiet"},sn=s=>Dt[s]??"quiet",Ft={admin:["gateway","Gateway","Admin"],installer:["gateway","Gateway","Installer"],api:["gateway","Gateway","API"],health:["gateway","Gateway","Health"],status:["gateway","Gateway","Status"],maintenance:["gateway","Gateway","Maintenance"],"quiet-time":["gateway","Gateway","Quiet time"],webhooks:["gateway","Gateway","Webhooks"],scheduler:["gateway","Gateway","Scheduler"],settings:["gateway","Gateway","Settings"],updates:["gateway","Gateway","Updates"],analytics:["gateway","Gateway","Analytics"],auth:["auth","Auth","Session"],devices:["auth","Auth","Devices"],playback:["playback","Playback","Session"],screensaver:["media","Media","Screensaver"],artwork:["media","Media","Artwork"],details:["media","Media","Details"],search:["search","Search","Query"],home:["home","Home","Rows"],"my-shows":["home","Home","My shows"],recommendations:["tracearr","Tracearr","Recommendations"],"for-you":["tracearr","Tracearr","For you"],library:["library","Library","Sync"],credits:["credits","Credits","Scanner"],ratings:["media","Media","Ratings"],integrations:["integrations","Integrations","Arr"],requests:["requests","Requests","Media"],"emby-health":["emby","Emby","Health"]},Pt=[[/^emby |emby (reachable|unreachable|health)/,["emby","Emby","API"]],[/subtitle/,["subtitles","Subtitles","Provider"]],[/^sonarr|sonarr /,["integrations","Integrations","Sonarr"]],[/^radarr|radarr /,["integrations","Integrations","Radarr"]],[/^tracearr/,["tracearr","Tracearr","Signals"]],[/^credits/,["credits","Credits","Scanner"]],[/^library sync/,["library","Library","Sync"]],[/^(signed in|signed out|sign-in rejected)/,["auth","Auth","Session"]],[/^device /,["auth","Auth","Devices"]],[/^(playback (requested|started|stopped|progress))/,["playback","Playback","Session"]],[/^(next episode resolved|trailer playback|trickplay)/,["playback","Playback","Player"]],[/^scheduled task/,["gateway","Gateway","Scheduler"]],[/^(update offered|update policy)/,["gateway","Gateway","Updates"]]];function Ot(s,n){const t=n.toLowerCase();for(const[r,o]of Pt)if(r.test(t))return o;const i=Ft[s];return i||(s?["gateway","Gateway",Ke(s.replace(/[-_]/g," "))]:["gateway","Gateway","Server"])}const _t={h:36e5,m:6e4,s:1e3,ms:1,us:.001,µs:.001,ns:1e-6};function ts(s){if(typeof s=="number")return Number.isFinite(s)?s:null;if(typeof s!="string"||!s)return null;const n=s.matchAll(/([0-9]*\.?[0-9]+)(ns|µs|us|ms|h|m|s)/g);let t=0,i=!1;for(const r of n){const o=r[2]?_t[r[2]]:void 0;o!==void 0&&(t+=Number(r[1])*o,i=!0)}return i?t:null}function os(s){return s<1?"<1 ms":s<1e3?`${Math.round(s)} ms`:s<1e4?`${(s/1e3).toFixed(1)} s`:s<6e4?`${Math.round(s/1e3)} s`:`${Math.floor(s/6e4)}m ${Math.round(s%6e4/1e3)}s`}const Ut=s=>s>=3e3?"bad":s>=1e3?"warn":null,Vt={200:"OK",201:"Created",202:"Accepted",204:"No Content",206:"Partial Content",301:"Moved Permanently",302:"Found",304:"Not Modified",400:"Bad Request",401:"Unauthorized",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",409:"Conflict",412:"Precondition Failed",418:"Client Closed Request",426:"Upgrade Required",429:"Too Many Requests",499:"Client Closed Request",500:"Internal Server Error",502:"Bad Gateway",503:"Service Unavailable",504:"Gateway Timeout"};function Bt(s){const n=Vt[s];return n||(s>=500?"Server Error":s>=400?"Client Error":s>=300?"Redirected":s>=200?"OK":"Response")}const Wt=s=>s>=500?"bad":s>=400?"warn":s>=300?"quiet":"ok",Ht={"/healthz":"Health probe","/readyz":"Readiness probe","/v1/status":"Status poll","/v1/home":"Home rows","/v1/features":"Features","/v1/preferences":"Preferences","/v1/theme":"Theme","/v1/magic":"Magic pick","/v1/calendar":"TV calendar","/v1/search":"Search","/v1/update":"Update check"},zt=s=>/\d/.test(s)||s.length>24;function Ss(s){const n=Ht[s];if(n)return n;const t=s.split("/").filter(r=>r&&r!=="v1"&&r!=="api");t[0]==="admin"&&t.shift();const i=t.filter(r=>!zt(r));return i.length===0?s:Ke(i.join(" ").replace(/[-_.]/g," ").replace(/\s+/g," ").trim())}const Ke=s=>s&&s.charAt(0).toUpperCase()+s.slice(1),Oe=s=>Ke(s.replace(/_/g," ")),de=s=>s==null?"":String(s),nn=new Set(["","unknown","none","null","","0"]),ge=s=>!nn.has(de(s).toLowerCase()),Kt=["title","series","name","query","item_title","file"],Gt={directplay:"ok",direct:"ok",directstream:"ok",transcode:"warn",transcoding:"warn"};function Zt(s,n,t){if(t!==null)return{label:`${t} ${Bt(t)}`,short:String(t),tone:Wt(t)};if(ge(s.error))return{label:"Failed",short:"Failed",tone:n==="WARN"?"warn":"bad"};const i=de(s.play_method).toLowerCase().replace(/[\s_-]/g,"");if(i&&!nn.has(i)){const r=Ke(de(s.play_method).replace(/([a-z])([A-Z])/g,"$1 $2"));return{label:r,short:r,tone:Gt[i]??"info"}}if(ge(s.cache)){const r=/hit|true|yes/i.test(de(s.cache));return{label:r?"Cached":"Cache miss",short:r?"Cached":"Miss",tone:r?"data":"quiet"}}return n==="ERROR"?{label:"Failed",short:"Failed",tone:"bad"}:n==="WARN"?{label:"Warning",short:"Warning",tone:"warn"}:null}function Jt(s){const n=[];ge(s.user)&&n.push(de(s.user)),ge(s.device)&&n.push(de(s.device));const t=ts(s.marker_ms);t!==null&&t>0&&n.push(`Start ${Cs(t)}`);const i=ts(s.position);return i!==null&&i>0&&n.push(`At ${Cs(i)}`),ge(s.watched)&&n.push(`${de(s.watched)} watched`),ge(s.reason)&&n.push(de(s.reason)),n.slice(0,3).join(" · ")}function Cs(s){const n=Math.round(s/1e3),t=Math.floor(n/3600),i=Math.floor(n%3600/60),r=n%60,o=a=>String(a).padStart(2,"0");return t>0?`${t}:${o(i)}:${o(r)}`:`${i}:${o(r)}`}const tn=[{title:"Request",keys:["method","path","query_keys","status","cache","client","protocol","host"]},{title:"Context",keys:["user","user_id","device","device_id","item","title","series","type","play_method","play_session_id","media_source_id","position","resume","runtime","watched","subtitles","subtitle_track","subtitle_language","event_name"]},{title:"Diagnostics",keys:["error","stack","correlation","version","gateway_version","duration"]}],Yt=new Set(tn.flatMap(s=>s.keys)),Qt=s=>Yt.has(s),Xt=new Intl.DateTimeFormat("en-NZ",{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}),ei=new Intl.DateTimeFormat("en-NZ",{weekday:"short",day:"numeric",month:"short"}),Ms=new WeakMap;function Re(s){const n=Ms.get(s);if(n)return n;const t=si(s);return Ms.set(s,t),t}function si(s){const n=s.attributes??{},t=s.message??"",[i,r,o]=Ot(de(n.component),t),a=de(n.path),d=a?de(n.method).toUpperCase():"",c=Number(n.status),l=a&&Number.isFinite(c)&&c>0?c:null,v=t==="request"&&!!a,g=Kt.find(R=>ge(n[R])),u=g?de(n[g]):"";let m,f;v?(m=d||"HTTP",f=Ss(a)):a&&d?(m=d,f=u?`${Oe(t)} · ${u}`:`${Oe(t)} — ${Ss(a)}`):(m="",f=u?`${Oe(t)} · ${u}`:Oe(t));const h=ts(n.duration??n.duration_ms??n.negotiation_duration),j=ge(n.error)?de(n.error):"",b=Jt(n),k=new Date(s.occurredAt),x=Object.entries(n);return{serviceKey:i,service:r,component:o,action:m,summary:f,context:b,detail:j,result:Zt(n,s.level,l),durationMs:h,method:d,status:l,eventKey:t,level:s.level,time:`${Xt.format(k)}.${String(k.getMilliseconds()).padStart(3,"0")}`,day:ei.format(k),dayKey:k.toDateString(),tall:!!j||!!b&&!v,haystack:[t,r,o,f,b,j,...x.flat().map(de)].join(" ").toLowerCase(),fields:x,attributes:n}}const Es={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},Qe={level:"INFO",service:"",component:"",event:"",method:"",status:"",slower:0,text:""};function ni(s,n){if(!s)return!0;if(n===null)return!1;if(s==="error")return n>=400;const t=Number(s[0]);return Math.floor(n/100)===t}function ti(s,n,t){if((Es[s.level]??0)<(Es[n.level]??20))return!1;const i=Re(s);return!(n.service&&i.serviceKey!==n.service||n.component&&i.component!==n.component||n.event&&i.eventKey!==n.event||n.method&&i.method!==n.method||!ni(n.status,i.status)||n.slower>0&&(i.durationMs??0)({key:o,label:a})).sort((o,a)=>o.label.localeCompare(a.label)),components:[...t].sort((o,a)=>o.localeCompare(a)),events:[...i].sort((o,a)=>o.localeCompare(a)),methods:[...r].sort((o,a)=>o.localeCompare(a))}}function ai(s,n){var i;const t=[];if(s.service){const r=((i=n.find(o=>o.key===s.service))==null?void 0:i.label)??s.service;t.push({key:"service",label:`Service: ${r}`})}return s.component&&t.push({key:"component",label:`Component: ${s.component}`}),s.event&&t.push({key:"event",label:`Event: ${s.event}`}),s.method&&t.push({key:"method",label:`Method: ${s.method}`}),s.status&&t.push({key:"status",label:`Status: ${s.status==="error"?"≥400":s.status}`}),s.slower>0&&t.push({key:"slower",label:`Duration: >${os(s.slower)}`}),s.text&&t.push({key:"text",label:`Search: ${s.text}`}),t}const _e=2e4,ri=5e3,As=30,Rs=48,$s=26,li=31,Ts=10,oi=[{value:"TRACE",label:"Everything"},{value:"DEBUG",label:"Debug+"},{value:"INFO",label:"Info+"},{value:"WARN",label:"Warnings+"},{value:"ERROR",label:"Errors only"}],ci=[{value:"",label:"Any result"},{value:"2xx",label:"Success (2xx)"},{value:"3xx",label:"Redirect (3xx)"},{value:"4xx",label:"Client error (4xx)"},{value:"5xx",label:"Server error (5xx)"},{value:"error",label:"Failed (≥400)"}],di=[{value:0,label:"Any duration"},{value:100,label:"Slower than 100 ms"},{value:500,label:"Slower than 500 ms"},{value:1e3,label:"Slower than 1 s"},{value:3e3,label:"Slower than 3 s"}],Is=s=>s.replace(/_/g," ");function Ue({onPick:s,className:n,title:t,children:i,...r}){return e.jsx("button",{type:"button",className:`logfacet ${n}`,title:t,onClick:s,...r,children:i})}const hi=p.memo(function({event:n,view:t,top:i,height:r,selected:o,onInspect:a,onFilter:d}){const c=t.durationMs!==null?Ut(t.durationMs):null;return e.jsxs("div",{className:"logrow","data-level":t.level,"data-selected":o||void 0,style:{transform:`translateY(${i}px)`,height:`${r}px`},children:[e.jsx("time",{className:"logrow-time",title:n.occurredAt,children:t.time}),e.jsx(Ue,{className:"logrow-level","data-level":t.level,title:`Show ${t.level} and above`,onPick:()=>d({level:t.level}),children:t.level}),e.jsxs("span",{className:"logrow-place",children:[e.jsx(Ue,{className:"logrow-service","data-tone":sn(t.serviceKey),title:`Filter to ${t.service}`,onPick:()=>d({service:t.serviceKey,component:""}),children:t.service}),e.jsx("span",{className:"logrow-sep","aria-hidden":"true",children:"›"}),e.jsx(Ue,{className:"logrow-component",title:`Filter to ${t.component}`,onPick:()=>d({component:t.component}),children:t.component})]}),e.jsxs("button",{type:"button",className:"logrow-summary",title:t.detail||t.summary,onClick:()=>a(n.sequence),children:[e.jsxs("span",{className:"logrow-line",children:[t.action?e.jsx("b",{className:"logrow-action","data-method":t.method||void 0,children:t.action}):null,e.jsx("span",{className:"logrow-text",children:t.summary})]}),t.detail?e.jsxs("span",{className:"logrow-error",children:["↳ ",t.detail]}):t.context?e.jsx("span",{className:"logrow-context",children:t.context}):null]}),e.jsx("span",{className:"logrow-result",children:t.result?e.jsx(Ue,{className:"logrow-verdict","data-tone":t.result.tone,title:t.status!==null?`Filter to ${t.status}`:`Filter to ${t.eventKey}`,onPick:()=>t.status!==null?d({status:`${Math.floor(t.status/100)}xx`}):d({event:t.eventKey}),children:t.result.label}):null}),e.jsx("span",{className:"logrow-duration","data-tone":c??void 0,children:t.durationMs!==null?os(t.durationMs):""})]})});function ui({event:s,view:n,onClose:t}){const[i,r]=p.useState(!1),o=n.fields.filter(([c])=>!Qt(c)&&c!=="component"),a=async()=>{try{await navigator.clipboard.writeText(JSON.stringify(s,null,2)),r(!0),window.setTimeout(()=>r(!1),1600)}catch{r(!1)}},d=tn.map(c=>({title:c.title,rows:c.keys.map(l=>[l,n.attributes[l]]).filter(([,l])=>l!=null&&String(l)!=="")})).filter(c=>c.rows.length>0);return e.jsxs("section",{className:"logdrawer","aria-label":`Log record ${s.sequence}`,children:[e.jsxs("header",{className:"logdrawer-head",children:[e.jsxs("div",{children:[e.jsxs("p",{className:"logdrawer-place",children:[e.jsx("span",{className:"logrow-service","data-tone":sn(n.serviceKey),children:n.service}),e.jsx("span",{className:"logrow-sep","aria-hidden":"true",children:"›"}),n.component]}),e.jsx("b",{children:n.summary}),n.detail?e.jsx("p",{className:"logdrawer-error",children:n.detail}):null]}),e.jsxs("div",{className:"logdrawer-actions",children:[e.jsx($,{size:"sm",variant:"quiet",onClick:a,icon:"download",children:i?"Copied":"Copy JSON"}),e.jsx($,{size:"sm",variant:"quiet",onClick:t,icon:"close",children:"Close"})]})]}),e.jsxs("div",{className:"logdrawer-grid",children:[e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Overview"}),e.jsxs("dl",{children:[e.jsx("dt",{children:"Time"}),e.jsxs("dd",{children:[n.day," ",n.time]}),e.jsx("dt",{children:"Level"}),e.jsx("dd",{children:n.level}),e.jsx("dt",{children:"Service"}),e.jsxs("dd",{children:[n.service," › ",n.component]}),e.jsx("dt",{children:"Event"}),e.jsx("dd",{children:n.eventKey}),n.result?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Result"}),e.jsx("dd",{children:n.result.label})]}):null,n.durationMs!==null?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Duration"}),e.jsx("dd",{children:os(n.durationMs)})]}):null,e.jsx("dt",{children:"Record"}),e.jsxs("dd",{children:["#",s.sequence]})]})]}),d.map(c=>e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:c.title}),e.jsx("dl",{children:c.rows.map(([l,v])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:Is(l)}),e.jsx("dd",{children:String(v)})]},l))})]},c.title)),o.length?e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Details"}),e.jsx("dl",{children:o.map(([c,l])=>e.jsxs(p.Fragment,{children:[e.jsx("dt",{children:Is(c)}),e.jsx("dd",{children:String(l)})]},c))})]}):null]}),e.jsxs("details",{className:"logdrawer-raw",children:[e.jsx("summary",{children:"Raw event"}),e.jsx("pre",{children:JSON.stringify(s,null,2)})]})]})}function mi(){var ds;const[s,n]=p.useState([]),[t,i]=p.useState(0),[r,o]=p.useState(!1),[a,d]=p.useState(0),[c,l]=p.useState(Qe),[v,g]=p.useState(""),[u,m]=p.useState({top:0,height:600}),[f,h]=p.useState(!0),[j,b]=p.useState(null),k=p.useDeferredValue(c.text.trim().toLowerCase()),x=p.useRef(0),y=p.useRef(!1),R=p.useRef(0),S=p.useRef(null),H=p.useRef(!0),E=p.useRef(void 0),I=p.useRef([]),G=p.useRef(r);p.useEffect(()=>{G.current=r},[r]);const ee=p.useCallback(C=>{n(O=>{const K=O.concat(C);return K.length>_e?K.slice(K.length-_e):K})},[]),se=p.useCallback(async()=>{if(y.current||document.hidden)return;y.current=!0;const C=R.current,O=[];let K=0;try{let ce=0,ve;do ve=await F.get(`/admin/api/events?after=${x.current}&limit=1000`),x.current=ve.next||x.current,K+=ve.dropped||0,O.push(...ve.events??[]),ce+=1;while(ve.hasMore&&ce<20);g("")}catch(ce){g(ce instanceof Error?ce.message:String(ce))}finally{O.length>0&&C===R.current&&(G.current?(I.current=I.current.concat(O),I.current.length>_e&&(I.current=I.current.slice(I.current.length-_e)),d(I.current.length)):ee(O)),K>0&&C===R.current&&i(ce=>ce+K),y.current=!1}},[ee]);p.useEffect(()=>{let C;const O=()=>{window.clearInterval(C),C=document.hidden?void 0:window.setInterval(()=>void se(),ri)},K=()=>{O(),document.hidden||se()};return se(),O(),document.addEventListener("visibilitychange",K),()=>{window.clearInterval(C),document.removeEventListener("visibilitychange",K)}},[se]);const ue=p.useCallback(()=>{G.current=!0,o(!0)},[]),A=p.useCallback(()=>{G.current=!1;const C=I.current;I.current=[],d(0),o(!1),C.length&&ee(C)},[ee]),L=p.useCallback(C=>{l(O=>({...O,...C}))},[]),N=p.useMemo(()=>s.filter(C=>ti(C,c,k)),[s,c,k]),D=p.useMemo(()=>{const C=new Float64Array(N.length+1),O=new Uint8Array(N.length),K=new Uint8Array(N.length);let ce=0,ve="";for(let Se=0;Se{let C=0,O=N.length;for(;C>1;(D.tops[K]??0)+(D.heights[K]??0)<=_?C=K+1:O=K}return Math.max(0,C-Ts)},[D,_,N.length]),pe=p.useMemo(()=>{const C=_+u.height;let O=te;for(;O{const C=[];for(let O=te;Os.find(C=>C.sequence===j),[s,j]),Ge=p.useCallback(()=>{const C=S.current;C&&(H.current=!0,C.scrollTop=C.scrollHeight,h(!0),m({top:C.scrollTop,height:C.clientHeight}))},[]);p.useLayoutEffect(()=>{const C=S.current;!C||!H.current||(C.scrollTop=C.scrollHeight,m({top:C.scrollTop,height:C.clientHeight}))},[Le,D.total]),p.useEffect(()=>()=>window.cancelAnimationFrame(E.current??0),[]);const ln=()=>{const C=S.current;if(!C)return;const O=C.scrollHeight-C.scrollTop-C.clientHeight{m({top:C.scrollTop,height:C.clientHeight})})},on=()=>{const C=new Blob([JSON.stringify(N,null,2)],{type:"application/json"}),O=document.createElement("a");O.href=URL.createObjectURL(C),O.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,O.click(),window.setTimeout(()=>URL.revokeObjectURL(O.href),1e3)},Me=p.useMemo(()=>ii(s),[s]),cs=ai(c,Me.services);return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(V,{message:v}),e.jsxs(T,{children:[e.jsxs("div",{className:"logbar",children:[e.jsxs("div",{className:"logbar-filters",children:[e.jsxs("select",{"aria-label":"Service",value:c.service,onChange:C=>L({service:C.target.value,component:""}),children:[e.jsx("option",{value:"",children:"All services"}),Me.services.map(C=>e.jsx("option",{value:C.key,children:C.label},C.key))]}),e.jsxs("select",{"aria-label":"Component",value:c.component,onChange:C=>L({component:C.target.value}),children:[e.jsx("option",{value:"",children:"All components"}),Me.components.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Level",value:c.level,onChange:C=>L({level:C.target.value}),children:oi.map(C=>e.jsx("option",{value:C.value,children:C.label},C.value))}),e.jsxs("select",{"aria-label":"Event",value:c.event,onChange:C=>L({event:C.target.value}),children:[e.jsx("option",{value:"",children:"All events"}),Me.events.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Result",value:c.status,onChange:C=>L({status:C.target.value}),children:ci.map(C=>e.jsx("option",{value:C.value,children:C.label},C.value))}),e.jsxs("select",{"aria-label":"Method",value:c.method,onChange:C=>L({method:C.target.value}),children:[e.jsx("option",{value:"",children:"Any method"}),Me.methods.map(C=>e.jsx("option",{value:C,children:C},C))]}),e.jsx("select",{"aria-label":"Duration",value:String(c.slower),onChange:C=>L({slower:Number(C.target.value)}),children:di.map(C=>e.jsx("option",{value:String(C.value),children:C.label},C.value))}),e.jsxs("label",{className:"logsearch",children:[e.jsx(J,{name:"search"}),e.jsx("input",{type:"search",value:c.text,"aria-label":"Search logs",placeholder:"Search person, title, service, component, path, request ID…",onChange:C=>L({text:C.target.value})})]})]}),e.jsxs("div",{className:"logbar-actions",children:[e.jsx($,{size:"sm",variant:"quiet",onClick:()=>r?A():ue(),icon:r?"play":"clock",children:r?a?`Resume (${w(a)})`:"Resume":"Pause"}),e.jsx($,{size:"sm",variant:"quiet",onClick:()=>{R.current+=1,I.current=[],d(0),n([]),i(0),b(null)},children:"Clear view"}),e.jsx($,{size:"sm",variant:"quiet",onClick:on,icon:"download",title:"Export the rows matching the current filters, as delivered by the gateway",children:"Export JSON"})]})]}),cs.length?e.jsxs("div",{className:"logchips",children:[cs.map(C=>e.jsxs("button",{type:"button",className:"logchip",onClick:()=>L({[C.key]:Qe[C.key]}),children:[C.label,e.jsx(J,{name:"close"})]},C.key)),e.jsx("button",{type:"button",className:"logchip logchip-clear",onClick:()=>l(Qe),children:"Clear all"})]}):null,e.jsxs("div",{className:"logshell",children:[e.jsxs("div",{className:"logview",ref:S,onScroll:ln,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:"Service"}),e.jsx("span",{children:"Event"}),e.jsx("span",{children:"Result"}),e.jsx("span",{children:"Duration"})]}),N.length===0?e.jsx("p",{className:"empty",children:s.length===0?"Waiting for server events…":"No events match these filters."}):e.jsx("div",{className:"logbody",style:{height:`${D.total}px`},children:Ie.map(({event:C,view:O,index:K})=>e.jsxs(p.Fragment,{children:[D.divider[K]?e.jsx("div",{className:"logday",style:{transform:`translateY(${(D.tops[K]??0)-$s}px)`},children:e.jsx("span",{children:O.day})}):null,e.jsx(hi,{event:C,view:O,top:D.tops[K]??0,height:D.heights[K]??As,selected:C.sequence===j,onInspect:b,onFilter:L})]},C.sequence))})]}),!f&&N.length>0?e.jsxs("button",{type:"button",className:"logtail",onClick:Ge,children:[e.jsx(J,{name:"caret"}),"Jump to latest"]}):null]}),e.jsxs("p",{className:"hint",children:[w(s.length)," retained · ",w(N.length)," matching",N.length?` · ${w(Ie.length)} rows mounted`:"",t?` · ${w(t)} overwritten before delivery`:"",r?` · paused${a?`, ${w(a)} held`:""}`:""]}),me?e.jsx(ui,{event:me,view:Re(me),onClose:()=>b(null)}):s.length?e.jsx(je,{children:"Select a row to see the full record — request, context, diagnostics and raw event."}):null]})]})}const Ls=["home","movies","shows","favorites","search","recent_searches","genre_browse","for_you","for_you_time","recommendation","continue","latest","my_shows","details","playback","magic_movie","notifications","profiles","settings"];function ye(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function pi(){const[s,n]=p.useState(30),[t,i]=p.useState(""),r=is(),o=p.useMemo(()=>`/admin/api/journeys${Te({days:s,userId:t})}`,[s,t]),{data:a,error:d,loading:c}=Q(o),l=a==null?void 0:a.stats,v=(a==null?void 0:a.users)??[],g=(a==null?void 0:a.actions)??[],u=(a==null?void 0:a.paths)??[],m=u[0],f=p.useMemo(()=>{const h=new Map(((a==null?void 0:a.features)??[]).map(b=>[b.feature,b])),j=new Map(Ls.map((b,k)=>[b,k]));return[...new Set([...Ls,...h.keys()])].map(b=>({name:b,stat:h.get(b)})).sort((b,k)=>{var y,R;const x=(((y=k.stat)==null?void 0:y.uses)??0)-(((R=b.stat)==null?void 0:R.uses)??0);return x||(j.get(b.name)??Number.MAX_SAFE_INTEGER)-(j.get(k.name)??Number.MAX_SAFE_INTEGER)})},[a==null?void 0:a.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(V,{message:d}),e.jsx(T,{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:h=>n(Number(h.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),e.jsx(q,{label:"User",children:e.jsxs("select",{value:t,onChange:h=>{const j=h.target.value;i(j),j&&r(`/admin/journeys/${encodeURIComponent(j)}`)},children:[e.jsx("option",{value:"",children:"All users"}),v.map(h=>e.jsx("option",{value:h.userId,children:h.username||h.userId},h.userId))]})})]}),children:c?e.jsx(W,{rows:1}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"journeys",value:w(l==null?void 0:l.journeys),icon:"list",tone:"data"},{label:"viewers",value:w(l==null?void 0:l.viewers),icon:"people",tone:"info"},{label:"completion",value:Ve(l==null?void 0:l.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:w(l==null?void 0:l.abandoned),icon:"alert",tone:"note"},{label:"active now",value:w(l==null?void 0:l.active),icon:"pulse",tone:"info"},{label:"average steps",value:((l==null?void 0:l.averageSteps)??0).toFixed(1),icon:"chart"},{label:"average visit",value:Ne(l==null?void 0:l.averageTimeMs),small:!0,icon:"clock"},{label:"history kept",value:`${(a==null?void 0:a.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:Ve(l==null?void 0:l.completionRate)})]}),e.jsx(In,{value:(l==null?void 0:l.completed)??0,total:(l==null?void 0:l.journeys)??0}),e.jsxs("p",{children:[w(l==null?void 0:l.completed)," completed · ",w(l==null?void 0:l.abandoned)," abandoned ·"," ",w(l==null?void 0:l.active)," active"]})]}),e.jsxs("div",{className:"summary",children:[e.jsx("div",{className:"summary-head",children:e.jsx("b",{children:"Most common route"})}),e.jsx("strong",{style:void 0,children:m?`${ye(m.from)} → ${ye(m.to)}`:"Not enough data"}),e.jsx("p",{children:m?`${w(m.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(he,{cols:"2",children:[e.jsx(T,{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:g.length===0?e.jsx(ae,{columns:3,children:"No significant actions in this window."}):g.map(h=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ye(h.action)}),e.jsx("span",{className:"table-sub",children:ye(h.category)})]}),e.jsx("td",{className:"num",children:w(h.events)}),e.jsx("td",{className:"num",children:w(h.journeys)})]},`${h.category}:${h.action}`))})]})})}),e.jsx(T,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(ae,{columns:2,children:"No repeated paths in this window."}):u.map((h,j)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[ye(h.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",ye(h.to)]}),e.jsx("td",{className:"num",children:w(h.count)})]},`${h.from}:${h.to}:${j}`))})]})})})]}),e.jsx(T,{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:h,stat:j})=>{const b=(j==null?void 0:j.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:ye(h)}),e.jsx("td",{className:"num",children:w(b)}),e.jsx("td",{className:"muted nowrap",children:j?P(j.lastUsedAt):"—"}),e.jsx("td",{children:b===0?e.jsx(M,{tone:"warn",children:"not used"}):b<3?e.jsx(M,{tone:"note",children:"rare"}):e.jsx(M,{tone:"ok",children:"used"})})]},h)})})]})})}),t?null:e.jsx(T,{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 fe=s=>{const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n},an=s=>fe((s==null?void 0:s.target)||(s==null?void 0:s.screen)||(s==null?void 0:s.source)||(s==null?void 0:s.feature)),xi=s=>{const n=s.find(t=>t.category==="playback"&&t.action==="request");return n!=null&&n.source?fe(n.source):an(s[0])},qs=s=>s.itemName?`${fe(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${fe(s.source)} → ${fe(s.target)}`:an(s),ji=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"?"Asked to watch":"Requested",stop:"Left the player",start:s.category==="playback"?s.outcome==="failure"?"Playback failed":"Started watching":"Started",complete:s.category==="playback"?s.outcome==="completed"?"Finished watching":"Stopped watching":"Completed"})[s.action]??fe(s.action);function vi(s){var i;const n=[...s].reverse().find(r=>r.category==="playback"&&r.outcome);if((n==null?void 0:n.outcome)==="failure")return{label:"playback failed",tone:"warn"};if((n==null?void 0:n.outcome)==="completed")return{label:"watched",tone:"ok"};if((n==null?void 0:n.outcome)==="abandoned")return{label:"stopped part-way",tone:"note"};if((n==null?void 0:n.outcome)==="success")return{label:"watched",tone:"ok"};const t=(i=[...s].reverse().find(r=>r.outcome))==null?void 0:i.outcome;return t==="success"||t==="completed"?{label:fe(t),tone:"ok"}:t==="failure"||t==="cancelled"||t==="abandoned"?{label:fe(t),tone:"note"}:s.some(r=>r.action==="stop"&&r.category==="playback")?{label:"watched",tone:"ok"}:{label:"left before playback ended",tone:"warn"}}function gi(s){const n=s.reduce((t,i,r)=>(i.category==="playback"&&i.action==="request"&&t.push(r),t),[]);return n.length===0?[s]:n.map((t,i)=>s.slice(i===0?0:t,n[i+1]??s.length))}function bi(){var c,l;const{userId:s=""}=We(),n=p.useMemo(()=>`/admin/api/journeys${Te({days:90,userId:s})}`,[s]),{data:t,error:i,loading:r}=Q(n),o=((l=(c=t==null?void 0:t.users)==null?void 0:c.find(v=>v.userId===s))==null?void 0:l.username)||s,a=p.useMemo(()=>{const v=new Map;for(const g of(t==null?void 0:t.events)??[])v.set(g.journeyId,[...v.get(g.journeyId)??[],g]);return[...v.values()].map(g=>g.sort((u,m)=>u.sequence-m.sequence)).sort((g,u)=>{var m,f;return(((m=u[0])==null?void 0:m.occurredAt)??"").localeCompare(((f=g[0])==null?void 0:f.occurredAt)??"")})},[t==null?void 0:t.events]),d=a.flatMap(v=>gi(v).map((g,u)=>{var m;return{events:g,key:`${(m=v[0])==null?void 0:m.journeyId}:${u}`}}));return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:`${o}'s journeys`,intro:"Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome.",icon:"journey",crumbs:e.jsx(re,{className:"crumb",to:"/admin/journeys",children:"Journeys"})}),e.jsx(V,{message:i}),r?e.jsx(W,{}):e.jsx(T,{title:"Viewing journeys",intro:`${a.length} app session${a.length===1?"":"s"} · ${d.length} viewing journey${d.length===1?"":"s"} in the last 90 days.`,icon:"journey",tone:"info",children:e.jsx("div",{className:"visits",children:d.length===0?e.jsx("p",{className:"empty",children:"No journeys recorded for this viewer."}):d.map((v,g)=>{const u=v.events,m=u[0],f=[...u].reverse().find(j=>j.itemName||j.action==="select"||j.category==="playback"&&j.action==="request"),h=vi(u);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:P(m==null?void 0:m.occurredAt)}),e.jsxs("span",{children:["Journey ",g+1," · ",u.length," recorded steps"]})]}),e.jsx(M,{tone:h.tone,children:h.label})]}),e.jsxs("div",{className:"journey-answers",children:[e.jsxs("div",{className:"journey-answer","data-kind":"entry",children:[e.jsx(J,{name:"journey"}),e.jsx("span",{children:"Entered from"}),e.jsx("b",{children:xi(u)})]}),e.jsxs("div",{className:"journey-answer","data-kind":"selection",children:[e.jsx(J,{name:"play"}),e.jsx("span",{children:"Selected"}),e.jsx("b",{children:f?qs(f):"Nothing selected"})]}),e.jsxs("div",{className:"journey-answer","data-kind":"outcome",children:[e.jsx(J,{name:h.tone==="ok"?"check":"clock"}),e.jsx("span",{children:"Outcome"}),e.jsx("b",{children:h.label})]})]}),e.jsx("ol",{className:"journey-timeline",children:u.map(j=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":j.action}),e.jsxs("div",{children:[e.jsx("b",{children:ji(j)}),e.jsx("span",{children:qs(j)})]}),e.jsx("time",{children:P(j.occurredAt)})]},`${j.journeyId}:${j.sequence}`))})]},v.key)})})})]})}function Ds(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function fi(){const[s,n]=p.useState(30),{data:t,error:i,loading:r}=Q(`/admin/api/analytics?days=${s}`),o=(t==null?void 0:t.rows)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Row engagement",intro:"Impressions, focus, dwell and selections per launcher row."}),e.jsx(V,{message:i}),e.jsx(T,{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:a=>n(Number(a.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"}),e.jsx("option",{value:90,children:"90 days"})]})}),children:r?e.jsx(W,{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:o.length===0?e.jsx(ae,{columns:8,children:"No events in this window."}):o.map(a=>e.jsxs("tr",{children:[e.jsx("td",{children:Ds(a.rowId)}),e.jsx("td",{className:"muted",children:Ds(a.rowKind)}),e.jsx("td",{className:"num",children:Ne(a.dwellMs)}),e.jsx("td",{className:"num",children:w(a.impressions)}),e.jsx("td",{className:"num",children:w(a.focuses)}),e.jsx("td",{className:"num",children:w(a.selects)}),e.jsx("td",{className:"num",children:Ve(a.selectRate)}),e.jsx("td",{className:"num",children:w(a.viewers)})]},`${a.rowId}:${a.rowKind}`))})]})})})]})}function yi(){const[s,n]=p.useState(7),{data:t,error:i,loading:r}=Q(`/admin/api/searches?days=${s}`),o=(t==null?void 0:t.terms)??[],a=(t==null?void 0:t.recent)??[],d=t==null?void 0:t.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(V,{message:i}),e.jsx(le,{tiles:[{label:"searches",value:w((d==null?void 0:d.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:w((d==null?void 0:d.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:w((d==null?void 0:d.viewers)??0),icon:"people",tone:"note"},{label:"history kept",value:`${(t==null?void 0:t.retentionDays)??30} days`,small:!0,icon:"clock"}]}),r?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(T,{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:c=>n(Number(c.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(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:o.length===0?e.jsx(ae,{columns:4,children:"Nothing searched in this window."}):o.map(c=>e.jsxs("tr",{children:[e.jsx("td",{children:c.query}),e.jsx("td",{className:"num",children:w(c.searches)}),e.jsx("td",{className:"num",children:w(c.viewers)}),e.jsx("td",{className:"muted nowrap",children:P(c.lastAt)})]},c.query))})]})})}),e.jsx(T,{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:a.length===0?e.jsx(ae,{columns:3,children:"No searches in this window."}):a.map((c,l)=>e.jsxs("tr",{children:[e.jsx("td",{className:"muted nowrap",children:P(c.occurredAt)}),e.jsx("td",{children:c.username||e.jsx(M,{tone:"warn",children:c.userId||"unknown"})}),e.jsx("td",{children:c.query})]},`${c.occurredAt}:${l}`))})]})})})]})]})}function Fs(s,n){if(n===0)return s>0?"new this week":"no change";const t=Math.round((s-n)/n*100);return`${t>0?"+":""}${t}% vs last week`}function wi(){const{data:s,error:n,loading:t}=Q("/admin/api/views",{pollMs:6e4}),i=(s==null?void 0:s.daily)??[],r=(s==null?void 0:s.hourly)??[];return e.jsxs(e.Fragment,{children:[e.jsx(U,{title:"Views",intro:"How often people reach Memby’s home screen. This measures app use, not playback streams."}),e.jsx(V,{message:n}),t?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:Fs((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:w(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:Fs((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:w(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx(T,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct household profiles.",icon:"chart",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Day"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(ae,{columns:3,children:"No home-screen visits yet."}):i.map(o=>e.jsxs("tr",{children:[e.jsx("td",{children:o.label}),e.jsx("td",{className:"num",children:w(o.visits)}),e.jsx("td",{className:"num",children:w(o.viewers)})]},o.label))})]})})}),e.jsx(T,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when the household is opening Memby.",icon:"clock",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Hour"}),e.jsx("th",{className:"num",children:"Visits"}),e.jsx("th",{className:"num",children:"Viewers"})]})}),e.jsx("tbody",{children:r.length===0?e.jsx(ae,{columns:3,children:"No home-screen visits yet today."}):r.map(o=>e.jsxs("tr",{children:[e.jsx("td",{children:o.label}),e.jsx("td",{className:"num",children:w(o.visits)}),e.jsx("td",{className:"num",children:w(o.viewers)})]},o.label))})]})})})]})]})}const ki=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function Ni(){var r;const s=Q("/admin/api/media-reports",{pollMs:15e3}),{busy:n,run:t}=X(),i=(o,a)=>t(`${o.id}-${a}`,async()=>{await F.post(`/admin/api/media-reports/${o.id}/status`,{status:a}),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(W,{rows:4}):e.jsx(T,{title:"Open and recent reports",intro:"A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy.",icon:"inbox",children:(r=s.data)!=null&&r.reports.length?e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Media"}),e.jsx("th",{children:"Report"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Replacement"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Reported"}),e.jsx("th",{children:"Actions"})]})}),e.jsx("tbody",{children:s.data.reports.map(o=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:ki(o)}),e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.title})]}),e.jsxs("td",{children:[o.reason.replaceAll("_"," "),o.comment?e.jsxs(e.Fragment,{children:[e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.comment})]}):null]}),e.jsxs("td",{children:[o.reportedByUsername,e.jsx("br",{}),e.jsx("span",{className:"muted",children:o.reportedByDevice||"Unknown device"})]}),e.jsx("td",{children:e.jsx(M,{tone:o.replacementRequested?"note":void 0,children:o.replacementRequested?o.replacementStatus||"Requested":"Not requested"})}),e.jsx("td",{children:e.jsx(M,{tone:o.status==="resolved"?"ok":o.status==="dismissed"?void 0:"warn",children:o.status})}),e.jsx("td",{className:"nowrap muted",children:P(o.createdAt)}),e.jsxs("td",{children:[e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-acknowledged`,onClick:()=>void i(o,"acknowledged"),children:"Acknowledge"})," ",e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-resolved`,onClick:()=>void i(o,"resolved"),children:"Resolve"})," ",e.jsx($,{size:"sm",variant:"quiet",busy:n===`${o.id}-dismissed`,onClick:()=>void i(o,"dismissed"),children:"Dismiss"})]})]},o.id))})]})}):e.jsx(Y,{children:"No media problems have been reported."})})]})}const Si=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",Ci=s=>s==="no_match"?"no match":s,Ps=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",Os=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function Mi(){var h,j,b,k;const s=Q("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=ne(),{busy:t,run:i}=X(),[r,o]=p.useState(),[a,d]=p.useState(!1);p.useEffect(()=>{!a&&s.data&&o(s.data.settings)},[s.data,a]);const c=x=>{o(y=>y&&{...y,...x}),d(!0)},l=()=>{r&&i("save",async()=>{const x=await n(()=>F.put("/admin/api/credits",r),"Credits scanning settings saved.");x&&(s.set(x),o(x.settings),d(!1))})},v=((h=s.data)==null?void 0:h.history)??[],g=((j=s.data)==null?void 0:j.pending)??[],u=v.filter(x=>x.outcome==="detected").length,m=v.filter(x=>x.outcome==="no_match").length,f=v.filter(x=>x.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(V,{message:s.error}),s.loading||!r?e.jsx(W,{}):e.jsxs(e.Fragment,{children:[(b=s.data)!=null&&b.enabled?null:e.jsx(je,{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(le,{tiles:[{label:"Waiting candidates",value:w((k=s.data)==null?void 0:k.queueDepth),icon:"clock",tone:g.length?"info":void 0},{label:"Detected in this history",value:w(u),icon:"check",tone:"ok"},{label:"No match",value:w(m),icon:"search",tone:m?"warn":void 0},{label:"Failed",value:w(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(he,{cols:"wide",children:[e.jsx(T,{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($,{variant:"primary",icon:"check",busy:t==="save",disabled:!a,onClick:l,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(q,{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:r.candidateLimit,onChange:x=>c({candidateLimit:Number(x.target.value)})})}),e.jsx(q,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:r.prefetchEpisodes,onChange:x=>c({prefetchEpisodes:Number(x.target.value)})})}),e.jsx(q,{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:r.prefetchEpisodes,max:20,value:r.maxPrefetch,onChange:x=>c({maxPrefetch:Number(x.target.value)})})}),e.jsx(q,{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:r.retryHours,onChange:x=>c({retryHours:Number(x.target.value)})})})]})}),e.jsxs(T,{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(T,{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:g.length===0?e.jsx(ae,{columns:6,children:"No episodes are waiting to be scanned."}):g.map(x=>e.jsxs("tr",{children:[e.jsx("td",{children:Os(x.season,x.episode)}),e.jsx("td",{children:e.jsx(M,{tone:"info",children:Ps(x.reason)})}),e.jsx("td",{className:"num",children:w(x.priority)}),e.jsx("td",{className:"num",children:w(x.userCount)}),e.jsx("td",{className:"nowrap muted",title:P(x.lastViewed),children:be(x.lastViewed)}),e.jsx("td",{className:"mono muted",children:x.itemId})]},x.itemId))})]})})}),e.jsx(T,{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(ae,{columns:7,children:"No credits scans have completed yet."}):v.map(x=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(x.finishedAt),children:be(x.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:x.seriesName||x.itemName||x.itemId}),e.jsxs("span",{className:"table-sub",children:[Os(x.season,x.episode),x.itemName&&x.seriesName?` · ${x.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:"info",children:Ps(x.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",x.priority]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:Si(x.outcome),children:Ci(x.outcome)}),x.error?e.jsx("span",{className:"table-sub",children:x.error}):null]}),e.jsx("td",{className:"nowrap",children:x.markerMs>0?Ne(x.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[x.method||"visual",x.confidence>0?` · ${Ve(x.confidence)}`:"",x.frames>0?` · ${x.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:Ne(x.durationMs)})]},x.id))})]})})})]})]})}function Ei(){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 Ai(){return e.jsx(hn,{children:e.jsx(Cn,{children:e.jsx($n,{children:e.jsx(Fn,{children:e.jsx(un,{children:e.jsxs(B,{path:"/admin",element:e.jsx(qn,{}),children:[e.jsx(B,{index:!0,element:e.jsx(Pn,{})}),e.jsx(B,{path:"activity",element:e.jsx(_n,{})}),e.jsx(B,{path:"accounts",element:e.jsx(Un,{})}),e.jsx(B,{path:"accounts/:userId",element:e.jsx(Vn,{})}),e.jsx(B,{path:"accounts/:userId/settings",element:e.jsx(Gn,{})}),e.jsx(B,{path:"clients",element:e.jsx(Jn,{})}),e.jsx(B,{path:"logins",element:e.jsx(Qn,{})}),e.jsx(B,{path:"devices/:deviceId",element:e.jsx(nt,{})}),e.jsx(B,{path:"library",element:e.jsx(tt,{})}),e.jsx(B,{path:"ratings",element:e.jsx(at,{})}),e.jsx(B,{path:"requests",element:e.jsx(rt,{})}),e.jsx(B,{path:"recommendations",element:e.jsx(lt,{})}),e.jsx(B,{path:"inspector",element:e.jsx(dt,{})}),e.jsx(B,{path:"hero",element:e.jsx(pt,{})}),e.jsx(B,{path:"features",element:e.jsx(jt,{})}),e.jsx(B,{path:"playback",element:e.jsx(vt,{})}),e.jsx(B,{path:"subtitles",element:e.jsx(gt,{})}),e.jsx(B,{path:"credits",element:e.jsx(Mi,{})}),e.jsx(B,{path:"updates",element:e.jsx(bt,{})}),e.jsx(B,{path:"tasks",element:e.jsx(Nt,{})}),e.jsx(B,{path:"integrations",element:e.jsx(Ct,{})}),e.jsx(B,{path:"maintenance",element:e.jsx(Tt,{})}),e.jsx(B,{path:"settings",element:e.jsx(Lt,{})}),e.jsx(B,{path:"imports",element:e.jsx(qt,{})}),e.jsx(B,{path:"logs",element:e.jsx(mi,{})}),e.jsx(B,{path:"journeys",element:e.jsx(pi,{})}),e.jsx(B,{path:"journeys/:userId",element:e.jsx(bi,{})}),e.jsx(B,{path:"views",element:e.jsx(wi,{})}),e.jsx(B,{path:"engagement",element:e.jsx(fi,{})}),e.jsx(B,{path:"searches",element:e.jsx(yi,{})}),e.jsx(B,{path:"media-reports",element:e.jsx(Ni,{})}),e.jsx(B,{path:"overview",element:e.jsx(mn,{to:"/admin",replace:!0})}),e.jsx(B,{path:"*",element:e.jsx(Ei,{})})]})})})})})})}const rn=document.getElementById("root");if(!rn)throw new Error("the console has no root element to render into");Bs(rn).render(e.jsx(p.StrictMode,{children:e.jsx(Ai,{})})); diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index 87e2e0f..31a3bbd 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -13,7 +13,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" /> - + diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 8217939..063880a 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -541,6 +541,7 @@ export interface GatewaySettings { sonarrAlertMinutes: number; radarrAlertMinutes: number; embyHealthSeconds: number; + librarySyncMinutes: number; updatedAt?: string; updatedBy?: string; } @@ -554,6 +555,7 @@ export interface GatewaySettingValues { sonarrAlertMinutes: number; radarrAlertMinutes: number; embyHealthSeconds: number; + librarySyncMinutes: number; } export interface GatewaySettingsResponse { @@ -563,3 +565,39 @@ export interface GatewaySettingsResponse { logLevels: string[] | null; version: string; } + +/** IngestJob is one thing Sonarr or Radarr said changed. The key is derived from the file + * rather than the delivery, which is what makes a repeated webhook one row. */ +export interface IngestJob { + key: string; + action: string; + kind: string; + reason: string; + source: string; + payload: { + series?: string; + seriesYear?: number; + season?: number; + episode?: number; + title?: string; + year?: number; + embyItemId?: string; + }; + state: string; + outcome: string; + itemId: string; + attempts: number; + lastError: string; + dueAt: string; + createdAt: string; + updatedAt: string; +} + +export interface IngestResponse { + sonarrConfigured: boolean; + radarrConfigured: boolean; + settleSeconds: number; + syncMinutes: number; + counts: { pending: number; done: number; failed: number }; + recent: IngestJob[]; +} diff --git a/admin-ui/src/pages/Accounts.tsx b/admin-ui/src/pages/Accounts.tsx index 12caf69..0d11d0b 100644 --- a/admin-ui/src/pages/Accounts.tsx +++ b/admin-ui/src/pages/Accounts.tsx @@ -1,7 +1,16 @@ import { Link } from 'react-router-dom'; import { useQuery } from '../lib/hooks'; -import { initials, num, presence, recent, watchTime, when } from '../lib/format'; -import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui'; +import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format'; +import { + Banner, + Card, + EmptyRow, + Loading, + PageHead, + TableWrap, + Tag, + Tiles, +} from '../components/ui'; import type { KnownClient } from '../api/types'; /* A directory, and only a directory. Everything you can *do* to a person lives on their own @@ -38,6 +47,23 @@ interface AccountsResponse { accounts: Account[] | null; } +/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than + * first — an invalid date yields NaN, and NaN comparisons would scatter those rows. */ +function seenAt(value: string | undefined): number { + const at = value ? new Date(value).getTime() : 0; + return Number.isFinite(at) ? at : 0; +} + +/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running + * none and a person it has never matched are both "not measured", never "none". */ +function NotMeasured() { + return ( + + — + + ); +} + export function AccountsPage() { const { data, error, loading } = useQuery('/admin/api/accounts', { pollMs: 60_000, @@ -53,17 +79,18 @@ export function AccountsPage() { const tracked = accounts.filter((account) => account.watchTime?.matched); const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0); + /* Most recently seen first, so the table means something without being sorted. Whoever + is using Memby right now is the row an operator opening this page is looking for, and a + person who has never signed in from a device sorts to the bottom rather than the top. */ + const rows = [...accounts].sort( + (a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen), + ); + return ( <> - - 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. - - {loading ? ( ) : ( @@ -93,53 +120,80 @@ export function AccountsPage() { ]} /> -
- {accounts.length === 0 ? ( - - No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here. - - ) : ( - accounts.map((account) => { - const list = account.devices ?? []; - const active = list.filter((device) => recent(device.lastSeen)).length; - const state = account.recommendations?.completed - ? { label: 'personalised', tone: 'ok' as const } - : account.recommendations?.prompted - ? { label: 'prompt queued', tone: 'warn' as const } - : { label: 'not invited', tone: undefined }; - const seen = presence(account.lastSeen); - const watched = account.watchTime; - return ( - - - {account.initials || initials(account.username)} - - - {account.username || 'Unnamed user'} - - - - {num(list.length)} device{list.length === 1 ? '' : 's'} - {active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)} + + + + + + + + + + + + + + + {rows.length === 0 ? ( + + No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here. + + ) : ( + rows.map((account) => { + const list = account.devices ?? []; + const active = list.filter((device) => recent(device.lastSeen)).length; + const state = account.recommendations?.completed + ? { label: 'personalised', tone: 'ok' as const } + : account.recommendations?.prompted + ? { label: 'prompt queued', tone: 'warn' as const } + : { label: 'not invited', tone: undefined }; + const seen = presence(account.lastSeen); + const watched = account.watchTime; + return ( + + + {/* The month sits beside the week because a quiet week only means - something next to the month around it. Both are omitted rather - than zeroed for somebody Tracearr has never seen. */} - {watched?.matched - ? ` · watched ${watchTime(watched.weekMs)} this week, ${watchTime(watched.monthMs)} this month` - : ''} - - - - - {watched?.matched ? {watchTime(watched.weekMs)} : null} - {state.label} - Manage - - - ); - }) - )} - + something next to the month around it. Both are a dash rather + than a zero for somebody Tracearr has never seen: "0 min" would + have an operator investigating a person when the real answer is + that nothing was ever asked. */} + + + + + + ); + }) + )} + +
PersonDevicesThis weekThis monthRecommendationsLast seen
+ + + {account.initials || initials(account.username)} + + {account.username || 'Unnamed user'} + + + + {num(list.length)} + {/* Only where there is something to say. A sub-line under every + row reading "0 active now" is a column of noise. */} + {active ? {num(active)} active now : null} + + {watched?.matched ? watchTime(watched.weekMs) : } + + {watched?.matched ? watchTime(watched.monthMs) : } + + {state.label} + + {ago(account.lastSeen)} +
+
+
)} diff --git a/admin-ui/src/pages/Imports.tsx b/admin-ui/src/pages/Imports.tsx index ac35871..7b76ad3 100644 --- a/admin-ui/src/pages/Imports.tsx +++ b/admin-ui/src/pages/Imports.tsx @@ -1,6 +1,136 @@ import { useGateway } from '../lib/gateway'; +import { useQuery } from '../lib/hooks'; import { num, when } from '../lib/format'; -import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag } from '../components/ui'; +import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles } from '../components/ui'; +import type { IngestResponse, IngestJob } from '../api/types'; + +// Two tables about the same catalogue, and they answer different questions. The webhook +// activity is "what did Sonarr and Radarr tell us, and did we act on it" — the one to read +// when somebody says a new episode is not showing up. The sweep history underneath is +// "when did we last ask Emby the whole question", which is now reconciliation rather than +// how anything is discovered. + +const stateTone: Record = { + done: 'ok', + pending: 'warn', + failed: 'bad', +}; + +/** What a row was about, in words. The key is a machine identity and the payload is what a + * person recognises. */ +function subject(job: IngestJob): string { + const payload = job.payload ?? {}; + if (payload.series) { + const position = + payload.episode && payload.episode > 0 + ? ` S${String(payload.season ?? 0).padStart(2, '0')}E${String(payload.episode).padStart(2, '0')}` + : ''; + return `${payload.series}${position}`; + } + if (payload.title) { + return payload.year ? `${payload.title} (${payload.year})` : payload.title; + } + return job.key; +} + +function WebhookActivity() { + const { data, error, loading } = useQuery('/admin/api/ingest', { pollMs: 15000 }); + + if (loading) return ; + + const wired = Boolean(data?.sonarrConfigured || data?.radarrConfigured); + const recent = data?.recent ?? []; + + return ( + <> + + + + + {!wired ? ( +

+ Neither hook has a token, so both answer 404 and nothing is recorded here. Set + MEMBY_SONARR_WEBHOOK_TOKEN and MEMBY_RADARR_WEBHOOK_TOKEN, then point each *arr at{' '} + /hooks/sonarr and /hooks/radarr. Until then the catalogue + sweep below is the only way a new title is found. +

+ ) : null} + + + + + + + + + + + + + + + + + {recent.length === 0 ? ( + + {wired + ? 'Nothing has been imported, upgraded, renamed or deleted since this was switched on.' + : 'No webhook is configured.'} + + ) : ( + recent.map((job) => ( + + + + + + + + + + + )) + )} + +
WhenSourceWhatWhyStateOutcomeTriesNotes
{when(job.updatedAt)}{job.source || '—'}{subject(job)}{job.reason} + {job.state} + {job.outcome || '—'}{job.attempts}{job.lastError || ''}
+
+
+ + ); +} export function ImportsPage() { const { status, error, loading } = useGateway(); @@ -8,15 +138,20 @@ export function ImportsPage() { return ( <> - + + + {loading ? ( ) : ( diff --git a/admin-ui/src/pages/Settings.tsx b/admin-ui/src/pages/Settings.tsx index 58c031b..e481ca3 100644 --- a/admin-ui/src/pages/Settings.tsx +++ b/admin-ui/src/pages/Settings.tsx @@ -53,6 +53,7 @@ interface Draft { sonarrAlertMinutes: string; radarrAlertMinutes: string; embyHealthSeconds: string; + librarySyncMinutes: string; } function draftFrom(settings: GatewaySettings): Draft { @@ -63,6 +64,7 @@ function draftFrom(settings: GatewaySettings): Draft { sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes), radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes), embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds), + librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes), }; } @@ -92,6 +94,7 @@ export function SettingsPage() { sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true), radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true), embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true), + librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true), }; const saved = await wrap( () => api.post('/admin/api/gateway-settings', body), @@ -111,6 +114,7 @@ export function SettingsPage() { () => api.post('/admin/api/gateway-settings', { timezone: '', logLevel: '', sessionIdleDays: 0, sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, + librarySyncMinutes: 0, }), 'Every setting is back to what this container was deployed with.', ); @@ -147,6 +151,7 @@ export function SettingsPage() { { label: 'Log level', value: effective.logLevel }, { label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') }, { label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') }, + { label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') }, { label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') }, { label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') }, ]} @@ -225,6 +230,21 @@ export function SettingsPage() { +
+ + set('librarySyncMinutes', event.target.value)} + /> + +
+
preferences[Keys.THEME_PALETTE] = paletteJson + preferences[Keys.THEME_ICON_SET] = iconSet preferences[Keys.THEME_REVISION] = revision } } @@ -1397,6 +1406,7 @@ class SettingsStore(private val context: Context) { // Dropping it puts this set on the default until ThemeSync answers, which is a // second of the app's own colours rather than a minute of somebody else's. preferences.remove(Keys.THEME_PALETTE) + preferences.remove(Keys.THEME_ICON_SET) preferences.remove(Keys.THEME_REVISION) preferences[Keys.HOME_SECTIONS] = profile.homeSections preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity @@ -1520,6 +1530,7 @@ class SettingsStore(private val context: Context) { ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE, themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID, themePaletteJson = preferences[Keys.THEME_PALETTE], + themeIconSet = preferences[Keys.THEME_ICON_SET].orEmpty(), themeRevision = preferences[Keys.THEME_REVISION].orEmpty(), onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION], diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt index 2d578f5..8f8e8be 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt @@ -5,8 +5,11 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.ponzischeme89.memby.data.model.GatewayTheme import com.ponzischeme89.memby.data.model.GatewayThemeStatus +import com.ponzischeme89.memby.ui.theme.MaterialIconPack import com.ponzischeme89.memby.ui.theme.MembyPalette +import com.ponzischeme89.memby.ui.theme.applyMembyIconPack import com.ponzischeme89.memby.ui.theme.applyMembyPalette +import com.ponzischeme89.memby.ui.theme.membyIconPackFor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -95,8 +98,11 @@ class ThemeSync( // happens to reach the foreground. scope.launch { repository.settingsFlow - .distinctUntilChanged { old, new -> old.themePaletteJson == new.themePaletteJson } - .collect { session -> applyCached(session.themePaletteJson) } + .distinctUntilChanged { old, new -> + old.themePaletteJson == new.themePaletteJson && + old.themeIconSet == new.themeIconSet + } + .collect { session -> applyCached(session.themePaletteJson, session.themeIconSet) } } ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -131,6 +137,7 @@ class ThemeSync( _available.value = emptyList() appliedRevision = null applyMembyPalette(MembyPalette()) + applyMembyIconPack(MaterialIconPack.pack) return } // A server that predates themes sends nothing, and there is nothing to fetch. The @@ -159,6 +166,9 @@ class ThemeSync( val palette = resolved.palette.toMembyPalette() applyMembyPalette(palette) + // Unknown slugs resolve to the marks the app shipped with, so a pack the operator + // added after this build went out costs nothing — see membyIconPackFor. + applyMembyIconPack(membyIconPackFor(resolved.iconSet)) // The revision recorded is the one the *response* carried, not the one the poll // advertised. They differ if a season turned over between the two, and storing the // poll's would leave this set believing it holds a palette it never received. @@ -166,6 +176,7 @@ class ThemeSync( runCatching { settings.setThemePalette( json.encodeToString(com.ponzischeme89.memby.data.model.GatewayPalette.serializer(), resolved.palette), + resolved.iconSet, appliedRevision.orEmpty(), ) }.onFailure { @@ -180,7 +191,11 @@ class ThemeSync( * Paints from what was stored at the end of the last session, before anything is asked * of the network. A blank or unreadable cache leaves the default palette standing. */ - private fun applyCached(paletteJson: String?) { + private fun applyCached(paletteJson: String?, iconSet: String) { + // The pack is applied on its own evidence. It is cached as a slug rather than + // inside the palette document, so a set whose stored palette will not parse still + // opens wearing the marks it was told to wear. + applyMembyIconPack(membyIconPackFor(iconSet)) if (paletteJson.isNullOrBlank()) return val palette = runCatching { json.decodeFromString( diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 051fef1..5a9968a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -319,6 +319,18 @@ data class GatewayTheme( * field rather than deriving the animation from the theme id. */ val decoration: String = "", + /** + * The pack this theme's marks are drawn from: "material", "lucide", "fontawesome", or + * empty for the marks the app shipped with. + * + * A slug, for the reason [decoration] is one — the shapes are the television's, in + * `ui/theme/MembyIconPacks.kt`, and a pack this build has never heard of falls back to + * Material rather than drawing nothing. That is what lets an operator add a pack to the + * catalogue before the fleet has the release that knows it, and it is the same bargain + * the palette makes: the gateway sends a decision, never geometry, so the worst a bad + * theme can do is look unremarkable. + */ + val iconSet: String = "", val revision: String = "", val palette: GatewayPalette = GatewayPalette(), ) 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 10bc356..ccf5e53 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState @@ -39,9 +41,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -1254,7 +1253,7 @@ internal fun DetailStripFrame( // 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, + MembyIcon.ChevronDown.mark, contentDescription = null, tint = DetailQuietText, modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp), @@ -1690,7 +1689,7 @@ private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modif AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } Icon( - Icons.Default.PlayArrow, + MembyIcon.Play.mark, contentDescription = null, tint = Color.White, modifier = Modifier 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 44a48e8..475a2f9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup @@ -22,12 +24,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -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.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -280,7 +276,7 @@ internal fun EpisodeDetailContent( DetailHeroAction( // A heart, not a tick: "Mark watched" beside it is a tick as well. The // heart is what a home card and the screensaver already use. - icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites", active = item.isFavorite, onClick = { @@ -292,7 +288,7 @@ internal fun EpisodeDetailContent( ) add( DetailHeroAction( - icon = Icons.Default.DoneAll, + icon = MembyIcon.CheckAll.mark, description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", active = item.userData?.played == true, onClick = { onTogglePlayed(item, item.userData?.played != true) }, @@ -303,7 +299,7 @@ internal fun EpisodeDetailContent( item.seriesId?.takeIf(String::isNotBlank)?.let { seriesId -> add( DetailHeroAction( - icon = Icons.Default.Tv, + icon = MembyIcon.Tv.mark, description = "Open ${item.seriesName ?: "the series"}", onClick = { onOpenItem( @@ -453,7 +449,7 @@ private fun SeasonStop( ) { if (done) { Icon( - Icons.Default.Check, + MembyIcon.Check.mark, contentDescription = "Watched", tint = if (selected || focused) DetailAccent else DetailAccent.copy(alpha = 0.6f), modifier = Modifier.size(14.dp).padding(end = 1.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt index 8e83aa6..a9c9555 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ExitConfirmation.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -19,8 +21,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -162,7 +162,7 @@ internal fun ExitMembyConfirmation( contentAlignment = Alignment.Center, ) { Icon( - Icons.Default.PowerSettingsNew, + MembyIcon.Power.mark, contentDescription = null, tint = MembyAccentBright, modifier = Modifier.size(27.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 03c2471..cf5e8be 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -82,37 +84,6 @@ 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.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.PlaylistAdd -import androidx.compose.material.icons.filled.PlaylistRemove -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.PlayCircleFilled -import androidx.compose.material.icons.filled.Recommend -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SentimentVerySatisfied -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.TheaterComedy -import androidx.compose.material.icons.filled.Tv -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.material.icons.filled.VisibilityOff import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage @@ -177,21 +148,21 @@ internal val TvRailContentShift = 112.dp * while For You is a *narrow* one — a handful of ranked titles — so it belongs down beside * the calendar with the other answers rather than above the shelves it draws from. */ -enum class BrowseDestination(val label: String, val icon: ImageVector) { - HOME("Home", Icons.Default.Home), +enum class BrowseDestination(val label: String, val icon: MembyIcon) { + HOME("Home", MembyIcon.Home), // The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has // the genre browser on — see [TvNavigationRail]'s genresEnabled. - GENRES("Genres", Icons.Default.GridView), - SEARCH("Search", Icons.Default.Search), - MOVIES("Movies", Icons.Default.Movie), - SHOWS("TV Shows", Icons.Default.Tv), - FOR_YOU("For You", Icons.Default.AutoAwesome), + GENRES("Genres", MembyIcon.Grid), + SEARCH("Search", MembyIcon.Search), + MOVIES("Movies", MembyIcon.Movie), + SHOWS("TV Shows", MembyIcon.Tv), + FOR_YOU("For You", MembyIcon.Sparkle), // Sonarr's schedule, a month at a time. Hidden unless the gateway says the household // has one — see [TvNavigationRail]'s calendarEnabled. - CALENDAR("TV Calendar", Icons.Default.CalendarMonth), - FAVORITES("Favourites", Icons.Default.Favorite), - PROFILES("User", Icons.Default.Person), - SETTINGS("Settings", Icons.Default.Settings), + CALENDAR("TV Calendar", MembyIcon.Calendar), + FAVORITES("Favourites", MembyIcon.Favourite), + PROFILES("User", MembyIcon.Person), + SETTINGS("Settings", MembyIcon.Settings), } /** @@ -229,40 +200,40 @@ private data class HomeRowVisual( private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when { row.id == "continue" -> HomeRowVisual( - Icons.Default.PlayCircleFilled, + MembyIcon.PlayCircle.mark, ) row.id == "continue-shows" -> HomeRowVisual( - Icons.Default.PlayCircleFilled, + MembyIcon.PlayCircle.mark, ) row.kind == MediaRowKind.FAVORITES -> HomeRowVisual( - Icons.Default.Favorite, + MembyIcon.Favourite.mark, ) row.id == "latest-movies" -> HomeRowVisual( - Icons.Default.Movie, + MembyIcon.Movie.mark, ) row.id == "sonarr-airing-today" -> HomeRowVisual( - Icons.Default.CalendarMonth, + MembyIcon.Calendar.mark, ) row.id == "curated:apple-tv" -> HomeRowVisual( - Icons.Default.LiveTv, + MembyIcon.LiveTv.mark, ) row.id == "curated:drama-shows" -> HomeRowVisual( - Icons.Default.TheaterComedy, + MembyIcon.Drama.mark, ) row.id == "curated:comedy-shows" -> HomeRowVisual( - Icons.Default.SentimentVerySatisfied, + MembyIcon.Happy.mark, ) row.id.startsWith("similar:") -> HomeRowVisual( - Icons.Default.AutoAwesome, + MembyIcon.Sparkle.mark, ) row.id == "recommended" -> HomeRowVisual( - Icons.Default.Recommend, + MembyIcon.Recommend.mark, ) row.id.startsWith("for-you:") -> HomeRowVisual( - Icons.Default.AutoAwesome, + MembyIcon.Sparkle.mark, ) else -> HomeRowVisual( - Icons.Default.VideoLibrary, + MembyIcon.VideoLibrary.mark, ) } @@ -632,7 +603,7 @@ fun UserSwitcherOverlay( // what replaces the bell that used to sit in the corner of Home. UserSwitcherAction( label = "Notifications", - icon = Icons.Default.Notifications, + icon = MembyIcon.Notification.mark, badge = alertBadgeLabel(alertCount), modifier = Modifier .focusRequester(focusRequesters[profiles.size]) @@ -645,7 +616,7 @@ fun UserSwitcherOverlay( if (showRequests) { UserSwitcherAction( label = "Requests", - icon = Icons.Default.PlaylistAdd, + icon = MembyIcon.PlaylistAdd.mark, modifier = Modifier .focusRequester(focusRequesters[profiles.size + 1]) .onFocusChanged { @@ -657,7 +628,7 @@ fun UserSwitcherOverlay( val settingsIndex = profiles.size + if (showRequests) 2 else 1 UserSwitcherAction( label = "Settings", - icon = Icons.Default.Settings, + icon = MembyIcon.Settings.mark, modifier = Modifier .focusRequester(focusRequesters[settingsIndex]) .onFocusChanged { @@ -668,7 +639,7 @@ fun UserSwitcherOverlay( val manageIndex = profiles.size + actionCount - 1 UserSwitcherAction( label = "Manage users", - icon = Icons.Default.Person, + icon = MembyIcon.Person.mark, modifier = Modifier .focusRequester(focusRequesters[manageIndex]) .onFocusChanged { @@ -743,7 +714,7 @@ private fun UserSwitcherProfileItem( ) if (current) { Icon( - Icons.Default.CheckCircle, + MembyIcon.CheckCircle.mark, contentDescription = null, tint = EmbyGreen, modifier = Modifier.size(14.dp), @@ -876,7 +847,7 @@ fun ExpandableNavigationItem( ) } } else { - Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp)) + Icon(destination.icon.mark, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp)) } if (selected) { Box( @@ -1158,7 +1129,7 @@ fun MediaQuickActionsOverlay( ) QuickActionMenuItem( label = "View details", - icon = Icons.Default.Info, + icon = MembyIcon.Info.mark, modifier = Modifier .focusRequester(focusRequesters[0]) .onFocusChanged { if (it.isFocused) focusedIndex = 0 }, @@ -1167,7 +1138,7 @@ fun MediaQuickActionsOverlay( Spacer(Modifier.height(2.dp)) QuickActionMenuItem( label = if (item.isFavorite) "Remove from favourites" else "Add to favourites", - icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, modifier = Modifier .focusRequester(focusRequesters[1]) .onFocusChanged { if (it.isFocused) focusedIndex = 1 }, @@ -1179,7 +1150,7 @@ fun MediaQuickActionsOverlay( Spacer(Modifier.height(2.dp)) QuickActionMenuItem( label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", - icon = Icons.Default.CheckCircle, + icon = MembyIcon.CheckCircle.mark, modifier = Modifier .focusRequester(focusRequesters[2]) .onFocusChanged { if (it.isFocused) focusedIndex = 2 }, @@ -1192,7 +1163,7 @@ fun MediaQuickActionsOverlay( Spacer(Modifier.height(2.dp)) QuickActionMenuItem( label = "Remove from Continue Watching", - icon = Icons.Default.PlaylistRemove, + icon = MembyIcon.PlaylistRemove.mark, modifier = Modifier .focusRequester(focusRequesters[3]) .onFocusChanged { if (it.isFocused) focusedIndex = 3 }, @@ -1217,7 +1188,7 @@ fun MediaQuickActionsOverlay( ) QuickActionMenuItem( label = if (rowPinned) "Unpin row" else "Pin row to top", - icon = Icons.Default.PushPin, + icon = MembyIcon.Pin.mark, modifier = Modifier .focusRequester(focusRequesters[rowActionStartIndex]) .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex }, @@ -1225,7 +1196,7 @@ fun MediaQuickActionsOverlay( ) QuickActionMenuItem( label = "Move row up", - icon = Icons.Default.ArrowUpward, + icon = MembyIcon.ArrowUp.mark, modifier = Modifier .focusRequester(focusRequesters[rowActionStartIndex + 1]) .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 }, @@ -1233,7 +1204,7 @@ fun MediaQuickActionsOverlay( ) QuickActionMenuItem( label = "Move row down", - icon = Icons.Default.ArrowDownward, + icon = MembyIcon.ArrowDown.mark, modifier = Modifier .focusRequester(focusRequesters[rowActionStartIndex + 2]) .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 }, @@ -1241,7 +1212,7 @@ fun MediaQuickActionsOverlay( ) QuickActionMenuItem( label = "Hide this row", - icon = Icons.Default.VisibilityOff, + icon = MembyIcon.HideWatched.mark, modifier = Modifier .focusRequester(focusRequesters[rowActionStartIndex + 3]) .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 }, @@ -1258,7 +1229,7 @@ fun MediaQuickActionsOverlay( Spacer(Modifier.height(6.dp)) QuickActionMenuItem( label = "Close", - icon = Icons.Default.ChevronLeft, + icon = MembyIcon.ChevronLeft.mark, modifier = Modifier .focusRequester(focusRequesters[actionCount - 1]) .onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 }, @@ -1487,11 +1458,11 @@ private fun MetadataStatus(item: BaseItem) { Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp) } if (item.userData?.played == true) { - Icon(Icons.Default.CheckCircle, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp)) + Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp)) Text("Watched", color = MutedText, fontSize = 13.sp) } if (item.isFavorite) { - Icon(Icons.Default.Favorite, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp)) + Icon(MembyIcon.Favourite.mark, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp)) Text("Favourite", color = MutedText, fontSize = 13.sp) } } @@ -1542,8 +1513,8 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) */ internal fun mediaTypeMark(item: BaseItem): Pair? = when { item.isSeries || item.isEpisode || item.type.equals("Season", ignoreCase = true) -> - Icons.Default.Tv to "TV show" - item.type.equals("Movie", ignoreCase = true) -> Icons.Default.Movie to "Film" + MembyIcon.Tv.mark to "TV show" + item.type.equals("Movie", ignoreCase = true) -> MembyIcon.Movie.mark to "Film" else -> null } @@ -1817,7 +1788,7 @@ private fun FavoriteShowsEmptyState( contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Default.FavoriteBorder, + imageVector = MembyIcon.FavouriteOutline.mark, contentDescription = null, tint = Color.White, modifier = Modifier.size(25.dp), @@ -1872,7 +1843,7 @@ private fun GalleryJumpButton( contentAlignment = Alignment.Center, ) { Icon( - imageVector = if (forward) Icons.Default.ChevronRight else Icons.Default.ChevronLeft, + imageVector = if (forward) MembyIcon.ChevronRight.mark else MembyIcon.ChevronLeft.mark, contentDescription = null, tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f), modifier = Modifier.size(23.dp), @@ -2125,7 +2096,7 @@ private fun MediaCard( } if (imageUrl == null || failed) { Icon( - Icons.Default.BrokenImage, + MembyIcon.BrokenImage.mark, contentDescription = "Artwork unavailable", tint = QuietText, modifier = Modifier.size(30.dp), @@ -2146,14 +2117,14 @@ private fun MediaCard( ) { if (item.userData?.played == true) { MediaStatusIcon( - icon = Icons.Default.CheckCircle, + icon = MembyIcon.CheckCircle.mark, description = "Watched", tint = EmbyGreen, ) } if (item.isFavorite) { MediaStatusIcon( - icon = Icons.Default.Favorite, + icon = MembyIcon.Favourite.mark, description = "Favourite", tint = Color(0xFFFF6B81), ) 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 259d7c6..7f1fccd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import android.content.Intent import android.os.Build import android.os.Bundle @@ -117,11 +119,6 @@ import coil.compose.AsyncImage import coil.imageLoader import coil.request.ImageRequest import com.ponzischeme89.memby.R -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.NightsStay -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.WbSunny import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings @@ -4111,7 +4108,7 @@ private fun RecentSearchesRow( modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically, ) { - HomeRowHeaderIcon(Icons.Default.Search) + HomeRowHeaderIcon(MembyIcon.Search.mark) Spacer(Modifier.width(HomeRowHeaderIconGap)) Text( "Recent searches", @@ -4212,9 +4209,9 @@ private fun HomeClock( ) { Icon( imageVector = when (period) { - HomeGreetingPeriod.MORNING -> Icons.Default.LightMode - HomeGreetingPeriod.AFTERNOON -> Icons.Default.WbSunny - HomeGreetingPeriod.EVENING -> Icons.Default.NightsStay + HomeGreetingPeriod.MORNING -> MembyIcon.Sunrise.mark + HomeGreetingPeriod.AFTERNOON -> MembyIcon.Sun.mark + HomeGreetingPeriod.EVENING -> MembyIcon.Night.mark }, contentDescription = null, tint = MembyAccent, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index 7ccdde3..cb179b6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -27,8 +29,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -288,7 +288,7 @@ private fun PulsingEmblem(pulse: Float, gearRotation: Float) { contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Default.Build, + imageVector = MembyIcon.Build.mark, contentDescription = null, tint = MaintenanceAccent, modifier = Modifier 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 8768c23..5d4c785 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -1,12 +1,8 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material.icons.Icons -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.FirstPage -import androidx.compose.material.icons.filled.Movie import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -257,7 +253,7 @@ internal fun MediaDetailContent( DetailHeroAction( // A heart, not a tick: "Mark watched" two buttons along is a tick as // well. The heart is what a home card and the screensaver already use. - icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites", active = item.isFavorite, onClick = { @@ -268,11 +264,11 @@ internal fun MediaDetailContent( ), ) trailer?.let { - add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) })) + add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) })) } add( DetailHeroAction( - icon = Icons.Default.DoneAll, + icon = MembyIcon.CheckAll.mark, description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", active = item.userData?.played == true, onClick = { onTogglePlayed(item, item.userData?.played != true) }, @@ -283,7 +279,7 @@ internal fun MediaDetailContent( franchise?.takeIf { it.firstMovie.id != item.id }?.let { start -> add( DetailHeroAction( - icon = Icons.Default.FirstPage, + icon = MembyIcon.FirstPage.mark, description = "Open the first ${start.name} movie, ${start.firstMovie.name}", label = "Start with ${start.firstMovie.name}", onClick = { onOpenItem(start.firstMovie) }, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt index d5be6ae..c731115 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -13,9 +15,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -64,7 +63,7 @@ internal fun MembyPlayButton( val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus") PrimaryActionSurface( label = label, - icon = Icons.Default.PlayArrow, + icon = MembyIcon.Play.mark, focused = focused, compact = compact, modifier = modifier @@ -83,7 +82,7 @@ internal fun MembyPlayChip( ) { PrimaryActionSurface( label = label, - icon = Icons.Default.PlayArrow, + icon = MembyIcon.Play.mark, focused = focused, compact = compact, modifier = modifier, @@ -108,7 +107,7 @@ internal fun MembyArtworkPlayCue(modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { Icon( - Icons.Default.PlayArrow, + MembyIcon.Play.mark, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp), @@ -186,7 +185,7 @@ internal fun MembyChoiceChip( ) { if (selected) { Icon( - Icons.Default.Check, + MembyIcon.Check.mark, contentDescription = null, tint = if (focused) Color.Black else Color.White, modifier = Modifier.size(15.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index baf934b..7876016 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -2,6 +2,8 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -19,8 +21,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.focusGroup import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -77,7 +77,7 @@ internal fun MyShowsStrip( modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically, ) { - HomeRowHeaderIcon(Icons.Default.Bookmark) + HomeRowHeaderIcon(MembyIcon.Bookmark.mark) Spacer(Modifier.width(HomeRowHeaderIconGap)) Text( "My Shows", 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 59f2a2a..3888c1e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -26,13 +28,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.BookmarkBorder -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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -377,7 +372,7 @@ internal fun SeriesDetailContent( // two adjacent circles that both read as "add this" say nothing about // which list is which. The heart is already what a home card, the // screensaver and the Favourites row header use for this. - icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites", active = item.isFavorite, onClick = { @@ -392,14 +387,14 @@ internal fun SeriesDetailContent( // Filled/outline says on-or-off for both toggles, so the two differ by // silhouette alone — heart against bookmark. The +/✓ variants said it // a second way and borrowed the glyphs the other buttons use. - icon = if (isMyShow) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + icon = if (isMyShow) MembyIcon.Bookmark.mark else MembyIcon.BookmarkOutline.mark, description = if (isMyShow) "Remove from My Shows" else "Add to My Shows", active = isMyShow, onClick = { onToggleMyShow(item, !isMyShow) }, )) } trailer?.let { - add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) })) + add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) })) } }, ) { visibleTab -> @@ -681,7 +676,7 @@ internal fun EpisodeCard( } if (episode.isPlayed) { Icon( - Icons.Default.CheckCircle, + MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = DetailAccent, modifier = Modifier diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index 146b7f0..7dd2b72 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import android.os.Build import android.content.Context import androidx.activity.compose.BackHandler @@ -29,8 +31,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -283,7 +283,7 @@ fun UpdateScreen( contentAlignment = Alignment.Center, ) { Icon( - imageVector = Icons.Default.ArrowDownward, + imageVector = MembyIcon.ArrowDown.mark, contentDescription = null, tint = UpdateAccent, modifier = Modifier.size(40.dp), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index 3cd7725..e3bef06 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui.alerts +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import android.provider.Settings as AndroidSettings import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -29,11 +31,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.filled.NotificationsNone -import androidx.compose.material.icons.filled.NotificationsOff -import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -332,7 +329,7 @@ private fun AlertsEmptyMark(listening: Boolean) { drawAlertsEmptyMark(if (animate) progress.value else null, mark) } Icon( - if (listening) Icons.Default.NotificationsNone else Icons.Default.NotificationsOff, + if (listening) MembyIcon.NotificationNone.mark else MembyIcon.NotificationOff.mark, contentDescription = null, tint = mark, modifier = Modifier @@ -498,8 +495,8 @@ private fun AlertRow( } private fun alertIcon(kind: String): ImageVector = when { - kind.contains("return", ignoreCase = true) -> Icons.Default.Tv - kind.contains("series", ignoreCase = true) -> Icons.Default.Tv - kind.startsWith("show-", ignoreCase = true) -> Icons.Default.Tv - else -> Icons.Default.NotificationsActive + kind.contains("return", ignoreCase = true) -> MembyIcon.Tv.mark + kind.contains("series", ignoreCase = true) -> MembyIcon.Tv.mark + kind.startsWith("show-", ignoreCase = true) -> MembyIcon.Tv.mark + else -> MembyIcon.NotificationActive.mark } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt index b2798b3..b4635df 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt @@ -2,6 +2,8 @@ package com.ponzischeme89.memby.ui.calendar +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -25,11 +27,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Event import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -232,7 +229,7 @@ private fun AgendaHeader( Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape), contentAlignment = Alignment.Center, ) { - Icon(Icons.Default.CalendarMonth, null, tint = MembyAccent, modifier = Modifier.size(21.dp)) + Icon(MembyIcon.Calendar.mark, null, tint = MembyAccent, modifier = Modifier.size(21.dp)) } Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { @@ -246,7 +243,7 @@ private fun AgendaHeader( modifier = Modifier.padding(end = 14.dp), ) AgendaIconButton( - icon = Icons.AutoMirrored.Filled.ArrowBack, + icon = MembyIcon.ArrowBack.mark, description = "Previous month", enabled = previousMonth.isNotEmpty(), onClick = { onShowMonth(previousMonth) }, @@ -262,7 +259,7 @@ private fun AgendaHeader( ) Spacer(Modifier.width(8.dp)) AgendaIconButton( - icon = Icons.AutoMirrored.Filled.ArrowForward, + icon = MembyIcon.ArrowForward.mark, description = "Next month", enabled = nextMonth.isNotEmpty(), onClick = { onShowMonth(nextMonth) }, @@ -303,7 +300,7 @@ private fun WeekSwitcher( ) { Row(verticalAlignment = Alignment.CenterVertically) { AgendaIconButton( - Icons.AutoMirrored.Filled.ArrowBack, + MembyIcon.ArrowBack.mark, "Previous week", weekIndex > 0, onPrevious, @@ -326,7 +323,7 @@ private fun WeekSwitcher( .padding(horizontal = 18.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Default.Event, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(16.dp)) + Icon(MembyIcon.Event.mark, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(8.dp)) Text( week.label, @@ -344,7 +341,7 @@ private fun WeekSwitcher( } Spacer(Modifier.width(10.dp)) AgendaIconButton( - Icons.AutoMirrored.Filled.ArrowForward, + MembyIcon.ArrowForward.mark, "Next week", weekIndex < weekCount - 1, onNext, @@ -498,7 +495,7 @@ private fun ProgrammePane( contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Default.Event, null, tint = MembyQuietText, modifier = Modifier.size(30.dp)) + Icon(MembyIcon.Event.mark, null, tint = MembyQuietText, modifier = Modifier.size(30.dp)) Spacer(Modifier.height(8.dp)) Text("Nothing airing", color = MembyMutedText, fontSize = 15.sp) Text("Choose another day or week", color = MembyQuietText, fontSize = 12.sp) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 6e41594..687aa6c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -2,6 +2,8 @@ package com.ponzischeme89.memby.ui.genre +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -34,23 +36,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.HelpOutline -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Category -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Gavel -import androidx.compose.material.icons.filled.Landscape -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.LocalFireDepartment -import androidx.compose.material.icons.filled.MilitaryTech -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.SentimentVerySatisfied -import androidx.compose.material.icons.filled.SportsSoccer -import androidx.compose.material.icons.filled.TheaterComedy -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -188,23 +173,23 @@ private fun GenreDiscoveryCard( private data class GenreVisual(val icon: ImageVector, val colour: Color) private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) { - GenreCategoryIcon.ALL -> GenreVisual(Icons.Default.Category, Color(0xFF4F46A5)) - GenreCategoryIcon.ACTION -> GenreVisual(Icons.Default.Bolt, Color(0xFFB45309)) - GenreCategoryIcon.COMEDY -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF15803D)) - GenreCategoryIcon.CRIME -> GenreVisual(Icons.Default.Gavel, Color(0xFF475569)) - GenreCategoryIcon.DRAMA -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF7E22CE)) - GenreCategoryIcon.HORROR -> GenreVisual(Icons.Default.LocalFireDepartment, Color(0xFF991B1B)) - GenreCategoryIcon.MYSTERY -> GenreVisual(Icons.AutoMirrored.Filled.HelpOutline, Color(0xFF4338CA)) - GenreCategoryIcon.SCI_FI -> GenreVisual(Icons.Default.AutoAwesome, Color(0xFF0369A1)) - GenreCategoryIcon.THRILLER -> GenreVisual(Icons.Default.VisibilityOff, Color(0xFF0F766E)) - GenreCategoryIcon.WAR -> GenreVisual(Icons.Default.MilitaryTech, Color(0xFF57534E)) - GenreCategoryIcon.FAMILY -> GenreVisual(Icons.Default.SentimentVerySatisfied, Color(0xFFDB2777)) - GenreCategoryIcon.DOCUMENTARY -> GenreVisual(Icons.Default.VideoLibrary, Color(0xFF0E7490)) - GenreCategoryIcon.ROMANCE -> GenreVisual(Icons.Default.Favorite, Color(0xFFBE185D)) - GenreCategoryIcon.WESTERN -> GenreVisual(Icons.Default.Landscape, Color(0xFF92400E)) - GenreCategoryIcon.MUSIC -> GenreVisual(Icons.Default.MusicNote, Color(0xFF6D28D9)) - GenreCategoryIcon.SPORT -> GenreVisual(Icons.Default.SportsSoccer, Color(0xFF047857)) - GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C)) + GenreCategoryIcon.ALL -> GenreVisual(MembyIcon.Category.mark, Color(0xFF4F46A5)) + GenreCategoryIcon.ACTION -> GenreVisual(MembyIcon.Bolt.mark, Color(0xFFB45309)) + GenreCategoryIcon.COMEDY -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF15803D)) + GenreCategoryIcon.CRIME -> GenreVisual(MembyIcon.Gavel.mark, Color(0xFF475569)) + GenreCategoryIcon.DRAMA -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF7E22CE)) + GenreCategoryIcon.HORROR -> GenreVisual(MembyIcon.Fire.mark, Color(0xFF991B1B)) + GenreCategoryIcon.MYSTERY -> GenreVisual(MembyIcon.Help.mark, Color(0xFF4338CA)) + GenreCategoryIcon.SCI_FI -> GenreVisual(MembyIcon.Sparkle.mark, Color(0xFF0369A1)) + GenreCategoryIcon.THRILLER -> GenreVisual(MembyIcon.HideWatched.mark, Color(0xFF0F766E)) + GenreCategoryIcon.WAR -> GenreVisual(MembyIcon.Trophy.mark, Color(0xFF57534E)) + GenreCategoryIcon.FAMILY -> GenreVisual(MembyIcon.Happy.mark, Color(0xFFDB2777)) + GenreCategoryIcon.DOCUMENTARY -> GenreVisual(MembyIcon.VideoLibrary.mark, Color(0xFF0E7490)) + GenreCategoryIcon.ROMANCE -> GenreVisual(MembyIcon.Favourite.mark, Color(0xFFBE185D)) + GenreCategoryIcon.WESTERN -> GenreVisual(MembyIcon.Landscape.mark, Color(0xFF92400E)) + GenreCategoryIcon.MUSIC -> GenreVisual(MembyIcon.Music.mark, Color(0xFF6D28D9)) + GenreCategoryIcon.SPORT -> GenreVisual(MembyIcon.Football.mark, Color(0xFF047857)) + GenreCategoryIcon.REALITY -> GenreVisual(MembyIcon.LiveTv.mark, Color(0xFFC2410C)) } /** diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt index 373b6f5..49bd022 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui.requests +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -16,13 +18,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material.icons.filled.Tv import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -188,20 +183,20 @@ internal fun RequestCard( ) } if (busy) { - RequestTrailing(icon = Icons.Default.Schedule, label = "Asking", focused = focused) + RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused) } else if (action != null) { - RequestTrailing(icon = action.icon, label = action.label, focused = focused) + RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused) } } } } /** The trailing affordance on a card: what the centre button would do. */ -internal data class RequestCardAction(val icon: ImageVector, val label: String) +internal data class RequestCardAction(val icon: MembyIcon, val label: String) -internal val RequestActionRequest = RequestCardAction(Icons.Default.Add, "Request") -internal val RequestActionPlay = RequestCardAction(Icons.Default.PlayArrow, "Watch") -internal val RequestActionRemove = RequestCardAction(Icons.Default.CheckCircle, "Remove") +internal val RequestActionRequest = RequestCardAction(MembyIcon.Add, "Request") +internal val RequestActionPlay = RequestCardAction(MembyIcon.Play, "Watch") +internal val RequestActionRemove = RequestCardAction(MembyIcon.CheckCircle, "Remove") @Composable private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) { @@ -287,7 +282,7 @@ internal fun requestToneColour(status: String): Color = when (requestStatusTone( */ @Composable private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) { - val icon = if (mediaType == "series") Icons.Default.Tv else Icons.Default.Movie + val icon = if (mediaType == "series") MembyIcon.Tv.mark else MembyIcon.Movie.mark Box( modifier .size(24.dp) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt index 8a3d957..9c53272 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestsScreen.kt @@ -2,6 +2,8 @@ package com.ponzischeme89.memby.ui.requests +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,10 +26,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Inbox -import androidx.compose.material.icons.filled.PlaylistAdd -import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -171,7 +169,7 @@ fun RequestsScreen( // Permission withdrawn while the page was open. Said plainly, because an // empty list would read as "you have never asked for anything". !state.allowed -> RequestsNotice( - icon = Icons.Default.Inbox, + icon = MembyIcon.Inbox.mark, heading = "Requests are not available", body = "This profile is no longer allowed to request titles. Ask whoever looks after Memby.", ) @@ -227,7 +225,7 @@ private fun RequestsHeader(state: RequestsUiState) { contentAlignment = Alignment.Center, ) { Icon( - Icons.Default.PlaylistAdd, null, + MembyIcon.PlaylistAdd.mark, null, tint = MembyAccent, modifier = Modifier.size(21.dp), ) } @@ -321,7 +319,7 @@ private fun RequestsTabStrip( verticalAlignment = Alignment.CenterVertically, ) { Icon( - if (tab == RequestsTab.MINE) Icons.Default.Inbox else Icons.Default.Search, + if (tab == RequestsTab.MINE) MembyIcon.Inbox.mark else MembyIcon.Search.mark, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(15.dp), @@ -361,7 +359,7 @@ private fun MyRequestsPane( } if (state.requestsError != null && state.requests.isEmpty()) { RequestsNotice( - icon = Icons.Default.Inbox, + icon = MembyIcon.Inbox.mark, heading = "Could not load your requests", body = state.requestsError, action = "Try again", @@ -375,7 +373,7 @@ private fun MyRequestsPane( } if (state.requests.isEmpty()) { RequestsNotice( - icon = Icons.Default.Inbox, + icon = MembyIcon.Inbox.mark, heading = "You have not asked for anything yet", body = "Find a film or series and it will show up here while it is added to your library.", action = "Request something", @@ -762,7 +760,7 @@ private fun PaneMessage(heading: String, body: String) { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 40.dp), ) { - Icon(Icons.Default.Search, null, tint = MembyQuietText, modifier = Modifier.size(30.dp)) + Icon(MembyIcon.Search.mark, null, tint = MembyQuietText, modifier = Modifier.size(30.dp)) Spacer(Modifier.height(8.dp)) Text(heading, color = MembyMutedText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(4.dp)) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index 0331cdb..302e1cf 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui.screensaver +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable @@ -22,19 +24,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronLeft -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.AccessTime -import androidx.compose.material.icons.filled.Business -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.LocalOffer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -838,12 +827,12 @@ private fun InfoAndActions( verticalAlignment = Alignment.CenterVertically, ) { Button(onClick = onPlay, modifier = Modifier.focusRequester(playFocus)) { - Icon(Icons.Default.PlayArrow, contentDescription = null) + Icon(MembyIcon.Play.mark, contentDescription = null) Text(text = " Play trailer", modifier = Modifier.padding(start = 4.dp)) } Button(onClick = onToggleFavorite) { Icon( - imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + imageVector = if (isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, contentDescription = null, ) Text( @@ -852,13 +841,13 @@ private fun InfoAndActions( ) } Spacer(Modifier.weight(1f)) - Button(onClick = onPrev) { Icon(Icons.Default.ChevronLeft, contentDescription = "Previous") } - Button(onClick = onNext) { Icon(Icons.Default.ChevronRight, contentDescription = "Next") } + Button(onClick = onPrev) { Icon(MembyIcon.ChevronLeft.mark, contentDescription = "Previous") } + Button(onClick = onNext) { Icon(MembyIcon.ChevronRight.mark, contentDescription = "Next") } Button(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + Icon(MembyIcon.Settings.mark, contentDescription = "Settings") } Button(onClick = onExit) { - Icon(Icons.Default.Close, contentDescription = "Exit screensaver") + Icon(MembyIcon.Close.mark, contentDescription = "Exit screensaver") } } } @@ -933,7 +922,7 @@ private fun MediaMetadata(item: BaseItem) { val metadataColor = Color(0xFFC7CED4) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon( - imageVector = if (item.isSeries) Icons.Default.LiveTv else Icons.Default.Movie, + imageVector = if (item.isSeries) MembyIcon.LiveTv.mark else MembyIcon.Movie.mark, contentDescription = if (item.isSeries) "Series" else "Movie", tint = metadataColor, modifier = Modifier.size(20.dp), @@ -941,15 +930,15 @@ private fun MediaMetadata(item: BaseItem) { item.productionYear?.let { MetadataText(it.toString(), metadataColor) } item.communityRating?.let { MetadataText("★ ${"%.1f".format(it)}", metadataColor) } item.runtimeMinutes?.let { - Icon(Icons.Default.AccessTime, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp)) + Icon(MembyIcon.Clock.mark, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp)) MetadataText("$it min", metadataColor) } item.studios.firstOrNull { it.name.isNotBlank() }?.name?.let { - Icon(Icons.Default.Business, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp)) + Icon(MembyIcon.Studio.mark, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp)) MetadataText(it, metadataColor) } item.genres.firstOrNull()?.takeIf { it.isNotBlank() }?.let { - Icon(Icons.Default.LocalOffer, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp)) + Icon(MembyIcon.Tag.mark, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp)) MetadataText(it, metadataColor) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index a4b3cc6..dd31d8e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -2,6 +2,8 @@ package com.ponzischeme89.memby.ui.search +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import android.Manifest import android.app.Activity import android.content.Intent @@ -41,21 +43,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.LiveTv -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.PlayCircleFilled -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SentimentVerySatisfied -import androidx.compose.material.icons.filled.SpaceBar -import androidx.compose.material.icons.filled.TheaterComedy import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -461,7 +448,7 @@ internal fun SearchQueryField( verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Default.Search, + MembyIcon.Search.mark, contentDescription = null, tint = if (query.isEmpty()) Muted else Accent, modifier = Modifier.size(18.dp), @@ -505,7 +492,7 @@ internal fun SearchQueryField( modifier = micModifier.clip(RoundedCornerShape(8.dp)), ) { focused -> Icon( - Icons.Default.Mic, + MembyIcon.Mic.mark, contentDescription = null, tint = if (focused) KeyLabelFocused else KeyLabel, modifier = Modifier @@ -608,7 +595,7 @@ internal fun TvKeyboard( Spacer(Modifier.height(2.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { ActionKey( - icon = Icons.Default.SpaceBar, + icon = MembyIcon.Space.mark, label = "Space", contentDescription = "Insert a space", onClick = { onCharacter(" ") }, @@ -626,7 +613,7 @@ internal fun TvKeyboard( ), ) ActionKey( - icon = Icons.AutoMirrored.Filled.Backspace, + icon = MembyIcon.Backspace.mark, label = "Delete", contentDescription = "Delete the last character", onClick = onBackspace, @@ -643,7 +630,7 @@ internal fun TvKeyboard( ), ) ActionKey( - icon = Icons.Default.Close, + icon = MembyIcon.Close.mark, label = "Clear", contentDescription = "Clear the whole query", onClick = onClear, @@ -671,7 +658,7 @@ internal fun TvKeyboard( if (onSearch != null) { Spacer(Modifier.height(if (compact) 4.dp else 6.dp)) ActionKey( - icon = Icons.Default.Search, + icon = MembyIcon.Search.mark, label = "Search", contentDescription = "Search for what you have typed", onClick = onSearch, @@ -961,7 +948,7 @@ private fun ResultsHeading( contentAlignment = Alignment.Center, ) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, + MembyIcon.ArrowBack.mark, contentDescription = null, tint = if (focused) KeyLabelFocused else Heading, modifier = Modifier.size(21.dp), @@ -1145,7 +1132,7 @@ private fun RequestOptions( .background(Accent.copy(alpha = 0.16f)), contentAlignment = Alignment.Center, ) { - Icon(Icons.Default.Add, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp)) + Icon(MembyIcon.Add.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp)) } Spacer(Modifier.width(13.dp)) Column(Modifier.weight(1f)) { @@ -1207,7 +1194,7 @@ private fun RequestOptions( verticalAlignment = Alignment.CenterVertically, ) { Icon( - if (state.requestMessageIsError) Icons.Default.Close else Icons.Default.CheckCircle, + if (state.requestMessageIsError) MembyIcon.Close.mark else MembyIcon.CheckCircle.mark, contentDescription = null, tint = statusColor, modifier = Modifier.size(18.dp), @@ -1288,7 +1275,7 @@ private fun RequestCandidateCard( ) } else { Icon( - if (mediaLabel == "MOVIE") Icons.Default.Movie else Icons.Default.LiveTv, + if (mediaLabel == "MOVIE") MembyIcon.Movie.mark else MembyIcon.LiveTv.mark, contentDescription = null, tint = Muted.copy(alpha = 0.65f), modifier = Modifier.size(30.dp), @@ -1323,7 +1310,7 @@ private fun RequestCandidateCard( Spacer(Modifier.weight(1f)) Row(verticalAlignment = Alignment.CenterVertically) { if (candidate.inLibrary || candidate.alreadyAdded) { - Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp)) + Icon(MembyIcon.CheckCircle.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp)) Spacer(Modifier.width(5.dp)) } Text( @@ -1394,12 +1381,12 @@ private val GenreColors = listOf( ) private fun genreIcon(label: String): ImageVector = when { - label.contains("comedy", true) -> Icons.Default.TheaterComedy - label.contains("music", true) -> Icons.Default.LiveTv - label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied - label.contains("sport", true) -> Icons.Default.PlayCircleFilled - label.contains("document", true) -> Icons.Default.Movie - else -> Icons.Default.AutoAwesome + label.contains("comedy", true) -> MembyIcon.Drama.mark + label.contains("music", true) -> MembyIcon.LiveTv.mark + label.contains("children", true) || label.contains("family", true) -> MembyIcon.Happy.mark + label.contains("sport", true) -> MembyIcon.PlayCircle.mark + label.contains("document", true) -> MembyIcon.Movie.mark + else -> MembyIcon.Sparkle.mark } @Composable diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index b63a031..3543f7e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -8,6 +8,8 @@ package com.ponzischeme89.memby.ui.settings +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -38,13 +40,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Storage import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -187,20 +182,20 @@ private const val THEME_PICKER_ENABLED = false internal enum class SettingsPage( val label: String, val description: String, - val icon: ImageVector, + val icon: MembyIcon, val showInRail: Boolean = true, ) { - APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette), - PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow), - HOME("Home screen", "What you see when Memby opens", Icons.Default.Home), + APPEARANCE("Appearance", "How Memby looks", MembyIcon.Palette), + PLAYBACK("Playback", "What happens while you watch", MembyIcon.Play), + HOME("Home screen", "What you see when Memby opens", MembyIcon.Home), // No Updates page. The manual check needs a Gitea address, a repository and a token, // and nothing on this television can enter them — so the button could only ever report // a failure, on the one screen a viewer goes to when they suspect something is wrong. // Updates arrive through the gateway's own verdict (ui/UpdateScreen.kt), which carries // its download URL with it. - DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices), - STORAGE("Storage", "Artwork Memby keeps on this TV", Icons.Default.Storage), - ABOUT("About", "Version and release notes", Icons.Default.Info), + DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices), + STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage), + ABOUT("About", "Version and release notes", MembyIcon.Info), } // Black, and one lit thing at a time. @@ -1518,7 +1513,7 @@ private fun SettingsSecondaryRail( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( - page.icon, + page.icon.mark, contentDescription = null, tint = when { focused -> Canvas diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt new file mode 100644 index 0000000..bf30471 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt @@ -0,0 +1,144 @@ +package com.ponzischeme89.memby.ui.theme + +import com.composables.icons.fontawesome.FontAwesome +import com.composables.icons.fontawesome.solid.AngleDoubleLeft +import com.composables.icons.fontawesome.solid.ArrowDown +import com.composables.icons.fontawesome.solid.ArrowLeft +import com.composables.icons.fontawesome.solid.ArrowRight +import com.composables.icons.fontawesome.solid.ArrowUp +import com.composables.icons.fontawesome.solid.Backspace +import com.composables.icons.fontawesome.solid.Bell +import com.composables.icons.fontawesome.solid.BellSlash +import com.composables.icons.fontawesome.solid.Bolt +import com.composables.icons.fontawesome.solid.Bookmark +import com.composables.icons.fontawesome.solid.BroadcastTower +import com.composables.icons.fontawesome.solid.Building +import com.composables.icons.fontawesome.solid.CalendarAlt +import com.composables.icons.fontawesome.solid.CalendarDay +import com.composables.icons.fontawesome.solid.Check +import com.composables.icons.fontawesome.solid.CheckCircle +import com.composables.icons.fontawesome.solid.CheckDouble +import com.composables.icons.fontawesome.solid.ChevronDown +import com.composables.icons.fontawesome.solid.ChevronLeft +import com.composables.icons.fontawesome.solid.ChevronRight +import com.composables.icons.fontawesome.solid.Clock +import com.composables.icons.fontawesome.solid.Cog +import com.composables.icons.fontawesome.solid.Desktop +import com.composables.icons.fontawesome.solid.EyeSlash +import com.composables.icons.fontawesome.solid.Film +import com.composables.icons.fontawesome.solid.Fire +import com.composables.icons.fontawesome.solid.Futbol +import com.composables.icons.fontawesome.solid.Gavel +import com.composables.icons.fontawesome.solid.Hdd +import com.composables.icons.fontawesome.solid.Heart +import com.composables.icons.fontawesome.solid.Home +import com.composables.icons.fontawesome.solid.Image +import com.composables.icons.fontawesome.solid.Inbox +import com.composables.icons.fontawesome.solid.InfoCircle +import com.composables.icons.fontawesome.solid.Magic +import com.composables.icons.fontawesome.solid.Medal +import com.composables.icons.fontawesome.solid.Microphone +import com.composables.icons.fontawesome.solid.MinusCircle +import com.composables.icons.fontawesome.solid.Moon +import com.composables.icons.fontawesome.solid.Mountain +import com.composables.icons.fontawesome.solid.Music +import com.composables.icons.fontawesome.solid.Palette +import com.composables.icons.fontawesome.solid.PhotoVideo +import com.composables.icons.fontawesome.solid.Play +import com.composables.icons.fontawesome.solid.PlayCircle +import com.composables.icons.fontawesome.solid.Plus +import com.composables.icons.fontawesome.solid.PlusCircle +import com.composables.icons.fontawesome.solid.PowerOff +import com.composables.icons.fontawesome.solid.QuestionCircle +import com.composables.icons.fontawesome.solid.Search +import com.composables.icons.fontawesome.solid.Shapes +import com.composables.icons.fontawesome.solid.Smile +import com.composables.icons.fontawesome.solid.Sun +import com.composables.icons.fontawesome.solid.Tag +import com.composables.icons.fontawesome.solid.ThLarge +import com.composables.icons.fontawesome.solid.TheaterMasks +import com.composables.icons.fontawesome.solid.ThumbsUp +import com.composables.icons.fontawesome.solid.Thumbtack +import com.composables.icons.fontawesome.solid.Times +import com.composables.icons.fontawesome.solid.Tv +import com.composables.icons.fontawesome.solid.User +import com.composables.icons.fontawesome.solid.Wrench + +/** + * Font Awesome Solid — filled throughout, and the pack to reach for on a set watched from + * the sofa. Stroke sets are drawn for 16–24px on a monitor; at three metres on a 38dp rail + * chip they read thin and washed, where a solid mark keeps its shape. + * + * The outline halves are the ones missing here, the mirror image of Lucide's omission and + * for the same reason. + */ +internal val fontAwesomeIconPack = MembyIconPack( + MEMBY_ICON_PACK_FONT_AWESOME, + mapOf( + MembyIcon.Home to { FontAwesome.Solid.Home }, + MembyIcon.Search to { FontAwesome.Solid.Search }, + MembyIcon.Grid to { FontAwesome.Solid.ThLarge }, + MembyIcon.Movie to { FontAwesome.Solid.Film }, + MembyIcon.Tv to { FontAwesome.Solid.Tv }, + MembyIcon.LiveTv to { FontAwesome.Solid.BroadcastTower }, + MembyIcon.VideoLibrary to { FontAwesome.Solid.PhotoVideo }, + MembyIcon.Person to { FontAwesome.Solid.User }, + MembyIcon.Settings to { FontAwesome.Solid.Cog }, + MembyIcon.Calendar to { FontAwesome.Solid.CalendarAlt }, + MembyIcon.Event to { FontAwesome.Solid.CalendarDay }, + MembyIcon.Play to { FontAwesome.Solid.Play }, + MembyIcon.PlayCircle to { FontAwesome.Solid.PlayCircle }, + MembyIcon.Favourite to { FontAwesome.Solid.Heart }, + MembyIcon.Bookmark to { FontAwesome.Solid.Bookmark }, + MembyIcon.PlaylistAdd to { FontAwesome.Solid.PlusCircle }, + MembyIcon.PlaylistRemove to { FontAwesome.Solid.MinusCircle }, + MembyIcon.HideWatched to { FontAwesome.Solid.EyeSlash }, + MembyIcon.Check to { FontAwesome.Solid.Check }, + MembyIcon.CheckCircle to { FontAwesome.Solid.CheckCircle }, + MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble }, + MembyIcon.Add to { FontAwesome.Solid.Plus }, + MembyIcon.Close to { FontAwesome.Solid.Times }, + MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft }, + MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight }, + MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown }, + MembyIcon.ArrowBack to { FontAwesome.Solid.ArrowLeft }, + MembyIcon.ArrowForward to { FontAwesome.Solid.ArrowRight }, + MembyIcon.ArrowUp to { FontAwesome.Solid.ArrowUp }, + MembyIcon.ArrowDown to { FontAwesome.Solid.ArrowDown }, + MembyIcon.FirstPage to { FontAwesome.Solid.AngleDoubleLeft }, + MembyIcon.Backspace to { FontAwesome.Solid.Backspace }, + MembyIcon.Sparkle to { FontAwesome.Solid.Magic }, + MembyIcon.Recommend to { FontAwesome.Solid.ThumbsUp }, + MembyIcon.Drama to { FontAwesome.Solid.TheaterMasks }, + MembyIcon.Happy to { FontAwesome.Solid.Smile }, + MembyIcon.Music to { FontAwesome.Solid.Music }, + MembyIcon.Football to { FontAwesome.Solid.Futbol }, + MembyIcon.Trophy to { FontAwesome.Solid.Medal }, + MembyIcon.Fire to { FontAwesome.Solid.Fire }, + MembyIcon.Tag to { FontAwesome.Solid.Tag }, + MembyIcon.Category to { FontAwesome.Solid.Shapes }, + MembyIcon.Studio to { FontAwesome.Solid.Building }, + MembyIcon.Landscape to { FontAwesome.Solid.Mountain }, + MembyIcon.Palette to { FontAwesome.Solid.Palette }, + MembyIcon.Notification to { FontAwesome.Solid.Bell }, + MembyIcon.NotificationActive to { FontAwesome.Solid.Bell }, + MembyIcon.NotificationOff to { FontAwesome.Solid.BellSlash }, + MembyIcon.Inbox to { FontAwesome.Solid.Inbox }, + MembyIcon.Pin to { FontAwesome.Solid.Thumbtack }, + MembyIcon.Bolt to { FontAwesome.Solid.Bolt }, + MembyIcon.Clock to { FontAwesome.Solid.Clock }, + MembyIcon.Schedule to { FontAwesome.Solid.Clock }, + MembyIcon.Sun to { FontAwesome.Solid.Sun }, + MembyIcon.Sunrise to { FontAwesome.Solid.Sun }, + MembyIcon.Night to { FontAwesome.Solid.Moon }, + MembyIcon.Info to { FontAwesome.Solid.InfoCircle }, + MembyIcon.Help to { FontAwesome.Solid.QuestionCircle }, + MembyIcon.BrokenImage to { FontAwesome.Solid.Image }, + MembyIcon.Devices to { FontAwesome.Solid.Desktop }, + MembyIcon.Storage to { FontAwesome.Solid.Hdd }, + MembyIcon.Build to { FontAwesome.Solid.Wrench }, + MembyIcon.Power to { FontAwesome.Solid.PowerOff }, + MembyIcon.Gavel to { FontAwesome.Solid.Gavel }, + MembyIcon.Mic to { FontAwesome.Solid.Microphone }, + ), +) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt new file mode 100644 index 0000000..d8a2f6d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt @@ -0,0 +1,146 @@ +package com.ponzischeme89.memby.ui.theme + +import com.composables.icons.lucide.ArrowDown +import com.composables.icons.lucide.ArrowLeft +import com.composables.icons.lucide.ArrowRight +import com.composables.icons.lucide.ArrowUp +import com.composables.icons.lucide.Zap +import com.composables.icons.lucide.Bell +import com.composables.icons.lucide.BellOff +import com.composables.icons.lucide.BellRing +import com.composables.icons.lucide.Bookmark +import com.composables.icons.lucide.Building2 +import com.composables.icons.lucide.CalendarClock +import com.composables.icons.lucide.CalendarDays +import com.composables.icons.lucide.Check +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.ChevronLeft +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.ChevronsLeft +import com.composables.icons.lucide.CirclePlay +import com.composables.icons.lucide.CircleQuestionMark +import com.composables.icons.lucide.Clock +import com.composables.icons.lucide.Delete +import com.composables.icons.lucide.Drama +import com.composables.icons.lucide.EyeOff +import com.composables.icons.lucide.Film +import com.composables.icons.lucide.Flame +import com.composables.icons.lucide.Gavel +import com.composables.icons.lucide.HardDrive +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.House +import com.composables.icons.lucide.ImageOff +import com.composables.icons.lucide.Inbox +import com.composables.icons.lucide.Info +import com.composables.icons.lucide.LayoutGrid +import com.composables.icons.lucide.LibraryBig +import com.composables.icons.lucide.ListMinus +import com.composables.icons.lucide.ListPlus +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Mic +import com.composables.icons.lucide.MonitorSpeaker +import com.composables.icons.lucide.MoonStar +import com.composables.icons.lucide.MountainSnow +import com.composables.icons.lucide.Music2 +import com.composables.icons.lucide.Palette +import com.composables.icons.lucide.Pin +import com.composables.icons.lucide.Play +import com.composables.icons.lucide.Plus +import com.composables.icons.lucide.Power +import com.composables.icons.lucide.Radio +import com.composables.icons.lucide.Search +import com.composables.icons.lucide.Settings +import com.composables.icons.lucide.Shapes +import com.composables.icons.lucide.Smile +import com.composables.icons.lucide.Space +import com.composables.icons.lucide.Sparkles +import com.composables.icons.lucide.Sun +import com.composables.icons.lucide.Sunrise +import com.composables.icons.lucide.Tag +import com.composables.icons.lucide.ThumbsUp +import com.composables.icons.lucide.Trophy +import com.composables.icons.lucide.Tv +import com.composables.icons.lucide.User +import com.composables.icons.lucide.Volleyball +import com.composables.icons.lucide.Wrench +import com.composables.icons.lucide.X + +/** + * Lucide — one weight, drawn as strokes. + * + * The filled halves of the state-bearing pairs are deliberately **not** mapped. Lucide is a + * stroke set with no filled heart or bookmark, so mapping [MembyIcon.Favourite] to the same + * glyph as [MembyIcon.FavouriteOutline] would make "this is a favourite" and "this is not" + * identical on screen — a pack cannot be allowed to cost the app a distinction. They fall + * back to Material's filled marks, which is a small mixture and the honest one. + */ +internal val lucideIconPack = MembyIconPack( + MEMBY_ICON_PACK_LUCIDE, + mapOf( + MembyIcon.Home to { Lucide.House }, + MembyIcon.Search to { Lucide.Search }, + MembyIcon.Grid to { Lucide.LayoutGrid }, + MembyIcon.Movie to { Lucide.Film }, + MembyIcon.Tv to { Lucide.Tv }, + MembyIcon.LiveTv to { Lucide.Radio }, + MembyIcon.VideoLibrary to { Lucide.LibraryBig }, + MembyIcon.Person to { Lucide.User }, + MembyIcon.Settings to { Lucide.Settings }, + MembyIcon.Calendar to { Lucide.CalendarDays }, + MembyIcon.Event to { Lucide.CalendarClock }, + MembyIcon.Play to { Lucide.Play }, + MembyIcon.PlayCircle to { Lucide.CirclePlay }, + MembyIcon.FavouriteOutline to { Lucide.Heart }, + MembyIcon.BookmarkOutline to { Lucide.Bookmark }, + MembyIcon.PlaylistAdd to { Lucide.ListPlus }, + MembyIcon.PlaylistRemove to { Lucide.ListMinus }, + MembyIcon.HideWatched to { Lucide.EyeOff }, + MembyIcon.Check to { Lucide.Check }, + MembyIcon.Add to { Lucide.Plus }, + MembyIcon.Close to { Lucide.X }, + MembyIcon.ChevronLeft to { Lucide.ChevronLeft }, + MembyIcon.ChevronRight to { Lucide.ChevronRight }, + MembyIcon.ChevronDown to { Lucide.ChevronDown }, + MembyIcon.ArrowBack to { Lucide.ArrowLeft }, + MembyIcon.ArrowForward to { Lucide.ArrowRight }, + MembyIcon.ArrowUp to { Lucide.ArrowUp }, + MembyIcon.ArrowDown to { Lucide.ArrowDown }, + MembyIcon.FirstPage to { Lucide.ChevronsLeft }, + MembyIcon.Backspace to { Lucide.Delete }, + MembyIcon.Space to { Lucide.Space }, + MembyIcon.Sparkle to { Lucide.Sparkles }, + MembyIcon.Recommend to { Lucide.ThumbsUp }, + MembyIcon.Drama to { Lucide.Drama }, + MembyIcon.Happy to { Lucide.Smile }, + MembyIcon.Music to { Lucide.Music2 }, + MembyIcon.Football to { Lucide.Volleyball }, + MembyIcon.Trophy to { Lucide.Trophy }, + MembyIcon.Fire to { Lucide.Flame }, + MembyIcon.Tag to { Lucide.Tag }, + MembyIcon.Category to { Lucide.Shapes }, + MembyIcon.Studio to { Lucide.Building2 }, + MembyIcon.Landscape to { Lucide.MountainSnow }, + MembyIcon.Palette to { Lucide.Palette }, + MembyIcon.Notification to { Lucide.Bell }, + MembyIcon.NotificationActive to { Lucide.BellRing }, + MembyIcon.NotificationNone to { Lucide.Bell }, + MembyIcon.NotificationOff to { Lucide.BellOff }, + MembyIcon.Inbox to { Lucide.Inbox }, + MembyIcon.Pin to { Lucide.Pin }, + MembyIcon.Bolt to { Lucide.Zap }, + MembyIcon.Clock to { Lucide.Clock }, + MembyIcon.Schedule to { Lucide.Clock }, + MembyIcon.Sun to { Lucide.Sun }, + MembyIcon.Sunrise to { Lucide.Sunrise }, + MembyIcon.Night to { Lucide.MoonStar }, + MembyIcon.Info to { Lucide.Info }, + MembyIcon.Help to { Lucide.CircleQuestionMark }, + MembyIcon.BrokenImage to { Lucide.ImageOff }, + MembyIcon.Devices to { Lucide.MonitorSpeaker }, + MembyIcon.Storage to { Lucide.HardDrive }, + MembyIcon.Build to { Lucide.Wrench }, + MembyIcon.Power to { Lucide.Power }, + MembyIcon.Gavel to { Lucide.Gavel }, + MembyIcon.Mic to { Lucide.Mic }, + ), +) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt new file mode 100644 index 0000000..8773a6c --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt @@ -0,0 +1,202 @@ +package com.ponzischeme89.memby.ui.theme + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.FirstPage +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.LocalOffer +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MilitaryTech +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.NightsStay +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SentimentVerySatisfied +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SpaceBar +import androidx.compose.material.icons.filled.SportsSoccer +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * The packs a theme may name, and the marks each of them draws. + * + * Adding a pack is one entry in [membyIconPacks] and one map here — no call site changes, + * because every screen names a [MembyIcon] and nothing names a pack. That is the property + * the whole feature is for: an operator moves a household onto a different set of marks by + * editing the gateway's theme catalogue, and no television is touched. + * + * Only the ~70 slots named below survive R8 out of packs holding a thousand icons each, so + * this file is also the size bound: a slot costs three vectors, and a slot nothing draws + * costs three vectors for nothing. + */ +object MaterialIconPack { + /** + * Every slot, because this is what a partial pack falls back to. A slot added to + * [MembyIcon] and not to this map would draw nothing at all — which is why + * `MembyIconPackTest` asserts the coverage rather than leaving it to a code review. + */ + private val marks: Map ImageVector> = mapOf( + MembyIcon.Home to { Icons.Default.Home }, + MembyIcon.Search to { Icons.Default.Search }, + MembyIcon.Grid to { Icons.Default.GridView }, + MembyIcon.Movie to { Icons.Default.Movie }, + MembyIcon.Tv to { Icons.Default.Tv }, + MembyIcon.LiveTv to { Icons.Default.LiveTv }, + MembyIcon.VideoLibrary to { Icons.Default.VideoLibrary }, + MembyIcon.Person to { Icons.Default.Person }, + MembyIcon.Settings to { Icons.Default.Settings }, + MembyIcon.Calendar to { Icons.Default.CalendarMonth }, + MembyIcon.Event to { Icons.Default.Event }, + MembyIcon.Play to { Icons.Default.PlayArrow }, + MembyIcon.PlayCircle to { Icons.Default.PlayCircleFilled }, + MembyIcon.Favourite to { Icons.Default.Favorite }, + MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder }, + MembyIcon.Bookmark to { Icons.Default.Bookmark }, + MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder }, + MembyIcon.PlaylistAdd to { Icons.Default.PlaylistAdd }, + MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove }, + MembyIcon.HideWatched to { Icons.Default.VisibilityOff }, + MembyIcon.Check to { Icons.Default.Check }, + MembyIcon.CheckCircle to { Icons.Default.CheckCircle }, + MembyIcon.CheckAll to { Icons.Default.DoneAll }, + MembyIcon.Add to { Icons.Default.Add }, + MembyIcon.Close to { Icons.Default.Close }, + MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft }, + MembyIcon.ChevronRight to { Icons.Default.ChevronRight }, + MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown }, + MembyIcon.ArrowBack to { Icons.AutoMirrored.Filled.ArrowBack }, + MembyIcon.ArrowForward to { Icons.AutoMirrored.Filled.ArrowForward }, + MembyIcon.ArrowUp to { Icons.Default.ArrowUpward }, + MembyIcon.ArrowDown to { Icons.Default.ArrowDownward }, + MembyIcon.FirstPage to { Icons.Default.FirstPage }, + MembyIcon.Backspace to { Icons.AutoMirrored.Filled.Backspace }, + MembyIcon.Space to { Icons.Default.SpaceBar }, + MembyIcon.Sparkle to { Icons.Default.AutoAwesome }, + MembyIcon.Recommend to { Icons.Default.Recommend }, + MembyIcon.Drama to { Icons.Default.TheaterComedy }, + MembyIcon.Happy to { Icons.Default.SentimentVerySatisfied }, + MembyIcon.Music to { Icons.Default.MusicNote }, + MembyIcon.Football to { Icons.Default.SportsSoccer }, + MembyIcon.Trophy to { Icons.Default.MilitaryTech }, + MembyIcon.Fire to { Icons.Default.LocalFireDepartment }, + MembyIcon.Tag to { Icons.Default.LocalOffer }, + MembyIcon.Category to { Icons.Default.Category }, + MembyIcon.Studio to { Icons.Default.Business }, + MembyIcon.Landscape to { Icons.Default.Landscape }, + MembyIcon.Palette to { Icons.Default.Palette }, + MembyIcon.Notification to { Icons.Default.Notifications }, + MembyIcon.NotificationActive to { Icons.Default.NotificationsActive }, + MembyIcon.NotificationNone to { Icons.Default.NotificationsNone }, + MembyIcon.NotificationOff to { Icons.Default.NotificationsOff }, + MembyIcon.Inbox to { Icons.Default.Inbox }, + MembyIcon.Pin to { Icons.Default.PushPin }, + MembyIcon.Bolt to { Icons.Default.Bolt }, + MembyIcon.Clock to { Icons.Default.AccessTime }, + MembyIcon.Schedule to { Icons.Default.Schedule }, + MembyIcon.Sun to { Icons.Default.WbSunny }, + MembyIcon.Sunrise to { Icons.Default.LightMode }, + MembyIcon.Night to { Icons.Default.NightsStay }, + MembyIcon.Info to { Icons.Default.Info }, + MembyIcon.Help to { Icons.AutoMirrored.Filled.HelpOutline }, + MembyIcon.BrokenImage to { Icons.Default.BrokenImage }, + MembyIcon.Devices to { Icons.Default.Devices }, + MembyIcon.Storage to { Icons.Default.Storage }, + MembyIcon.Build to { Icons.Default.Build }, + MembyIcon.Power to { Icons.Default.PowerSettingsNew }, + MembyIcon.Gavel to { Icons.Default.Gavel }, + MembyIcon.Mic to { Icons.Default.Mic }, + ) + + /** The marks the app shipped with, and the floor every other pack stands on. */ + val pack = MembyIconPack(MEMBY_ICON_PACK_MATERIAL, marks) + + internal fun fallback(slot: MembyIcon): ImageVector = + marks.getValue(slot).invoke() +} + +const val MEMBY_ICON_PACK_MATERIAL = "material" +const val MEMBY_ICON_PACK_LUCIDE = "lucide" +const val MEMBY_ICON_PACK_FONT_AWESOME = "fontawesome" + +/** + * Every pack this build can draw, by the slug the gateway names it with. + * + * The list is the client's whole side of the contract. A gateway naming a pack this + * television has never heard of is an ordinary event — an operator has added one and this + * set has not been updated — so [membyIconPackFor] answers with the marks the app shipped + * with rather than with nothing. That is the same stance an unknown decoration slug and an + * unparseable hex already take: the worst a theme does is look unchanged. + */ +private val membyIconPacks: Map = listOf( + MaterialIconPack.pack, + lucideIconPack, + fontAwesomeIconPack, +).associateBy { it.id } + +/** + * The pack for a slug, or the app's own marks. + * + * Pure, and tested — the slug arrives off a wire, and a blank one (a gateway that predates + * this, a theme that expresses no opinion) has to mean "leave the marks alone" rather than + * "draw nothing". + */ +fun membyIconPackFor(slug: String?): MembyIconPack = + membyIconPacks[slug?.trim()?.lowercase().orEmpty()] ?: MaterialIconPack.pack + +/** The slugs this build understands. Read by the tests and by Settings → About. */ +val membyIconPackIds: List get() = membyIconPacks.keys.toList() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt new file mode 100644 index 0000000..53709d6 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt @@ -0,0 +1,165 @@ +package com.ponzischeme89.memby.ui.theme + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * The marks this app draws, named by what they mean rather than by who drew them. + * + * This is `DesignTokens.kt` for icons, and it exists for the same reason. A colour scheme + * could not reach the launcher while a hundred literal hexes were written at the call + * sites, and an icon pack could not reach it while seventy `Icons.Default.*` were: the + * gateway can only change what the television has a *slot* for. Naming a slot after the + * Material identifier it happens to hold today (`AutoAwesome`, `SentimentVerySatisfied`) + * would put that back — a pack whose recommendation mark is a wand rather than four stars + * would be filed under a name that lies about it. So the slots are named for the job: + * [Sparkle], [Happy], [Drama]. + * + * What a slot is *worth* is the other half of the rule. A slot is added when a screen needs + * a mark, never so that a pack can show off a glyph — the gateway names a pack and nothing + * else, so an unused slot is dead weight in three maps at once. + */ +enum class MembyIcon { + // Navigation and destinations + Home, + Search, + Grid, + Movie, + Tv, + LiveTv, + VideoLibrary, + Person, + Settings, + Calendar, + Event, + + // Playback and library actions + Play, + PlayCircle, + Favourite, + FavouriteOutline, + Bookmark, + BookmarkOutline, + PlaylistAdd, + PlaylistRemove, + HideWatched, + + // Affirmation + Check, + CheckCircle, + CheckAll, + Add, + Close, + + // Movement + ChevronLeft, + ChevronRight, + ChevronDown, + ArrowBack, + ArrowForward, + ArrowUp, + ArrowDown, + FirstPage, + Backspace, + Space, + + // Row and genre character + Sparkle, + Recommend, + Drama, + Happy, + Music, + Football, + Trophy, + Fire, + Tag, + Category, + Studio, + Landscape, + Palette, + + // News + Notification, + NotificationActive, + NotificationNone, + NotificationOff, + Inbox, + Pin, + Bolt, + + // Time of day and time itself + Clock, + Schedule, + Sun, + Sunrise, + Night, + + // Status and diagnosis + Info, + Help, + BrokenImage, + Devices, + Storage, + Build, + Power, + Gavel, + Mic, +} + +/** + * One pack's answer for every slot it has a mark for. + * + * The marks are **lambdas, not vectors**, because an `ImageVector` is built the first time + * it is read and a map of them would build all seventy on the first frame that touched the + * pack — on the cold start, which is the one thing in this app nothing is allowed to cost. + * Held this way a pack costs one map of function references, and a mark is built when a + * screen actually draws it. + * + * A pack may be **partial**, and that is deliberate rather than tolerated. Solid sets have + * no honest filled form of a chevron or a tick, and a pack forced to name one would either + * block the pack from ever being offered or put a poor mark on the rail. An absent slot + * falls back to [MaterialIconPack], so the worst a pack does is look unchanged in places — + * the same stance [parseThemeColor] takes on a hex string it cannot read. + */ +@Stable +class MembyIconPack( + /** The slug the gateway names this pack by. */ + val id: String, + private val marks: Map ImageVector>, +) { + /** Which slots this pack draws itself. Read by the tests that pin pack coverage. */ + val slots: Set get() = marks.keys + + internal fun markFor(slot: MembyIcon): ImageVector = + marks[slot]?.invoke() ?: MaterialIconPack.fallback(slot) +} + +private val currentIconPack = mutableStateOf(MaterialIconPack.pack) + +/** + * Repaints every mark in the app. + * + * The state is process-wide and read through [mark] below, so this is the icon half of + * [applyMembyPalette] and arrives by the same road: a theme revision the television does + * not hold, fetched by `ThemeSync`. + */ +fun applyMembyIconPack(pack: MembyIconPack) { + if (currentIconPack.value.id != pack.id) currentIconPack.value = pack +} + +/** The pack in force. Exposed for the settings screen's own description of it. */ +val membyIconPackId: String get() = currentIconPack.value.id + +/** + * The mark to draw for this slot, under whatever pack the gateway last resolved. + * + * Reading this in a composable is what subscribes that composable to a pack change, which + * is the whole delivery mechanism — and the reason nothing may hold the result. A mark + * captured in an `enum` constant or a `val` freezes the pack that was in force when its + * class initialised, which is exactly the `val`-versus-`get()` trap that made + * `SettingsSheet` the one screen a palette could never reach. Types that carry a mark + * around (a rail destination, a row's visual) carry the [MembyIcon] and resolve it where + * they draw. + */ +val MembyIcon.mark: ImageVector get() = currentIconPack.value.markFor(this) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/theme/IconPackScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/theme/IconPackScreenshotTest.kt new file mode 100644 index 0000000..69ca3f3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/theme/IconPackScreenshotTest.kt @@ -0,0 +1,152 @@ +package com.ponzischeme89.memby.ui.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Every mark of every pack, to `build/screenshots/icon-packs/`. + * + * ```powershell + * .\gradlew.bat :app:testDebugUnitTest --tests "*IconPackScreenshotTest" + * ``` + * + * This is the only test that can judge the feature, for the reason `ThemeScreenshotTest` + * gives about palettes: a unit test can check that a pack names a mark for a slot, and it + * cannot check whether that mark *means* the slot. Nothing would have caught Lucide's + * volleyball standing in for football, or a stroke set going invisible at rail size, except + * looking — and a pack nobody has looked at is a pack pushed to a household on faith. + * + * Both sizes are captured on purpose and the small one is the point. 21dp is what the + * navigation rail actually draws, and a stroke set has least to spare there — every pack + * looks fine in the large grid, so that is not where a thin one would be caught. The strip + * along the bottom is the same nine marks in the accent, because a rail is read as a column + * of coloured glyphs rather than as isolated shapes on black. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class IconPackScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + /** + * The pack is process-wide state — the whole reason one answer from the gateway + * repaints every marked thing in the app — so a test that left one applied would draw + * the wrong marks into every screenshot taken after it in the same JVM. + */ + @After + fun unpack() { + applyMembyIconPack(MaterialIconPack.pack) + } + + @Test + fun `every pack, at reading size`() { + sweep("labelled") { Sheet(size = 28.dp, labelled = true) } + } + + @Test + fun `every pack, at the size the rail draws them`() { + sweep("rail-size") { Sheet(size = 21.dp, labelled = false) } + } + + /** + * Composes the sheet **once** and repaints it by swapping the pack, which is both the + * only thing `setContent` allows and the more honest picture: it is exactly what a + * television already showing a screen does the moment the gateway resolves a different + * pack, rather than what a set that happened to start up under one does. + */ + private fun sweep(suffix: String, content: @Composable () -> Unit) { + compose.setContent(content) + membyIconPackIds.forEach { id -> + applyMembyIconPack(membyIconPackFor(id)) + compose.onRoot().captureRoboImage("build/screenshots/icon-packs/$id-$suffix.png") + } + } + + @Composable + private fun Sheet(size: androidx.compose.ui.unit.Dp, labelled: Boolean) { + Box(Modifier.fillMaxSize().background(MembySurface)) { + LazyVerticalGrid( + columns = GridCells.Fixed(if (labelled) 9 else 14), + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(if (labelled) 10.dp else 14.dp), + ) { + items(MembyIcon.entries.toList()) { slot -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + slot.mark, + contentDescription = null, + tint = MembyOnSurface, + modifier = Modifier.size(size), + ) + if (labelled) { + Text( + slot.name, + color = MembyQuietText, + fontSize = 7.sp, + textAlign = TextAlign.Center, + modifier = Modifier.width(96.dp).padding(top = 3.dp), + ) + } + } + } + } + // The rail is the case the small sheet exists for, so it is on the same image + // rather than in one nobody opens: an accent-tinted strip at the exact size and + // spacing TvNavigationRail draws. + if (!labelled) { + Row( + Modifier.align(Alignment.BottomStart).padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + listOf( + MembyIcon.Home, MembyIcon.Search, MembyIcon.Movie, MembyIcon.Tv, + MembyIcon.Sparkle, MembyIcon.Calendar, MembyIcon.Favourite, + MembyIcon.Person, MembyIcon.Settings, + ).forEach { + Icon(it.mark, null, tint = MembyAccent, modifier = Modifier.size(21.dp)) + } + } + } + } + } + +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/theme/MembyIconPackTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/theme/MembyIconPackTest.kt new file mode 100644 index 0000000..7ddf029 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/theme/MembyIconPackTest.kt @@ -0,0 +1,71 @@ +package com.ponzischeme89.memby.ui.theme + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The two things about icon packs that can break silently. + * + * Neither is visible at a code review and both look identical to a working feature from the + * outside: a slot Material does not draw is a blank space on one screen somewhere, and a + * slug that does not resolve is a television that quietly ignores what the gateway told it. + */ +class MembyIconPackTest { + + @Test + fun `material draws every slot`() { + // The floor every partial pack stands on. A slot added to MembyIcon and forgotten + // here would draw nothing at all — and only on whichever screen happens to use it, + // which is exactly the kind of gap that ships. + val missing = MembyIcon.entries.filterNot { it in MaterialIconPack.pack.slots } + assertTrue("Material has no mark for $missing", missing.isEmpty()) + } + + @Test + fun `a partial pack is legal and falls back rather than drawing nothing`() { + // Lucide is a stroke set, so it deliberately declines the filled halves of the + // state-bearing pairs. What matters is that declining costs a mark, never a screen. + val lucide = membyIconPackFor(MEMBY_ICON_PACK_LUCIDE) + assertTrue( + "Lucide should decline the filled favourite", + MembyIcon.Favourite !in lucide.slots, + ) + MembyIcon.entries.forEach { slot -> + assertNotNull("no mark resolved for $slot", lucide.markFor(slot)) + } + } + + @Test + fun `an unreadable slug leaves the marks the app shipped with`() { + // Every one of these is an ordinary event rather than a fault: a gateway that + // predates the feature sends nothing, a theme that expresses no opinion sends + // empty, and an operator may add a pack to the catalogue before this build knows + // it. None of them may produce a launcher drawn with no marks. + listOf(null, "", " ", "tabler", "Material ", "LUCIDE").forEach { slug -> + val pack = membyIconPackFor(slug) + when (slug?.trim()?.lowercase()) { + MEMBY_ICON_PACK_LUCIDE -> assertEquals(MEMBY_ICON_PACK_LUCIDE, pack.id) + MEMBY_ICON_PACK_MATERIAL -> assertEquals(MEMBY_ICON_PACK_MATERIAL, pack.id) + else -> assertSame( + "unknown slug $slug should fall back to the shipped marks", + MaterialIconPack.pack, + pack, + ) + } + } + } + + @Test + fun `the slugs are the ones the gateway sends`() { + // The client's whole half of the wire contract. `internal/api/themes.go` names the + // same three, and its own test pins them from that end — change one without the + // other and a household gets the marks it did not ask for, silently. + assertEquals( + listOf("material", "lucide", "fontawesome").sorted(), + membyIconPackIds.sorted(), + ) + } +} diff --git a/deploy-server.ps1 b/deploy-server.ps1 index 4501c1b..cf97c7c 100644 --- a/deploy-server.ps1 +++ b/deploy-server.ps1 @@ -28,6 +28,14 @@ by this script. This deploys the current local working tree, including uncommitted server changes. Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published. +Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The +console is its own container with its own health check and nothing depends on it, so +only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one +container is restarted. The gateway, PostgreSQL and Redis keep running throughout, +.env and docker-compose.yml are left exactly as deployed, no APK is built, and no +television is told anything because nothing they use goes away. It amends an existing +deployment and refuses to create one. + .EXAMPLE .\deploy-server.ps1 @@ -48,6 +56,12 @@ Use -SkipAppRelease for an admin/server-only deployment: no APK is built or publ .EXAMPLE .\deploy-server.ps1 -SkipAppRelease + +.EXAMPLE +.\deploy-server.ps1 --Admin + +.EXAMPLE +.\deploy-server.ps1 -AdminOnly #> #Requires -Version 7.2 @@ -87,6 +101,15 @@ param( [Parameter()] [switch] $SkipAppRelease, + # Replace the operations console and nothing else. The console is its own container + # with its own health check and nothing depends on it, so it can be rebuilt and + # restarted while the gateway, PostgreSQL and Redis keep running — a console change is + # then seconds rather than the several minutes a full stack swap costs, and no + # television notices anything at all. + [Parameter()] + [Alias('Admin')] + [switch] $AdminOnly, + [Parameter()] [Alias('m')] [switch] $MandatoryUpdate, @@ -113,20 +136,41 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } -$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') }) +$unknownFlags = @($trailingFlags | Where-Object { + $_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin') +}) if ($unknownFlags.Count -gt 0) { throw "Unknown deployment option: $($unknownFlags -join ', ')" } $mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m' $quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet' +$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin' -if ($mandatoryRelease -and $SkipAppRelease) { - throw '--m cannot be combined with -SkipAppRelease because no update would be published.' +if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) { + throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.' } +# Nothing about the gateway is rebuilt, so there is nothing for a television to be told +# about. Accepting --Quiet here would imply the announcement was a choice on this path. +if ($consoleOnly -and $quietDeployment) { + throw '--Quiet has no meaning with --Admin: no televisions are affected, so none are told.' +} +# The console-only path never packages an APK. Stated rather than silently ignored, so a +# combined invocation cannot look as though it published one. +if ($consoleOnly) { + $SkipAppRelease = $true +} + +# Timings are kept per kind of deployment, because the two are not the same operation +# measured twice: a console run rebuilds one small image and a full one swaps the whole +# stack. Averaged together, each estimate would be wrong for both. A record written before +# this existed carries no kind and is read as 'full', which is what all of them were. +$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' } $script:CurrentStep = 0 $script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 } -$script:RemoteStepCount = 10 +# A console deployment skips the .env install, the dependency pull, the gateway build, the +# stack swap and the APK publish: what is left is check, extract, swap, build, restart, wait. +$script:RemoteStepCount = if ($consoleOnly) { 6 } else { 10 } $script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount $script:PhaseOrder = @( 'prerequisites', @@ -142,7 +186,9 @@ $script:FallbackSeconds = @{ 'payload' = 4 'android-release' = 230 'packaging' = 12 - 'remote-deployment' = 330 + # A console deployment uploads one small build context and rebuilds one image, where a + # full one ships the Go tree as well and rebuilds the whole stack. + 'remote-deployment' = if ($consoleOnly) { 90 } else { 330 } } $script:PhaseDurations = [ordered]@{} $script:CurrentPhaseKey = $null @@ -205,6 +251,9 @@ function Import-DeploymentHistory { function Get-PhaseHistory { param([Parameter(Mandatory)][string] $Key) $values = foreach ($run in $script:History) { + $kindProperty = $run.PSObject.Properties['kind'] + $kind = if ($kindProperty -and $kindProperty.Value) { [string]$kindProperty.Value } else { 'full' } + if ($kind -ne $script:DeploymentKind) { continue } $phasesProperty = $run.PSObject.Properties['phases'] if (-not $phasesProperty -or -not $phasesProperty.Value) { continue } $property = $phasesProperty.Value.PSObject.Properties[$Key] @@ -360,6 +409,7 @@ function Save-DeploymentHistory { durationSeconds = [Math]::Round($DurationSeconds, 2) phases = $script:PhaseDurations appRelease = -not [bool]$SkipAppRelease + kind = $script:DeploymentKind } $runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30 $directory = Split-Path -Parent $script:HistoryPath @@ -607,7 +657,13 @@ function New-DeploymentArchive { [string] $ArchivePath, [Parameter()] - [string] $ReleaseDirectory + [string] $ReleaseDirectory, + + # Pack the console's build context alone. Everything else the remote side needs — + # the Compose file, the environment — is already deployed and is deliberately left + # alone, so it must not be shipped and cannot be changed by accident. + [Parameter()] + [switch] $ConsoleOnly ) # Build artefacts left in either build context are paid for on the wire. Both @@ -620,9 +676,12 @@ function New-DeploymentArchive { '--exclude', 'server/bin', '--exclude', 'server/bin/*', '--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*', '--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*', - '-C', $RepositoryDirectory, - 'server', 'admin-ui', 'docker-compose.yml', '.env.example' - ) + '-C', $RepositoryDirectory + ) + $(if ($ConsoleOnly) { + @('admin-ui') + } else { + @('server', 'admin-ui', 'docker-compose.yml', '.env.example') + }) if ($ReleaseDirectory) { $arguments += @( '-C', (Split-Path -Parent $ReleaseDirectory), @@ -770,27 +829,39 @@ try { Write-Success "Using $checkoutDirectory" Write-Host '' - Write-Step 'Validating the Compose deployment payload' -Key 'payload' - $requiredPaths = @( - (Join-Path $checkoutDirectory 'server'), - (Join-Path $checkoutDirectory 'server/Dockerfile'), - (Join-Path $checkoutDirectory 'server/go.mod'), + Write-Step $(if ($consoleOnly) { + 'Validating the console deployment payload' + } else { + 'Validating the Compose deployment payload' + }) -Key 'payload' + $consolePaths = @( (Join-Path $checkoutDirectory 'admin-ui'), (Join-Path $checkoutDirectory 'admin-ui/Dockerfile'), (Join-Path $checkoutDirectory 'admin-ui/package.json'), - (Join-Path $checkoutDirectory 'admin-ui/src'), + (Join-Path $checkoutDirectory 'admin-ui/src') + ) + # A console deployment reuses the deployed docker-compose.yml and .env rather than + # shipping its own. That is the whole safety of it: the running gateway's configuration + # is left exactly as it was, so nothing can be changed here that would need it to + # restart to take effect. + $requiredPaths = $consolePaths + $(if ($consoleOnly) { @() } else { @( + (Join-Path $checkoutDirectory 'server'), + (Join-Path $checkoutDirectory 'server/Dockerfile'), + (Join-Path $checkoutDirectory 'server/go.mod'), (Join-Path $checkoutDirectory 'docker-compose.yml'), (Join-Path $checkoutDirectory '.env.example') - ) + ) }) foreach ($requiredPath in $requiredPaths) { if (-not (Test-Path -LiteralPath $requiredPath)) { throw "Required deployment file is missing: $requiredPath" } } - Write-Detail 'server/ build context' Write-Detail 'admin-ui/ build context' - Write-Detail 'docker-compose.yml' - Write-Detail '.env.example' + if (-not $consoleOnly) { + Write-Detail 'server/ build context' + Write-Detail 'docker-compose.yml' + Write-Detail '.env.example' + } $releaseDirectory = '' $releaseVersion = '' $releaseSHA256 = '' @@ -882,34 +953,20 @@ try { Write-Host '' } - Write-Step 'Packaging the release' -Key 'packaging' + Write-Step $(if ($consoleOnly) { 'Packaging the console' } else { 'Packaging the release' }) -Key 'packaging' New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath ` - -ReleaseDirectory $releaseDirectory + -ReleaseDirectory $releaseDirectory -ConsoleOnly:$consoleOnly $archiveSize = (Get-Item -LiteralPath $archivePath).Length Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB)) Write-Success 'Release archive is ready' Write-Host '' - # This template is single-quoted so PowerShell does not expand the remote - # shell's variables. Replacement values are validated before insertion. - $remoteCommand = @' -set -eu - -destination='__DESTINATION__' -health_timeout=__HEALTH_TIMEOUT__ -publish_release=__PUBLISH_RELEASE__ -mandatory_update=__MANDATORY_UPDATE__ -quiet_deployment=__QUIET_DEPLOYMENT__ -colour_output=__COLOUR_OUTPUT__ -remote_step_offset=__REMOTE_STEP_OFFSET__ -total_steps=__TOTAL_STEPS__ -parent=$(dirname "$destination") -staging="${destination}.new.$$" -backup="${destination}.previous.$$" -activated=0 -previous_stopped=0 -remote_step=0 - + # The reporting shared by both remote scripts. Kept in one place because the step + # counter, the wording and the health wait are what make a deployment readable, and two + # copies of them are two things to keep in step. Every template that uses this defines + # colour_output, remote_step, remote_step_offset, total_steps and health_timeout above + # the point it is inserted. + $remoteHelpers = @' step() { remote_step=$((remote_step + 1)) overall_step=$((remote_step_offset + remote_step)) @@ -932,6 +989,217 @@ failure() { if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi } +wait_for_service() { + service="$1" + elapsed=0 + detail "Waiting for $service" + + while [ "$elapsed" -lt "$health_timeout" ]; do + container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true) + + if [ -n "$container_id" ]; then + state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true) + health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true) + + if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then + success "$service is $state ($health)" + return 0 + fi + + if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then + failure "$service entered state: $state" + docker compose logs --no-color --tail 60 "$service" || true + return 1 + fi + fi + + sleep 2 + elapsed=$((elapsed + 2)) + if [ $((elapsed % 10)) -eq 0 ]; then + remaining=$((health_timeout - elapsed)) + detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining" + fi + done + + failure "$service did not become healthy within ${health_timeout}s" + docker compose logs --no-color --tail 60 "$service" || true + return 1 +} +'@ + + # Replacing the console alone. + # + # The console is its own container: nothing depends on it, it holds no state and it has + # a health check of its own, so it can be rebuilt and restarted while the gateway, + # PostgreSQL and Redis carry on serving. That is what makes this path safe enough to be + # worth having — and it is only safe while it stays this narrow. Three things it must + # never do: touch .env or docker-compose.yml (the running gateway's configuration would + # then differ from the file describing it, with no restart to reconcile them), name any + # service but memby-admin, or omit --no-deps (Compose would otherwise be free to + # recreate the gateway as a dependency and take the house down for a CSS change). + $consoleRemoteCommand = @' +set -eu + +destination='__DESTINATION__' +health_timeout=__HEALTH_TIMEOUT__ +colour_output=__COLOUR_OUTPUT__ +remote_step_offset=__REMOTE_STEP_OFFSET__ +total_steps=__TOTAL_STEPS__ +# Staged and backed up *inside* the deployment directory, so the swap is a rename on one +# filesystem rather than a copy that can be interrupted half way. +staging="${destination}/admin-ui.new.$$" +backup="${destination}/admin-ui.previous.$$" +swapped=0 +remote_step=0 + +__SHELL_HELPERS__ + +restore_console() { + status=$? + trap - EXIT + + if [ "$status" -eq 0 ]; then + return + fi + + failure 'Console deployment failed; cleaning up' + rm -rf -- "$staging" + + if [ "$swapped" -eq 1 ] && [ -d "$backup" ]; then + detail 'Restoring the previous console' + rm -rf -- "$destination/admin-ui" + mv -- "$backup" "$destination/admin-ui" + # Rebuilt as well as restored: the image that is running is the one that was just + # built from the files being thrown away, so putting the directory back without + # rebuilding would leave the NAS serving exactly what failed. + ( + cd "$destination" + docker compose build memby-admin >/dev/null 2>&1 && + docker compose up -d --no-deps --no-build memby-admin >/dev/null 2>&1 + ) || true + failure 'Previous console files were restored' + fi + + exit "$status" +} + +trap restore_console EXIT +trap 'exit 130' INT TERM + +step 'Checking Docker and the deployed stack' +if ! command -v docker >/dev/null 2>&1; then + failure 'Docker is not installed on the NAS' + exit 1 +fi +docker info >/dev/null 2>&1 || { + failure 'Docker is installed but the daemon is unavailable' + exit 1 +} +# A console deployment amends a deployment that already exists. It cannot create one: +# there is no .env and no Compose file in this archive, by design. +if [ ! -f "$destination/docker-compose.yml" ]; then + failure "No deployment found at $destination" + detail 'Run a full deployment first; --Admin only replaces the console of an existing one' + exit 1 +fi +if ! (cd "$destination" && docker compose config --services 2>/dev/null | grep -qx 'memby-admin'); then + failure 'The deployed docker-compose.yml has no memby-admin service' + detail 'That release predates the separate console; run a full deployment' + exit 1 +fi +success "$(docker --version)" +success 'Existing deployment found' + +step 'Extracting the console' +rm -rf -- "$staging" +mkdir -- "$staging" +tar -xf - -C "$staging" +test -f "$staging/admin-ui/Dockerfile" +test -f "$staging/admin-ui/package.json" +test -d "$staging/admin-ui/src" +success 'Console sources extracted' + +step 'Replacing the console files' +rm -rf -- "$backup" +if [ -d "$destination/admin-ui" ]; then + mv -- "$destination/admin-ui" "$backup" +fi +mv -- "$staging/admin-ui" "$destination/admin-ui" +rm -rf -- "$staging" +swapped=1 +success 'Console files replaced' + +step 'Building the console image' +# Built before anything is restarted, so a console that does not compile leaves the one +# that is running untouched. The type check runs inside this build. +( + cd "$destination" + docker compose build memby-admin +) +success 'Console image built' + +step 'Restarting the console' +( + cd "$destination" + # --no-deps is what keeps this to one container; --no-build because it was just built. + if ! docker compose up -d --no-deps --no-build memby-admin; then + failure 'Compose could not start the console' + docker compose logs --no-color --tail 60 memby-admin || true + exit 1 + fi +) +success 'Console container restarted' + +step 'Waiting for the console' +cd "$destination" +wait_for_service memby-admin + +# The gateway is what serves /admin, and it was never restarted — so this is a check that +# the console is reachable the way an operator actually reaches it, not merely that its own +# container is up. +if command -v curl >/dev/null 2>&1; then + console_status=$(curl --silent --show-error --max-time 5 \ + --output /dev/null --write-out '%{http_code}' \ + http://127.0.0.1:32768/admin/ 2>/dev/null || true) + case "$console_status" in + 2??|3??) success 'Console is being served through the gateway' ;; + *) detail "Console health is good but the gateway returned HTTP ${console_status:-no response} for /admin/" ;; + esac +fi + +printf '\n' +docker compose ps +printf '\n' + +rm -rf -- "$backup" +swapped=0 +trap - EXIT INT TERM +success 'Console deployment is healthy' +success 'Memby console: https://mserver.sublogue.com/admin/' +'@ + + # This template is single-quoted so PowerShell does not expand the remote + # shell's variables. Replacement values are validated before insertion. + $remoteCommand = @' +set -eu + +destination='__DESTINATION__' +health_timeout=__HEALTH_TIMEOUT__ +publish_release=__PUBLISH_RELEASE__ +mandatory_update=__MANDATORY_UPDATE__ +quiet_deployment=__QUIET_DEPLOYMENT__ +colour_output=__COLOUR_OUTPUT__ +remote_step_offset=__REMOTE_STEP_OFFSET__ +total_steps=__TOTAL_STEPS__ +parent=$(dirname "$destination") +staging="${destination}.new.$$" +backup="${destination}.previous.$$" +activated=0 +previous_stopped=0 +remote_step=0 + +__SHELL_HELPERS__ + start_restored_stack() { docker compose up -d --build --remove-orphans >/dev/null 2>&1 } @@ -977,43 +1245,6 @@ rollback() { exit "$status" } -wait_for_service() { - service="$1" - elapsed=0 - detail "Waiting for $service" - - while [ "$elapsed" -lt "$health_timeout" ]; do - container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true) - - if [ -n "$container_id" ]; then - state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true) - health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true) - - if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then - success "$service is $state ($health)" - return 0 - fi - - if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then - failure "$service entered state: $state" - docker compose logs --no-color --tail 60 "$service" || true - return 1 - fi - fi - - sleep 2 - elapsed=$((elapsed + 2)) - if [ $((elapsed % 10)) -eq 0 ]; then - remaining=$((health_timeout - elapsed)) - detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining" - fi - done - - failure "$service did not become healthy within ${health_timeout}s" - docker compose logs --no-color --tail 60 "$service" || true - return 1 -} - trap rollback EXIT trap 'exit 130' INT TERM @@ -1320,6 +1551,9 @@ success 'Remote deployment is healthy' success 'Memby gateway: https://mserver.sublogue.com' '@ + if ($consoleOnly) { $remoteCommand = $consoleRemoteCommand } + # The helpers go in before anything else, so a token inside them is substituted too. + $remoteCommand = $remoteCommand.Replace('__SHELL_HELPERS__', $remoteHelpers) $remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination) $remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString()) $remoteCommand = $remoteCommand.Replace( @@ -1346,8 +1580,15 @@ success 'Memby gateway: https://mserver.sublogue.com' # script is normalised to LF here rather than depending on how it was saved. $remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n") - Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange + Start-DeploymentPhase -Key 'remote-deployment' -Message $(if ($consoleOnly) { + "Deploying the console to $RemoteHost" + } else { + "Deploying to $RemoteHost" + }) -RemoteRange Write-Detail 'One SSH password prompt will appear' + if ($consoleOnly) { + Write-Detail 'The gateway, PostgreSQL and Redis keep running throughout' + } Write-Detail 'Remote build output follows; its steps continue the overall counter' Write-Host '' Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand @@ -1357,8 +1598,17 @@ success 'Memby gateway: https://mserver.sublogue.com' Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed } Write-Host '' - Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed) - Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White + Write-Success ($(if ($consoleOnly) { + "Console deployment complete in {0:mm\:ss}" + } else { + "Deployment complete in {0:mm\:ss}" + }) -f $deploymentTimer.Elapsed) + if ($consoleOnly) { + Write-Styled -Message ' Memby console: https://mserver.sublogue.com/admin/' -Colour White + Write-Styled -Message ' Gateway: untouched and still serving' -Colour Gray + } else { + Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White + } Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray if (-not $SkipAppRelease) { diff --git a/docker-compose.yml b/docker-compose.yml index 171e5e5..4a239fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,11 +68,25 @@ services: MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}" MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}" MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}" + # The webhook Sonarr and Radarr push into, which is how the catalogue learns a file + # landed rather than waiting for the hourly sweep. Empty is what makes each hook 404, + # so a token missing *here* is indistinguishable from one never configured at all — + # every variable the gateway reads has to be named in this list to reach it. + MEMBY_SONARR_WEBHOOK_TOKEN: "${MEMBY_SONARR_WEBHOOK_TOKEN:-}" + MEMBY_SONARR_ALERT_WINDOW: "${MEMBY_SONARR_ALERT_WINDOW:-3h}" + # How long after a webhook the gateway first looks for the file in Emby. + MEMBY_ARR_INGEST_SETTLE: "${MEMBY_ARR_INGEST_SETTLE:-1m}" # Optional read-only Radarr calendar integration. Only digital release dates # appear in the five-day movie row. MEMBY_RADARR_URL: "${MEMBY_RADARR_URL:-}" MEMBY_RADARR_API_KEY: "${MEMBY_RADARR_API_KEY:-}" MEMBY_RADARR_TTL: "${MEMBY_RADARR_TTL:-5m}" + MEMBY_RADARR_WEBHOOK_TOKEN: "${MEMBY_RADARR_WEBHOOK_TOKEN:-}" + MEMBY_RADARR_ALERT_WINDOW: "${MEMBY_RADARR_ALERT_WINDOW:-3h}" + # The reachability probe behind the outage bar and its two banners. 0 turns it off. + MEMBY_EMBY_HEALTH_INTERVAL: "${MEMBY_EMBY_HEALTH_INTERVAL:-60s}" + # Optional overrides for the recommendation scorer. Blank keeps the built-in weights. + MEMBY_RECOMMENDATION_WEIGHTS: "${MEMBY_RECOMMENDATION_WEIGHTS:-}" # Optional Tracearr public API. Memby reads recent playback analytics to rank # the dedicated For You area; the token never leaves this container. MEMBY_TRACEARR_URL: "${MEMBY_TRACEARR_URL:-}" diff --git a/server/README.md b/server/README.md index d4969de..5ae2aa5 100644 --- a/server/README.md +++ b/server/README.md @@ -429,6 +429,56 @@ favourites and resume positions are per-user and cannot be shared across a house they still come from Emby live. The imported copy powers search and the recommendation candidate pool. +### Event-driven ingest + +Sonarr and Radarr are the things that put files on disk, so they are what the catalogue +learns from. Both post to the gateway, each event is recorded in `library_ingest_queue`, +and a single worker reads the named title out of Emby a minute later — rather than the +whole catalogue waiting on the next sweep. An episode imported at 19:05 is searchable at +19:06 instead of as late as 20:00. + +| | | +|---|---| +| Sonarr | `POST /hooks/sonarr?token=$MEMBY_SONARR_WEBHOOK_TOKEN` | +| Radarr | `POST /hooks/radarr?token=$MEMBY_RADARR_WEBHOOK_TOKEN` | + +In each *arr: **Settings → Connect → + → Webhook**, method POST, with **On Import, On +Upgrade, On Rename, On File Delete** and **On Series/Movie Delete** ticked. The token may +also be sent as `X-Memby-Token`, a bearer token or basic-auth password; an unset token +makes the hook 404, so a deployment that never configured one cannot be posted to. Press +**Test** to check reachability — it answers 200 and records nothing. + +Both hooks sit outside the maintenance gate *and* outside the quiet-time gate, which is +the point of the queue being durable: the gate answers 503 and neither *arr re-delivers, +so a quiet hour would otherwise discard every import that happened during it. The hook +records at any hour; the worker is where quiet time is honoured. + +**What each event does.** An import or an upgrade re-reads the item — an upgrade is silent +as *news*, because the film was already there, but the file genuinely changed. A rename is +a refresh and never an invalidation: the Emby item id survives a move, and so does the +credits marker measured against it. A delete removes the row and its credits marker, and +only counts when the media went with it — a series unfollowed in Sonarr with its files +left on disk is still in the library. + +**Nothing is done twice.** The queue key is derived from the *file* rather than the +delivery, so a repeated webhook collapses onto one row; a file deleted and re-imported is +a different file and its own work. Emby not having scanned a new file yet is the expected +first answer rather than a fault: one rescan nudge is sent and the row retries on a +widening backoff (1m, 5m, 20m, 1h, then four-hourly) before being given up on. + +**The sweep is reconciliation now.** `MEMBY_SYNC_INTERVAL` still runs the incremental +import, and with both webhooks wired up it exists for what the *arrs do not manage — a +file dropped in by hand, a title edited in Emby, a notification that never arrived because +the container was down. Six hours is a sensible value then; the console can set it at +**Settings → Catalogue sweep** without a redeployment, and it takes effect on the next +cycle rather than at the next restart. + +**Where to look.** Admin console → **Imports** shows whether each hook is configured, what +is waiting, and the last fifty events with why each was queued and what happened to it — +which is the page to read when somebody says a new episode is not showing up. In the log +it is `event=arr_ingest` with an `outcome` of `queued`, `imported`, `removed`, `absent`, +`not_found` or `deferred`. + ## Maintenance mode Takes Memby down independently of Emby: all `/v1` routes answer `503` with diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index 429a151..e0e92ba 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -167,6 +167,20 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro DeviceID: "memby-gateway-sync", Gateway: true, }, log.With("component", "library")) + + // Sonarr and Radarr are what put files on disk, so they are what the catalogue learns + // from. The worker is built whenever either webhook is configured; with neither token + // set both hooks 404 and nothing here ever has anything to do, so it is not started. + var ingester *library.Ingester + if cfg.SonarrWebhookToken != "" || cfg.RadarrWebhookToken != "" { + ingester = &library.Ingester{ + Store: st, + Emby: embyClient, + Credentials: syncer.EmbyCredentials, + Log: log.With("component", "library-ingest"), + Settle: cfg.IngestSettleDelay, + } + } var forYouService *foryou.Service if tracearrClient != nil { forYouService = foryou.New( @@ -265,6 +279,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro Credits: creditsService, CreditsLoad: creditsLoad, Syncer: syncer, + Ingester: ingester, Log: log, Events: events, @@ -283,6 +298,16 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro creditsService.SetPaused(server.ActivityPaused) go creditsService.Run(ctx) } + if ingester != nil { + // Quiet time is honoured here rather than at the hook: the webhook is recorded + // whatever the hour, and this is what waits. + ingester.Paused = server.ActivityPaused + // The news follows the scan rather than the webhook, so the banner can say a title + // is there rather than that it is coming. Installed here for the same reason + // SetAfterSync is: library stays ignorant of what an alert is. + ingester.Announce = server.AnnounceLibraryIngest + go ingester.Run(ctx) + } // Registration is separate from construction so the task list reads as a declaration // of what the gateway does in the background rather than as more wiring in here. @@ -326,7 +351,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro } go server.WatchUpdatePolicy(ctx, 60*time.Second) - go syncer.Schedule(ctx, cfg.SyncInterval, server.ActivityPaused) + go syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused) if cfg.SyncOnStart { go func() { if server.ActivityPaused() { diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 56dfc42..43ffa6e 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -55,6 +55,7 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents)) mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) + mux.Handle("GET /admin/api/ingest", s.adminAuth(s.handleAdminIngest)) mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou)) mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings)) mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings)) diff --git a/server/internal/api/admin_ingest.go b/server/internal/api/admin_ingest.go new file mode 100644 index 0000000..64f6078 --- /dev/null +++ b/server/internal/api/admin_ingest.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +// What the *arr webhooks have been doing, for the Imports page. +// +// It exists because a webhook is the one part of this gateway that fails *silently*: a +// token typed wrongly into Sonarr, a URL the container cannot be reached on, or a +// notification never enabled all look exactly like a household in which nothing has been +// imported lately. Without this an operator's only recourse is reading container logs. + +// ingestEventLimit is how much of the log the page carries. Enough to cover an evening's +// imports and a season pack, which is what somebody is looking at when they open it. +const ingestEventLimit = 50 + +type adminIngestResponse struct { + // Configured says whether each hook would answer at all. Both unset is the honest + // explanation of an empty table, and the page says so rather than leaving an operator + // to conclude the feature is broken. + SonarrConfigured bool `json:"sonarrConfigured"` + RadarrConfigured bool `json:"radarrConfigured"` + SettleSeconds int `json:"settleSeconds"` + SyncMinutes int `json:"syncMinutes"` + Counts store.IngestCounts `json:"counts"` + Recent []store.IngestJob `json:"recent"` +} + +func (s *Server) handleAdminIngest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + response := adminIngestResponse{ + SonarrConfigured: s.cfg.SonarrWebhookToken != "", + RadarrConfigured: s.cfg.RadarrWebhookToken != "", + SettleSeconds: int(s.ingestSettle().Seconds()), + SyncMinutes: int(s.LibrarySyncInterval().Minutes()), + Recent: []store.IngestJob{}, + } + + counts, err := s.store.IngestStateCounts(ctx) + if err != nil { + s.loggerFor(ctx).Error("ingest counts failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read the import queue") + return + } + response.Counts = counts + + recent, err := s.store.RecentIngests(ctx, ingestEventLimit) + if err != nil { + s.loggerFor(ctx).Error("ingest history failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read the import queue") + return + } + response.Recent = recent + writeJSON(w, http.StatusOK, response) +} diff --git a/server/internal/api/alerts.go b/server/internal/api/alerts.go index 5f46cb9..dd70ab0 100644 --- a/server/internal/api/alerts.go +++ b/server/internal/api/alerts.go @@ -13,6 +13,10 @@ import ( const ( alertKindSonarrAired = "sonarr-aired" alertKindRadarrImport = "radarr-import" + // A new episode that has finished scanning in. Distinct from sonarr-aired, which is + // about an episode that has been broadcast and is *not* here yet — the two are opposite + // halves of the same wait and a viewer reads them differently. + alertKindSonarrImport = "sonarr-import" alertKindLibrarySync = "library-updated" alertKindServerDown = "server-unreachable" alertKindServerUp = "server-restored" diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 1584ccb..98f6337 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -59,8 +59,11 @@ type Server struct { credits *credits.Service creditsLoad *credits.PlaybackLoad syncer syncerHandle - log *slog.Logger - events *serverlogging.Buffer + // ingester records what Sonarr and Radarr say changed. Nil where no webhook token is + // configured, which is also what makes both hooks 404. + ingester ingesterHandle + log *slog.Logger + events *serverlogging.Buffer // adminEvents is the administrative feed: the console's notification bell and every // outgoing integration read from it. Distinct from `events` above, which is the // structured log ring — a log line is what the gateway did, an admin event is @@ -96,6 +99,9 @@ type Server struct { // logged by name. playbackTitles playbackTitles + // ingestRuns collapses a season pack's worth of finished scans into one banner. + ingestRuns ingestRuns + recommendationBuilds recommendationBuilds maintenance maintenanceState quietTime quietTimeState @@ -130,6 +136,7 @@ type Deps struct { Credits *credits.Service CreditsLoad *credits.PlaybackLoad Syncer syncerHandle + Ingester ingesterHandle Log *slog.Logger Events *serverlogging.Buffer @@ -157,6 +164,7 @@ func New(cfg config.Config, deps Deps) *Server { credits: deps.Credits, creditsLoad: deps.CreditsLoad, syncer: deps.Syncer, + ingester: deps.Ingester, log: deps.Log, events: deps.Events, @@ -314,9 +322,17 @@ func (s *Server) Routes() http.Handler { // status even while every normal /v1 operation is deliberately unavailable. mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus)) mux.Handle("/v1/", s.maintenanceGate(v1)) - // Radarr pushes here when an import finishes. Outside the gate on purpose: an event - // arriving during maintenance would otherwise be lost rather than delayed. - mux.Handle("POST /hooks/radarr", s.quietTimeGate(http.HandlerFunc(s.handleRadarrWebhook))) + // Sonarr and Radarr push here when something lands, is upgraded, is renamed or is + // deleted. Outside the maintenance gate on purpose: an event arriving during + // maintenance would otherwise be lost rather than delayed. + // + // Outside the *quiet-time* gate too, which the Radarr hook was previously inside. That + // gate answers 503, and neither *arr re-delivers — so a quiet hour used to silently + // discard every import that happened during it. The durable queue is what makes the + // distinction possible: the hook records the news whatever the hour, and the worker is + // where quiet time is honoured. + mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook) + mux.HandleFunc("POST /hooks/sonarr", s.handleSonarrWebhook) // State the canonical console URL explicitly. The console and its assets live below // /admin/, while a bare /admin is routinely typed and some reverse proxies do not // preserve ServeMux's implicit trailing-slash redirect for a mounted subtree. diff --git a/server/internal/api/arr_hooks.go b/server/internal/api/arr_hooks.go new file mode 100644 index 0000000..392835c --- /dev/null +++ b/server/internal/api/arr_hooks.go @@ -0,0 +1,96 @@ +package api + +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + + "github.com/ponzischeme89/memby/server/internal/library" +) + +// The two things that push into the gateway. +// +// Radarr's hook was already here, announcing a film as news. Both hooks now also *record* +// what changed, which is the half that replaces asking Emby every hour whether anything +// had happened: Sonarr and Radarr are the things that put files on disk, so they are the +// things that know. +// +// Recording is all a hook does. The lookup, the import and the retry all belong to the +// worker in internal/library, which is what lets these answer in a millisecond and, more +// importantly, what lets them answer *at all* during quiet hours — see below. + +// ingesterHandle is the slice of the ingest worker the API needs, so api does not depend +// on the concrete type for testing. Same arrangement as syncerHandle. +type ingesterHandle interface { + Enqueue(ctx context.Context, source string, requests []library.IngestRequest) (int, error) +} + +// handleSonarrWebhook accepts Sonarr's import, upgrade, rename and delete notifications. +// +// Unconfigured means absent — the stance /admin and the Radarr hook already take: a +// deployment that never set a token must not expose an endpoint anything can post to. +func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) { + if s.cfg.SonarrWebhookToken == "" { + http.NotFound(w, r) + return + } + if subtle.ConstantTimeCompare( + []byte(webhookToken(r)), []byte(s.cfg.SonarrWebhookToken), + ) != 1 { + writeError(w, http.StatusUnauthorized, "invalid webhook token") + return + } + + var payload library.SonarrWebhook + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid webhook payload") + return + } + // Sonarr's Test button posts a stub. Answering 200 without recording work about a + // series that does not exist is what makes that button mean "reachable". + if library.IsTestEvent(payload.EventType) { + s.loggerFor(r.Context()).Info("sonarr webhook test received") + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true}) + return + } + + queued := s.queueIngest(r, "sonarr", payload.EventType, library.SonarrRequests(payload)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued}) +} + +// queueIngest records the work a notification implies and reports how much of it was news. +// +// The context is deliberately detached from the request. A webhook is answered in a +// millisecond and Sonarr closes the connection; hanging the insert off the request would +// abandon exactly the deliveries that arrive in bursts, which is what a season pack is. +func (s *Server) queueIngest( + r *http.Request, source, eventType string, requests []library.IngestRequest, +) int { + log := s.loggerFor(r.Context()) + if s.ingester == nil { + return 0 + } + if len(requests) == 0 { + // A grab, a health check, an unfollowed series whose files stayed on disk: all + // real events, none of them a reason to re-read anything. + log.Debug("webhook implies no catalogue work", "source", source, "event", eventType) + return 0 + } + ctx := context.WithoutCancel(r.Context()) + queued, err := s.ingester.Enqueue(ctx, source, requests) + if err != nil { + // The event is lost, which is the one failure worth an error line here: the *arrs + // do not re-deliver, so nothing will bring this news again. The reconciliation + // sweep is what eventually covers it. + log.Error("could not record webhook work", + "source", source, "event", eventType, "error", err) + return queued + } + if queued > 0 { + log.Info("arr ingest queued", + "event", "arr_ingest", "source", source, "webhook_event", eventType, + "outcome", "queued", "items", queued, "reason", requests[0].Reason) + } + return queued +} diff --git a/server/internal/api/arr_hooks_test.go b/server/internal/api/arr_hooks_test.go new file mode 100644 index 0000000..0afe021 --- /dev/null +++ b/server/internal/api/arr_hooks_test.go @@ -0,0 +1,168 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ponzischeme89/memby/server/internal/config" + "github.com/ponzischeme89/memby/server/internal/library" +) + +// recordingIngester stands in for the worker. The hook's whole job is to record, so what +// it recorded is the only thing worth asserting on here. +type recordingIngester struct { + sources []string + requests []library.IngestRequest +} + +func (r *recordingIngester) Enqueue( + _ context.Context, source string, requests []library.IngestRequest, +) (int, error) { + r.sources = append(r.sources, source) + r.requests = append(r.requests, requests...) + return len(requests), nil +} + +func sonarrHookRequest(body string) *http.Request { + return httptest.NewRequest( + http.MethodPost, "/hooks/sonarr?token=hook-secret", strings.NewReader(body)) +} + +func TestSonarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) { + s := &Server{cfg: config.Config{}, log: discardLogger()} + rec := httptest.NewRecorder() + + s.handleSonarrWebhook(rec, sonarrHookRequest("{}")) + + if rec.Code != http.StatusNotFound { + t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code) + } +} + +func TestSonarrWebhookRejectsAWrongToken(t *testing.T) { + ingester := &recordingIngester{} + s := &Server{ + cfg: config.Config{SonarrWebhookToken: "hook-secret"}, + log: discardLogger(), + ingester: ingester, + } + rec := httptest.NewRecorder() + + s.handleSonarrWebhook(rec, httptest.NewRequest( + http.MethodPost, "/hooks/sonarr?token=guess", strings.NewReader("{}"))) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want 401", rec.Code) + } + if len(ingester.requests) != 0 { + t.Fatal("an unauthorised delivery recorded work") + } +} + +func TestSonarrWebhookRecordsAnImport(t *testing.T) { + ingester := &recordingIngester{} + s := &Server{ + cfg: config.Config{SonarrWebhookToken: "hook-secret"}, + log: discardLogger(), + ingester: ingester, + } + rec := httptest.NewRecorder() + + s.handleSonarrWebhook(rec, sonarrHookRequest(`{ + "eventType":"Download", + "series":{"id":12,"title":"Blue Bloods","year":2010}, + "episodes":[{"id":551,"seasonNumber":6,"episodeNumber":7}], + "episodeFile":{"id":8123} + }`)) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if len(ingester.requests) != 1 { + t.Fatalf("expected one recorded request, got %d", len(ingester.requests)) + } + request := ingester.requests[0] + if request.Kind != library.KindEpisode || request.Episode != 7 || request.Season != 6 { + t.Fatalf("unexpected request: %+v", request) + } + if ingester.sources[0] != "sonarr" { + t.Fatalf("unexpected source: %q", ingester.sources[0]) + } +} + +// The Test button must answer without recording work about a series that does not exist. +func TestSonarrWebhookTestEventRecordsNothing(t *testing.T) { + ingester := &recordingIngester{} + s := &Server{ + cfg: config.Config{SonarrWebhookToken: "hook-secret"}, + log: discardLogger(), + ingester: ingester, + } + rec := httptest.NewRecorder() + + s.handleSonarrWebhook(rec, sonarrHookRequest( + `{"eventType":"Test","series":{"id":1,"title":"Test Title"}}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if len(ingester.requests) != 0 { + t.Fatalf("the test event recorded work: %+v", ingester.requests) + } +} + +// A Radarr upgrade is silent as news and still a reason to re-read the row. The two +// judgements are made in different places and this is what pins them apart. +func TestRadarrUpgradeIsRecordedThoughItIsNotAnnounced(t *testing.T) { + ingester := &recordingIngester{} + s := &Server{ + cfg: config.Config{ + RadarrWebhookToken: "hook-secret", + RadarrAlertWindow: 0, // no announcement is possible + }, + log: discardLogger(), + ingester: ingester, + } + rec := httptest.NewRecorder() + + s.handleRadarrWebhook(rec, httptest.NewRequest( + http.MethodPost, "/hooks/radarr?token=hook-secret", strings.NewReader(`{ + "eventType":"Download","isUpgrade":true, + "movie":{"id":44,"title":"Arrival","year":2016}, + "movieFile":{"id":441,"quality":"Bluray-1080p"} + }`))) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200", rec.Code) + } + if len(ingester.requests) != 1 { + t.Fatalf("the upgrade was not recorded: %+v", ingester.requests) + } + if ingester.requests[0].Reason != library.ReasonUpgrade { + t.Fatalf("unexpected reason: %q", ingester.requests[0].Reason) + } + if ingester.requests[0].Action != library.ActionRefresh { + t.Fatalf("an upgrade must refresh, got %q", ingester.requests[0].Action) + } +} + +// A grab is a real event and not a reason to re-read anything. +func TestSonarrGrabRecordsNothing(t *testing.T) { + ingester := &recordingIngester{} + s := &Server{ + cfg: config.Config{SonarrWebhookToken: "hook-secret"}, + log: discardLogger(), + ingester: ingester, + } + rec := httptest.NewRecorder() + + s.handleSonarrWebhook(rec, sonarrHookRequest( + `{"eventType":"Grab","series":{"id":12,"title":"Blue Bloods"}}`)) + + if rec.Code != http.StatusOK || len(ingester.requests) != 0 { + t.Fatalf("got %d with %d requests", rec.Code, len(ingester.requests)) + } +} diff --git a/server/internal/api/gateway_settings.go b/server/internal/api/gateway_settings.go index c68ddd0..c6002e6 100644 --- a/server/internal/api/gateway_settings.go +++ b/server/internal/api/gateway_settings.go @@ -129,6 +129,15 @@ func (s *Server) embyHealthInterval() time.Duration { s.cfg.EmbyHealthInterval) } +// LibrarySyncInterval is how often the catalogue sweep runs. Exported because the syncer's +// schedule reads it every tick rather than closing over it at start-up — a setting read +// once at start-up is not a setting, and an operator lengthening the sweep after wiring up +// the webhooks must not have to restart the container to see it take effect. +func (s *Server) LibrarySyncInterval() time.Duration { + return overrideWindow(s.gatewaySettings.get().LibrarySyncMinutes, time.Minute, + s.cfg.SyncInterval) +} + // overrideWindow reads one of the three settings that can be switched off: a negative // value is off, zero is "whatever was deployed", anything else is the override in the // given unit. @@ -153,6 +162,7 @@ type deployedGatewaySettings struct { SonarrAlertMinutes int `json:"sonarrAlertMinutes"` RadarrAlertMinutes int `json:"radarrAlertMinutes"` EmbyHealthSeconds int `json:"embyHealthSeconds"` + LibrarySyncMinutes int `json:"librarySyncMinutes"` } func (s *Server) deployedSettings() deployedGatewaySettings { @@ -167,6 +177,7 @@ func (s *Server) deployedSettings() deployedGatewaySettings { SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute), RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute), EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second), + LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute), } } @@ -184,3 +195,13 @@ func levelName(level slog.Level) string { return "error" } } + +// ingestSettle is what the console prints beside the webhook activity, and it is a helper +// for the same reason the others here are: the delay is configuration, and the page must +// report the value actually in force rather than the constant it defaults to. +func (s *Server) ingestSettle() time.Duration { + if s.cfg.IngestSettleDelay > 0 { + return s.cfg.IngestSettleDelay + } + return time.Minute +} diff --git a/server/internal/api/housekeeping.go b/server/internal/api/housekeeping.go index 8fdb30a..bb4c353 100644 --- a/server/internal/api/housekeeping.go +++ b/server/internal/api/housekeeping.go @@ -77,6 +77,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) { }, }) + sched.Register(scheduler.Task{ + ID: "ingest-cleanup", + Name: "Import queue cleanup", + Group: "Housekeeping", + Description: fmt.Sprintf( + "Removes settled Sonarr and Radarr import records older than %d days. Work still waiting is never removed.", + int(store.IngestRetention/(24*time.Hour))), + Interval: 24 * time.Hour, + Run: func(ctx context.Context) (string, error) { + removed, err := s.store.PruneIngests(ctx, store.IngestRetention) + return countDetail(removed, "import record"), err + }, + }) + sched.Register(scheduler.Task{ ID: "task-history-cleanup", Name: "Task history cleanup", diff --git a/server/internal/api/ingest_alerts.go b/server/internal/api/ingest_alerts.go new file mode 100644 index 0000000..99c8736 --- /dev/null +++ b/server/internal/api/ingest_alerts.go @@ -0,0 +1,210 @@ +package api + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/library" +) + +// News about a finished Sonarr or Radarr scan. +// +// The webhook is not the news. Both *arrs fire the moment they have moved a file, and Emby +// has not scanned it in yet — which is why the banner this replaces had to say a film would +// be available "shortly", and why an episode could not be announced at all: there was +// nothing truthful to say about one until it was actually there. The gateway now knows when +// that moment arrives, because the ingest worker is what makes it arrive, so the +// announcement is made from the far end of the scan and says the title is ready. +// +// The cost is that the news is a minute or two later than the webhook, and that a title Emby +// never manages to scan is never announced. Both are the right way round: a notice about +// something a viewer can press Play on is worth more than an earlier one about something +// they cannot. + +const ( + // ingestRunWindow is how long two imports count as one piece of news. A season pack + // arrives as a dozen webhooks over a couple of minutes, and a household does not want a + // dozen banners about it — it wants to be told the show has new episodes. + ingestRunWindow = 15 * time.Minute + + // trackedIngestRuns bounds the tally. A household imports a handful of things at once; + // this is generous enough that a season pack always collapses and small enough that it + // can never grow into a leak. + trackedIngestRuns = 64 +) + +// AnnounceLibraryIngest turns a completed scan into the banner every open television shows. +// +// Only a genuine import is announced. An upgrade is deliberately silent — the title was +// already there, and "new episode" would be a lie about a file that was replaced with a +// better copy — and so are a rename and a delete, which are housekeeping rather than news. +// That judgement lives here rather than in the worker: the worker's business is that the +// row changed, this is the separate question of whether anybody should be told. +func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.IngestResult) { + if result.Reason != library.ReasonImport { + return + } + switch result.Kind { + case library.KindMovie: + s.announceImportedMovie(ctx, result) + case library.KindEpisode: + s.announceImportedEpisode(ctx, result) + } + // A series-level result is a rename settling or a show written ahead of its first + // episode. Neither is a title somebody can watch, and the episode that follows is. +} + +func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) { + window := s.radarrAlertWindow() + title := strings.TrimSpace(result.Name) + if window <= 0 || title == "" || result.ItemID == "" { + return + } + now := time.Now().UTC() + name := title + if result.Year > 0 { + name = fmt.Sprintf("%s (%d)", title, result.Year) + } + s.publishAlert(ctx, clientAlert{ + // Keyed on the Emby item, so a repeated delivery of one import is one banner while + // a film deleted and re-imported is news again. Clients dedupe on this id forever. + ID: "ingest:movie:" + result.ItemID, + Kind: alertKindRadarrImport, + Label: "NEW MOVIE ADDED", + Title: name, + Message: fmt.Sprintf("%s is ready to watch.", title), + ItemID: result.ItemID, + ImageTag: result.ImageTag, + AiredAt: now.Format(time.RFC3339), + }, window) + s.loggerFor(ctx).Info("library ingest announced", + "event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind, + "title", name, "item", result.ItemID) +} + +func (s *Server) announceImportedEpisode(ctx context.Context, result library.IngestResult) { + // The Sonarr window, so MEMBY_SONARR_ALERT_WINDOW=0 switches episode news off exactly + // as it does the "aired, coming soon" one, without touching films. + window := s.sonarrAlertWindow() + series := strings.TrimSpace(result.SeriesName) + if window <= 0 || series == "" || result.ItemID == "" { + return + } + now := time.Now().UTC() + run := s.ingestRuns.record( + seasonRunKey(series, result.Season), result.ItemID, + episodeSummary(result), now, ingestRunWindow, + ) + s.publishAlert(ctx, clientAlert{ + // The run's *first* episode anchors the id, so every later arrival in the same + // season pack replaces one banner rather than stacking another — and next week's + // episode, arriving after the window has closed, starts a run of its own and is + // therefore its own news rather than one the fleet has already dismissed as seen. + ID: "ingest:episode:" + run.Anchor, + Kind: alertKindSonarrImport, + Label: "NEW EPISODE ADDED", + Title: series, + Message: episodeRunMessage(run), + ItemID: result.ItemID, + ImageTag: result.ImageTag, + AiredAt: now.Format(time.RFC3339), + }, window) + s.loggerFor(ctx).Info("library ingest announced", + "event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind, + "series", series, "episodes", run.Count, "item", result.ItemID) +} + +// episodeSummary is how one episode is named in a banner: "S03E05 — The Bear". The code +// alone is what a viewer scanning a shelf recognises, and the title is what tells them it +// is the one they were waiting for, so both are kept where both exist. +func episodeSummary(result library.IngestResult) string { + code := "" + if result.Season > 0 || result.Episode > 0 { + code = fmt.Sprintf("S%02dE%02d", result.Season, result.Episode) + } + title := strings.TrimSpace(result.Name) + switch { + case code == "": + return title + case title == "" || strings.EqualFold(title, result.SeriesName): + return code + default: + return fmt.Sprintf("%s — %s", code, title) + } +} + +// episodeRunMessage words one arrival by name and several by count. Naming the last of six +// would be arbitrary — nothing makes it the one worth mentioning — where the count is the +// thing the viewer actually wants to know. +func episodeRunMessage(run ingestRun) string { + if run.Count > 1 { + return fmt.Sprintf("%d new episodes are ready to watch.", run.Count) + } + if run.Latest == "" { + return "A new episode is ready to watch." + } + return fmt.Sprintf("%s is ready to watch.", run.Latest) +} + +func seasonRunKey(series string, season int) string { + return fmt.Sprintf("%s|%d", library.NormalizedTitle(series), season) +} + +// ingestRun is what a season's imports have amounted to so far. +type ingestRun struct { + // Anchor is the first item id seen in this run, and is what keeps a burst of banners + // collapsed onto one. + Anchor string + Count int + Latest string +} + +// ingestRuns collapses a burst of imports of one season into a single piece of news. +// +// Deliberately in memory and deliberately lossy, the playbackTitles arrangement: a gateway +// restarted half way through a season pack announces the rest as a second run, which is a +// far better trade than a table recording what a banner said. +type ingestRuns struct { + mu sync.Mutex + runs map[string]*runState + order []string +} + +type runState struct { + anchor string + count int + latest string + until time.Time +} + +// record folds one import into its season's run and reports where that run now stands. A +// run whose window has closed is replaced rather than extended, so a show importing an +// episode a week is a separate notice every week. +func (r *ingestRuns) record( + key, itemID, summary string, now time.Time, window time.Duration, +) ingestRun { + r.mu.Lock() + defer r.mu.Unlock() + if r.runs == nil { + r.runs = make(map[string]*runState, trackedIngestRuns) + } + state, live := r.runs[key] + if !live || !state.until.After(now) { + if !live { + r.order = append(r.order, key) + if len(r.order) > trackedIngestRuns { + delete(r.runs, r.order[0]) + r.order = r.order[1:] + } + } + state = &runState{anchor: itemID} + r.runs[key] = state + } + state.count++ + state.latest = summary + state.until = now.Add(window) + return ingestRun{Anchor: state.anchor, Count: state.count, Latest: state.latest} +} diff --git a/server/internal/api/ingest_alerts_test.go b/server/internal/api/ingest_alerts_test.go new file mode 100644 index 0000000..4fee110 --- /dev/null +++ b/server/internal/api/ingest_alerts_test.go @@ -0,0 +1,155 @@ +package api + +import ( + "strings" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/library" +) + +func episodeResult(season, episode int, title string) library.IngestResult { + return library.IngestResult{ + Source: "sonarr", + Kind: library.KindEpisode, + Reason: library.ReasonImport, + ItemID: "emby-" + title, + Name: title, + SeriesName: "The Bear", + Season: season, + Episode: episode, + } +} + +func TestEpisodeSummaryNamesTheEpisodeBothWays(t *testing.T) { + if got := episodeSummary(episodeResult(3, 5, "Children")); got != "S03E05 — Children" { + t.Errorf("summary = %q, want the code and the title", got) + } + + // Emby records plenty of episodes under the show's own name, and "S03E05 — The Bear" + // reads as a mistake where the code alone reads as an episode. + same := episodeResult(3, 5, "The Bear") + if got := episodeSummary(same); got != "S03E05" { + t.Errorf("summary = %q, want the code alone when the title repeats the series", got) + } + + untitled := episodeResult(3, 5, "") + if got := episodeSummary(untitled); got != "S03E05" { + t.Errorf("summary = %q, want the code alone", got) + } +} + +// A season pack is one piece of news. Every arrival replaces the same banner, which is +// what the shared anchor is for, and the wording moves from the episode to the count. +func TestIngestRunsCollapseASeasonPack(t *testing.T) { + var runs ingestRuns + now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) + + first := runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow) + if first.Count != 1 || first.Anchor != "emby-1" { + t.Fatalf("first = %+v, want a run of one anchored on it", first) + } + if got := episodeRunMessage(first); got != "S03E01 is ready to watch." { + t.Errorf("message = %q, want the episode named", got) + } + + second := runs.record("bear|3", "emby-2", "S03E02", now.Add(30*time.Second), ingestRunWindow) + if second.Anchor != "emby-1" { + t.Errorf("anchor = %q, want the run's first episode so the banner is replaced", second.Anchor) + } + if second.Count != 2 { + t.Errorf("count = %d, want 2", second.Count) + } + if got := episodeRunMessage(second); got != "2 new episodes are ready to watch." { + t.Errorf("message = %q, want the count once there is more than one", got) + } +} + +// Next week's episode is its own news. Televisions dedupe on the alert id forever, so a +// run that reused last week's anchor would be silently swallowed on every set in the house. +func TestIngestRunsStartAfreshOnceTheWindowHasClosed(t *testing.T) { + var runs ingestRuns + now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) + + runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow) + later := runs.record("bear|3", "emby-2", "S03E02", now.Add(ingestRunWindow+time.Minute), ingestRunWindow) + + if later.Anchor != "emby-2" { + t.Errorf("anchor = %q, want a new run", later.Anchor) + } + if later.Count != 1 { + t.Errorf("count = %d, want a run of one", later.Count) + } +} + +// Two shows importing at once are two pieces of news, not one run of four episodes. +func TestIngestRunsAreKeptPerSeason(t *testing.T) { + var runs ingestRuns + now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) + + runs.record(seasonRunKey("The Bear", 3), "bear-1", "S03E01", now, ingestRunWindow) + other := runs.record(seasonRunKey("Slow Horses", 4), "horses-1", "S04E01", now, ingestRunWindow) + + if other.Count != 1 || other.Anchor != "horses-1" { + t.Fatalf("other show = %+v, want a run of its own", other) + } + // And the same show under a different spelling is still the same show, the rule the + // schedule row and the ingest worker already match titles by. + same := runs.record(seasonRunKey("the bear!", 3), "bear-2", "S03E02", now, ingestRunWindow) + if same.Anchor != "bear-1" || same.Count != 2 { + t.Fatalf("same season = %+v, want it folded into the first run", same) + } +} + +// The tally is memory the gateway can afford to lose, so it must also be memory it cannot +// grow without bound. +func TestIngestRunsAreBounded(t *testing.T) { + var runs ingestRuns + now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC) + for i := 0; i < trackedIngestRuns*2; i++ { + runs.record(time.Duration(i).String(), "item", "S01E01", now, ingestRunWindow) + } + if len(runs.runs) > trackedIngestRuns { + t.Fatalf("tracking %d runs, want a cap of %d", len(runs.runs), trackedIngestRuns) + } +} + +// Only an import is news. An upgrade replaced a file that was already watchable, and a +// rename or a delete is housekeeping — announcing any of them trains viewers to look away. +func TestOnlyAnImportIsAnnounced(t *testing.T) { + s := &Server{log: discardLogger()} + for _, reason := range []string{ + library.ReasonUpgrade, library.ReasonRename, library.ReasonDelete, + } { + result := episodeResult(3, 5, "Children") + result.Reason = reason + // A nil cache would be reached by publishAlert if this announced anything; it + // returns early on one, so the assertion is that nothing is recorded either. + s.AnnounceLibraryIngest(t.Context(), result) + if len(s.ingestRuns.runs) != 0 { + t.Fatalf("%s was treated as news", reason) + } + } +} + +// A series-level result is a rename settling or a show written ahead of its first episode. +// Neither is something anybody can press Play on. +func TestASeriesRefreshIsNotAnnounced(t *testing.T) { + s := &Server{log: discardLogger()} + s.AnnounceLibraryIngest(t.Context(), library.IngestResult{ + Source: "sonarr", Kind: library.KindSeries, Reason: library.ReasonImport, + ItemID: "series-1", Name: "The Bear", SeriesName: "The Bear", + }) + if len(s.ingestRuns.runs) != 0 { + t.Fatal("a series refresh was announced") + } +} + +func TestMovieRunMessageSaysTheFilmIsThere(t *testing.T) { + // The wording is the whole point of moving the announcement behind the scan: the + // banner published from the webhook could only ever promise the film was coming. + run := ingestRun{Anchor: "a", Count: 1, Latest: "S01E01"} + if strings.Contains(episodeRunMessage(run), "shortly") { + t.Error("the message still promises rather than states") + } +} diff --git a/server/internal/api/logging_test.go b/server/internal/api/logging_test.go index 3be5544..888706d 100644 --- a/server/internal/api/logging_test.go +++ b/server/internal/api/logging_test.go @@ -61,6 +61,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) { "/v1/my-shows": "my-shows", "/admin/api/status": "admin", "/hooks/radarr": "webhooks", + "/hooks/sonarr": "webhooks", "/install": "installer", "/updates/latest.apk": "updates", "/something-nobody-has-written": "api", diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index ac83fd0..7ca4e85 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -136,6 +136,19 @@ var preferenceCatalogue = []preferenceDefinition{ Kind: preferenceChoice, Default: defaultThemeID, Options: themeOptions(), }, + { + // The marks, kept apart from the palette because they are a different decision + // about legibility rather than about taste — a household watching from a sofa may + // well want the solid pack on every scheme they own. + // + // The options come from iconPackOptions() in themes.go for the reason themeId's + // come from the catalogue: a pack added there cannot become a value this rejects, + // and a pack removed cannot stay selectable here. + Key: "iconSet", Name: "Icon set", Area: "Presentation", + Description: "Which set of marks this viewer's televisions draw.", + Kind: preferenceChoice, Default: defaultIconPackID, + Options: iconPackOptions(), + }, { Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation", Description: "Tone of the short line shown after signing in.", diff --git a/server/internal/api/radarr_alerts.go b/server/internal/api/radarr_alerts.go index 5e1eed7..a630dba 100644 --- a/server/internal/api/radarr_alerts.go +++ b/server/internal/api/radarr_alerts.go @@ -3,10 +3,10 @@ package api import ( "crypto/subtle" "encoding/json" - "fmt" "net/http" "strings" - "time" + + "github.com/ponzischeme89/memby/server/internal/library" ) // Radarr's import notification arrives as a webhook, which is why this is the one part @@ -31,6 +31,30 @@ type radarrWebhookPayload struct { ID int `json:"id"` Quality string `json:"quality"` } `json:"movieFile"` + // The delete events carry the file id at the top level rather than under movieFile, + // and say whether the media went with the entry. Both are read by the catalogue half + // only; the banner has nothing to say about a deletion. + MovieFileID int `json:"movieFileId"` + DeletedFiles bool `json:"deletedFiles"` +} + +// ingestPayload hands the same notification to the catalogue rules. +// +// Written out rather than shared as one struct because the two halves genuinely read +// different fields for different reasons — the banner wants the quality string, the +// catalogue wants the deletion flags — and a single type would grow whichever field +// either of them needed next. +func ingestPayload(payload radarrWebhookPayload) library.RadarrWebhook { + var out library.RadarrWebhook + out.EventType = payload.EventType + out.IsUpgrade = payload.IsUpgrade + out.Movie.ID = payload.Movie.ID + out.Movie.Title = payload.Movie.Title + out.Movie.Year = payload.Movie.Year + out.MovieFile.ID = payload.MovieFile.ID + out.MovieFileID = payload.MovieFileID + out.DeletedFiles = payload.DeletedFiles + return out } // handleRadarrWebhook accepts Radarr's "On Import" notification. @@ -66,23 +90,20 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) { return } - alert, ok := radarrImportAlert(payload, time.Now().UTC()) - if !ok { - // A grab, a rename, a health check or an upgrade of something already in the - // library: all real events, none of them "a new film is here". - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) - return + // Recording is now the whole of what this hook does. The banner used to be published + // from right here, which meant it was published before Emby had scanned the film in — + // hence its wording, that the film would be available "shortly". It is announced from + // the far end of the scan instead (AnnounceLibraryIngest), where it can say the film is + // actually there and where an episode can be announced on the same terms. + // + // An upgrade is still recorded and still silent as news: the file genuinely changed, so + // the row must be re-read, but the film was already there. + queued := s.queueIngest(r, "radarr", payload.EventType, library.RadarrRequests(ingestPayload(payload))) + if queued > 0 { + s.loggerFor(r.Context()).Debug("radarr import recorded", + "movie", payload.Movie.Title, "quality", payload.MovieFile.Quality) } - window := s.radarrAlertWindow() - if window <= 0 { - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false}) - return - } - - s.publishAlert(r.Context(), alert, window) - s.loggerFor(r.Context()).Info("radarr import announced", - "movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued}) } // webhookToken accepts the shared secret three ways because Radarr's webhook settings @@ -100,53 +121,3 @@ func webhookToken(r *http.Request) string { } return strings.TrimSpace(r.URL.Query().Get("token")) } - -// radarrImportAlert turns an import notification into the banner a TV shows, or reports -// that this event is not worth announcing. -// -// An upgrade is deliberately silent: the film was already there, and "new movie added" -// would be a lie about a file that was replaced with a better copy. -func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) { - if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade { - return clientAlert{}, false - } - title := strings.TrimSpace(payload.Movie.Title) - if title == "" || payload.Movie.ID <= 0 { - return clientAlert{}, false - } - - // Keyed on the file, so a title deleted and re-imported is news again while a - // repeated delivery of the same import is not. Clients dedupe on this id forever. - id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID) - if payload.MovieFile.ID <= 0 { - id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix()) - } - - name := title - if payload.Movie.Year > 0 { - name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year) - } - return clientAlert{ - ID: id, - Kind: alertKindRadarrImport, - Label: "NEW MOVIE ADDED", - Title: name, - Message: fmt.Sprintf("%s will be available in Emby shortly.", title), - // The image proxy already serves Radarr covers under this id and tag, so the - // banner shows the poster before Emby has finished scanning the film in. - ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID), - ImageTag: "radarr", - AiredAt: now.UTC().Format(time.RFC3339), - }, true -} - -// isRadarrImportEvent matches the event Radarr fires once a downloaded file has been -// imported into the library. The name has moved between versions, so both are accepted. -func isRadarrImportEvent(eventType string) bool { - switch strings.ToLower(strings.TrimSpace(eventType)) { - case "download", "moviefileimported": - return true - default: - return false - } -} diff --git a/server/internal/api/radarr_alerts_test.go b/server/internal/api/radarr_alerts_test.go index bd5ea67..2bba235 100644 --- a/server/internal/api/radarr_alerts_test.go +++ b/server/internal/api/radarr_alerts_test.go @@ -12,96 +12,6 @@ import ( "github.com/ponzischeme89/memby/server/internal/config" ) -func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload { - var payload radarrWebhookPayload - payload.EventType = "Download" - payload.Movie.ID = movieID - payload.Movie.Title = title - payload.Movie.Year = year - payload.MovieFile.ID = fileID - return payload -} - -func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) { - now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) - - alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now) - if !ok { - t.Fatal("expected an import to be announced") - } - if alert.ID != "radarr:412:file:9001" { - t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID) - } - if alert.Kind != alertKindRadarrImport { - t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport) - } - if alert.Label == "" { - t.Error("want a label: the app cannot know the wording for a kind it predates") - } - if alert.Title != "Mr. Smith Goes to Washington (1939)" { - t.Errorf("title = %q, want the year alongside it", alert.Title) - } - if !strings.Contains(alert.Message, "available in Emby shortly") { - t.Errorf("message = %q, want it to say the film is on its way", alert.Message) - } - // The image proxy serves Radarr covers under this pair, so the banner has a poster - // before Emby has scanned the film in. - if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" { - t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag) - } - if alert.AiredAt != now.Format(time.RFC3339) { - t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt) - } -} - -func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) { - now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) - - upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939) - upgrade.IsUpgrade = true - - grab := importPayload(413, 0, "Some Film", 2024) - grab.EventType = "Grab" - - untitled := importPayload(414, 9003, " ", 2024) - - unknownMovie := importPayload(0, 9004, "No Id", 2024) - - for name, payload := range map[string]radarrWebhookPayload{ - "quality upgrade of a film already there": upgrade, - "grabbed but not imported": grab, - "no title": untitled, - "no movie id": unknownMovie, - } { - if _, ok := radarrImportAlert(payload, now); ok { - t.Errorf("%s: expected no alert", name) - } - } -} - -func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) { - now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) - payload := importPayload(415, 9005, "Rear Window", 1954) - payload.EventType = "MovieFileImported" - - if _, ok := radarrImportAlert(payload, now); !ok { - t.Error("expected the alternate import event name to be announced") - } -} - -// A file id is what makes a repeated notification the same news; without one the id -// falls back to the clock so a re-import is not silently swallowed. -func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) { - now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) - alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now) - if !ok { - t.Fatal("expected an alert") - } - if !strings.HasPrefix(alert.ID, "radarr:416:imported:") { - t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID) - } -} - func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) { now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC) existing := []storedAlert{ diff --git a/server/internal/api/themes.go b/server/internal/api/themes.go index 61487af..7ea2824 100644 --- a/server/internal/api/themes.go +++ b/server/internal/api/themes.go @@ -68,6 +68,39 @@ const ( decorationBlossom = "blossom" ) +// The icon packs a theme may draw its marks from. Slugs, for the reason the decorations +// above are slugs: the shapes live on the television, in ui/theme/MembyIconPacks.kt, and +// the gateway has no business describing geometry to it. A client that does not recognise +// one draws the marks it shipped with — so this list may gain a pack before the fleet has +// the build that knows it, the MembyHeroLabel precedent again. +// +// The reason to want any of this is that Material's marks are the marks every Android app +// on the television already wears. Moving a household off them is a decision an operator +// should be able to make in the gateway, not one that waits on an APK reaching every set. +const ( + iconPackMaterial = "material" + iconPackLucide = "lucide" + iconPackFontAwesome = "fontawesome" +) + +// defaultIconPackID is the marks the app shipped with, so nothing changes appearance on the +// day this lands. +const defaultIconPackID = iconPackMaterial + +// iconPackOptions is the vocabulary of the `iconSet` preference, and the only place a pack +// is declared. A pack this list does not name is one no viewer and no operator can select. +// +// The wording is about how the marks read from a sofa, because that is the whole of the +// choice: stroke sets are drawn for 16-24px on a monitor, and on a rail chip at three +// metres they go thin where a solid mark keeps its shape. +func iconPackOptions() []preferenceOption { + return []preferenceOption{ + option(iconPackMaterial, "Material — Android's own marks"), + option(iconPackLucide, "Lucide — lighter, drawn as outlines"), + option(iconPackFontAwesome, "Font Awesome — solid, clearest at a distance"), + } +} + type themeDefinition struct { ID string `json:"id"` Name string `json:"name"` @@ -83,6 +116,14 @@ type themeDefinition struct { // it. It is empty on every selectable theme by construction rather than by a check at // the point of use. Decoration string `json:"decoration,omitempty"` + // IconSet lets a theme bring its own marks, and like Decoration only a seasonal theme + // carries one. Empty means the viewer's own choice stands. + // + // The asymmetry with the palette is deliberate. A season *is* a look, so it may say + // what the marks are; a scheme somebody picked to live with all year must not silently + // take their marks away, because there would be no way to tell which of the two + // choices had done it. + IconSet string `json:"iconSet,omitempty"` } const ( @@ -164,6 +205,7 @@ var themeCatalogue = []themeDefinition{ ID: themeHalloween, Name: "Halloween", Seasonal: true, Description: "Pumpkin orange on black, for the last week of October.", Decoration: decorationBats, + IconSet: iconPackFontAwesome, Palette: themePalette{ Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F", OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C", @@ -341,6 +383,12 @@ type resolvedTheme struct { // theme by the television — a set holding a cached Christmas palette must not keep // snowing after the switch has been thrown. Decoration string `json:"decoration,omitempty"` + // IconSet is the pack the television draws its marks from: "material", "lucide", + // "fontawesome". Resolved here rather than derived on the set from the theme id, + // exactly as Decoration is — a television holding a cached seasonal answer must stop + // using that season's marks when the switch is thrown, and it has no way to know that + // on its own. + IconSet string `json:"iconSet,omitempty"` // Reason is the sentence the picker prints while it is locked. The gateway's wording, // the MembyHeroLabel precedent, so a season invented later reads correctly on today's // build rather than as a blank space where an explanation should be. @@ -364,6 +412,7 @@ type resolvedTheme struct { // a season. The only switch is seasonalEnabled, and that is the operator's feature flag. func resolveTheme( chosen string, + chosenIconPack string, allowed []string, seasonalEnabled bool, decorationsEnabled bool, @@ -394,15 +443,38 @@ func resolveTheme( decoration = applied.Decoration } + // The viewer's marks, unless the season brought its own. Note that this is *not* gated + // on decorationsEnabled: that switch is about the cost of a continuous animation on a + // weak box, and a set of icons costs nothing to draw. + iconPack := chosenIconPack + if !knownIconPack(iconPack) { + iconPack = defaultIconPackID + } + if seasonal && applied.IconSet != "" { + iconPack = applied.IconSet + } + resolved := resolvedTheme{ ID: applied.ID, Name: applied.Name, Palette: applied.Palette, Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason, - Decoration: decoration, + Decoration: decoration, IconSet: iconPack, } resolved.Revision = themeRevision(resolved) return resolved } +// knownIconPack keeps an unreadable or retired slug out of the answer. The television +// would fall back on its own — membyIconPackFor answers with Material for anything it does +// not know — but a gateway that echoed a pack nobody can draw would make every set in the +// house look broken in the same way while reporting that it had done what it was asked. +func knownIconPack(id string) bool { + switch id { + case iconPackMaterial, iconPackLucide, iconPackFontAwesome: + return true + } + return false +} + // themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row // has ever been written for the great majority of households, and reading that as "this // person may have no themes" would empty every picker in the house the day this ships. @@ -422,7 +494,7 @@ func themeRevision(resolved resolvedTheme) string { for _, part := range []string{ strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen, strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason, - resolved.Decoration, + resolved.Decoration, resolved.IconSet, palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface, palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface, } { @@ -442,12 +514,17 @@ func themeRevision(resolved resolvedTheme) string { // palette it falls back to is the one the app shipped with. func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme { chosen, _ := preferenceDefault("themeId").(string) + iconPack, _ := preferenceDefault("iconSet").(string) allowed := []string(nil) if s.store != nil && sess.EmbyUserID != "" { if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil { - if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok { + decoded := decodePreferences(stored.Preferences) + if value, ok := decoded["themeId"].(string); ok { chosen = value } + if value, ok := decoded["iconSet"].(string); ok { + iconPack = value + } } else { s.loggerFor(ctx).Warn("theme preference unavailable", "error", err) } @@ -458,7 +535,7 @@ func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme } } return resolveTheme( - chosen, allowed, + chosen, iconPack, allowed, s.featureEnabled(ctx, featureSeasonalThemes), s.featureEnabled(ctx, featureSeasonalDecorations), s.now(), diff --git a/server/internal/api/themes_test.go b/server/internal/api/themes_test.go index be45af9..2ae003e 100644 --- a/server/internal/api/themes_test.go +++ b/server/internal/api/themes_test.go @@ -77,7 +77,7 @@ func TestEasterWindowIsTheLongWeekend(t *testing.T) { // A season is the one thing on this feature nobody on a television can decline, so the // tests that matter most are the ones asserting that no argument suppresses it. func TestSeasonOutranksTheViewer(t *testing.T) { - resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) + resolved := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20)) if resolved.ID != themeChristmas { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas) } @@ -98,7 +98,7 @@ func TestSeasonOutranksTheViewer(t *testing.T) { // grantable per person, and an operator restricting a viewer to one palette must not be a // way of exempting them from Christmas. func TestAllowlistDoesNotApplyToSeasons(t *testing.T) { - resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31)) + resolved := resolveTheme(themePlum, "", []string{themeEmber}, true, true, date(2026, time.October, 31)) if resolved.ID != themeHalloween { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween) } @@ -110,7 +110,7 @@ func TestAllowlistDoesNotApplyToSeasons(t *testing.T) { } func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) { - resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20)) + resolved := resolveTheme(themeEmber, "", nil, false, true, date(2026, time.December, 20)) if resolved.ID != themeEmber { t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber) } @@ -138,7 +138,7 @@ func TestResolveThemeFallbacks(t *testing.T) { } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { - got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary) + got := resolveTheme(testCase.chosen, "", testCase.allowed, true, true, ordinary) if got.ID != testCase.want { t.Fatalf("resolveTheme(%q, %v) = %q, want %q", testCase.chosen, testCase.allowed, got.ID, testCase.want) @@ -150,14 +150,14 @@ func TestResolveThemeFallbacks(t *testing.T) { // The revision is the whole delivery mechanism: a television refetches the palette only when // this moves. If it did not move when a season began, no set in the house would repaint. func TestThemeRevisionTracksTheAnswer(t *testing.T) { - ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) - christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) + ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14)) + christmas := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20)) if ordinary.Revision == christmas.Revision { t.Fatal("the revision must change when the season does, or nothing refetches") } // And it must be stable, or every poll would look like a change and every set would // fetch the palette six times a minute. - again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15)) + again := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 15)) if ordinary.Revision != again.Revision { t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision) } @@ -168,10 +168,10 @@ func TestThemeRevisionTracksTheAnswer(t *testing.T) { // every day of the year. func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) { christmas := date(2026, time.December, 20) - if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow { + if got := resolveTheme(themePlum, "", nil, true, true, christmas); got.Decoration != decorationSnow { t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow) } - off := resolveTheme(themePlum, nil, true, false, christmas) + off := resolveTheme(themePlum, "", nil, true, false, christmas) if off.Decoration != "" { t.Fatalf("decorations off still returned %q", off.Decoration) } @@ -179,10 +179,10 @@ func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) { t.Fatal("turning decorations off must keep the seasonal palette") } // The revision has to move, or a set already snowing is never told to stop. - if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision { + if off.Revision == resolveTheme(themePlum, "", nil, true, true, christmas).Revision { t.Fatal("the revision must change when the decoration does") } - ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) + ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14)) if ordinary.Decoration != "" { t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration) } @@ -277,3 +277,88 @@ func TestNormalizeThemeAllowlist(t *testing.T) { t.Fatalf("allowlist is not in catalogue order: %v", ordered) } } + +// --- Icon packs --------------------------------------------------------------------------- + +// The gateway's half of the wire contract with ui/theme/MembyIconPacks.kt. Its own +// MembyIconPackTest pins the same three slugs from the television's end; change one +// without the other and a household is sent marks nothing can draw. +func TestIconPackOptionsAreTheKnownPacks(t *testing.T) { + for _, option := range iconPackOptions() { + if !knownIconPack(option.Value) { + t.Fatalf("offered icon pack %q is not one the resolver will accept", option.Value) + } + } + if !knownIconPack(defaultIconPackID) { + t.Fatalf("the default icon pack %q is not selectable", defaultIconPackID) + } +} + +// A slug the resolver does not recognise must never reach a television. The set would fall +// back on its own, but a gateway echoing a retired pack would make every screen in the +// house look wrong in the same way while reporting it had done what it was asked. +func TestUnknownIconPackFallsBackToTheShippedMarks(t *testing.T) { + ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) + for _, chosen := range []string{"", "tabler", "material "} { + got := resolveTheme(themeMidnight, chosen, nil, true, true, ordinary) + if got.IconSet != defaultIconPackID { + t.Fatalf("resolveTheme(icon %q) = %q, want %q", chosen, got.IconSet, defaultIconPackID) + } + } +} + +// The viewer's marks stand on every scheme they can choose, and only a season may replace +// them — the same asymmetry decorations have, and for the same reason: a season is a look, +// where a scheme somebody picked to live with all year must not silently take their marks +// away with no way to tell which choice did it. +func TestOnlyASeasonMayReplaceTheViewersMarks(t *testing.T) { + ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) + if got := resolveTheme(themePlum, iconPackLucide, nil, true, true, ordinary); got.IconSet != iconPackLucide { + t.Fatalf("an ordinary day = %q, want the viewer's %q", got.IconSet, iconPackLucide) + } + + halloween := time.Date(2026, time.October, 30, 20, 0, 0, 0, time.UTC) + got := resolveTheme(themePlum, iconPackLucide, nil, true, true, halloween) + if !got.Seasonal { + t.Fatalf("expected the Halloween window to be seasonal") + } + if got.IconSet != iconPackFontAwesome { + t.Fatalf("Halloween = %q, want its own %q", got.IconSet, iconPackFontAwesome) + } + if got.Chosen != themePlum { + t.Fatalf("the viewer's own choice should survive underneath a season, got %q", got.Chosen) + } + + // Seasons off: the viewer keeps both halves. + if off := resolveTheme(themePlum, iconPackLucide, nil, false, true, halloween); off.IconSet != iconPackLucide { + t.Fatalf("with seasons off = %q, want the viewer's %q", off.IconSet, iconPackLucide) + } +} + +// Only seasonal themes may declare marks of their own, the rule that holds the asymmetry +// above in place by construction rather than by a check at the point of use. +func TestOnlySeasonalThemesDeclareAnIconSet(t *testing.T) { + for _, theme := range themeCatalogue { + if theme.Seasonal { + if theme.IconSet != "" && !knownIconPack(theme.IconSet) { + t.Fatalf("%s names an icon pack %q nothing can draw", theme.ID, theme.IconSet) + } + continue + } + if theme.IconSet != "" { + t.Fatalf("selectable theme %s takes the viewer's marks away", theme.ID) + } + } +} + +// The revision is the entire delivery mechanism: televisions compare it and refetch only +// when it moves. A pack change the hash did not notice would reach nobody until something +// else about the theme happened to change. +func TestThemeRevisionTracksTheIconSet(t *testing.T) { + ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC) + material := resolveTheme(themeMidnight, iconPackMaterial, nil, true, true, ordinary) + lucide := resolveTheme(themeMidnight, iconPackLucide, nil, true, true, ordinary) + if material.Revision == lucide.Revision { + t.Fatalf("the same revision %q for two different icon packs", material.Revision) + } +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index a4c528c..8893a8e 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.53 +0.1.55 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 118e7f6..045cbcd 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -131,6 +131,15 @@ type Config struct { // touching the five-day schedule row. SonarrAlertWindow time.Duration + // SonarrWebhookToken guards the Sonarr import/upgrade/rename/delete webhook. Empty + // means the hook 404s, the stance the Radarr one takes. + SonarrWebhookToken string + + // IngestSettleDelay is how long after a webhook the gateway first looks for the file + // in Emby. Sonarr fires the moment it has moved the file into place and Emby has not + // scanned it yet, so asking immediately spends a request to learn nothing. + IngestSettleDelay time.Duration + // Radarr is optional. Its calendar supplies the five-day digital movie release row. RadarrURL string RadarrAPIKey string @@ -247,6 +256,8 @@ func Load() (Config, error) { SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")), SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute), SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour), + SonarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_SONARR_WEBHOOK_TOKEN")), + IngestSettleDelay: duration("MEMBY_ARR_INGEST_SETTLE", time.Minute), RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"), RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")), RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute), diff --git a/server/internal/library/events.go b/server/internal/library/events.go new file mode 100644 index 0000000..2435f25 --- /dev/null +++ b/server/internal/library/events.go @@ -0,0 +1,355 @@ +package library + +import ( + "fmt" + "strings" +) + +// Turning a Sonarr or Radarr notification into a piece of work, and all of it pure. +// +// The gateway used to learn that a file had appeared by asking Emby every hour whether +// anything had been saved since the last time it asked. Sonarr and Radarr already know — +// they are the things that put the file there — so this is the translation from what they +// say into the one question the ingest worker answers: which item should be re-read, or +// removed, and how do two deliveries of the same news collapse into one. +// +// Nothing here does I/O, which is what lets every rule below be a table test. + +// Ingest actions. A rename is deliberately a refresh like any other: the Emby item id +// survives a file being moved, and so does the credits fingerprint measured against it — +// the only stale thing is the row's payload. +const ( + ActionRefresh = "refresh" + ActionRemove = "remove" +) + +// Ingest kinds. +const ( + KindEpisode = "episode" + KindMovie = "movie" + KindSeries = "series" +) + +// Why a request exists. It is carried through to the log and the console, because "this +// episode was re-read because Sonarr upgraded the file" is the sentence an operator needs +// and "an item changed" is not. +const ( + ReasonImport = "import" + ReasonUpgrade = "upgrade" + ReasonRename = "rename" + ReasonDelete = "delete" +) + +// IngestRequest is one piece of work. It carries what the *arr knew rather than an Emby +// id, because at the moment a webhook arrives Emby has very often not scanned the file in +// yet and there is no id to carry. +type IngestRequest struct { + // Key is the dedupe identity, and it names the *file* rather than the event. Two + // deliveries of one import collapse onto one row; a file deleted and re-imported is a + // different file and therefore its own work. Same reasoning as the alert id in + // radarrImportAlert. + Key string `json:"-"` + Action string `json:"-"` + Kind string `json:"-"` + Reason string `json:"-"` + + // Series identity, for an episode or a series-level event. + Series string `json:"series,omitempty"` + SeriesYear int `json:"seriesYear,omitempty"` + Season int `json:"season,omitempty"` + Episode int `json:"episode,omitempty"` + + // Film identity. + Title string `json:"title,omitempty"` + Year int `json:"year,omitempty"` + + // EmbyItemID is filled in only where the caller already knows it — a delete of + // something the catalogue holds. Empty is the ordinary case. + EmbyItemID string `json:"embyItemId,omitempty"` +} + +// IngestResult is a finished piece of ingest work, handed to whoever wants to announce it. +// +// It is what makes "a scan has completed" a thing the gateway can say: a webhook only means +// the *arr has moved a file, and the several minutes between that and Emby having scanned +// it in are exactly the minutes in which a banner saying the title is there would be wrong. +// This is emitted from the other end, once the row is in the catalogue. +// +// It carries both what the *arr said and what Emby turned out to call the thing, because +// the news is about the title and the item id is what can put artwork behind it. +type IngestResult struct { + Source string // sonarr | radarr + Kind string // KindEpisode | KindMovie | KindSeries + Reason string // ReasonImport | ReasonUpgrade | ReasonRename | ReasonDelete + + // ItemID and Name are Emby's, filled in from the row that was just written. ItemID is + // empty for a series-wide refresh, which is about a show rather than about one file. + ItemID string + Name string + ImageTag string + + // SeriesName is Emby's name for the show an episode belongs to, which is what a banner + // leads with — the episode's own Name is its title. + SeriesName string + Season int + Episode int + Year int +} + +// SonarrWebhook is the subset of Sonarr's body this reads. Sonarr sends considerably +// more; anything not named here is ignored on purpose, so a Sonarr upgrade that adds +// fields cannot break the hook. +type SonarrWebhook struct { + EventType string `json:"eventType"` + Series struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + } `json:"series"` + Episodes []struct { + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + } `json:"episodes"` + EpisodeFile struct { + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + } `json:"episodeFile"` + // RenamedEpisodeFiles is what On Rename carries: the files that moved, each with the + // id the library already knows them by. + RenamedEpisodeFiles []struct { + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + } `json:"renamedEpisodeFiles"` + IsUpgrade bool `json:"isUpgrade"` + // DeletedFiles marks a series delete that took the media with it. A series removed + // from Sonarr's list while its files stay on disk is not a reason to forget it. + DeletedFiles bool `json:"deletedFiles"` +} + +// RadarrWebhook is the same narrow reading of Radarr's body. +type RadarrWebhook struct { + EventType string `json:"eventType"` + IsUpgrade bool `json:"isUpgrade"` + Movie struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + } `json:"movie"` + MovieFile struct { + ID int `json:"id"` + } `json:"movieFile"` + MovieFileID int `json:"movieFileId"` + DeletedFiles bool `json:"deletedFiles"` +} + +// IsTestEvent reports the payload a webhook's Test button sends. It is answered 200 and +// enqueues nothing, which is what makes that button mean "reachable" rather than +// "reachable, and here is a row about a series that does not exist". +func IsTestEvent(eventType string) bool { + return strings.EqualFold(strings.TrimSpace(eventType), "Test") +} + +// SonarrRequests turns one Sonarr notification into the work it implies. +// +// A notification can name several episodes — a multi-episode file, or a rename that moved +// a season — so this answers a slice. Each carries its own key, because each is its own +// file and the two may well arrive again separately. +func SonarrRequests(payload SonarrWebhook) []IngestRequest { + event := strings.ToLower(strings.TrimSpace(payload.EventType)) + title := strings.TrimSpace(payload.Series.Title) + + switch event { + case "download", "episodefileimported": + if title == "" || len(payload.Episodes) == 0 { + return nil + } + reason := ReasonImport + if payload.IsUpgrade { + // The file genuinely changed, so the row must be re-read. That it is not + // *news* is a separate judgement, made by the alert half. + reason = ReasonUpgrade + } + out := make([]IngestRequest, 0, len(payload.Episodes)) + for _, episode := range payload.Episodes { + out = append(out, IngestRequest{ + Key: sonarrEpisodeKey(payload.EpisodeFile.ID, episode.ID), + Action: ActionRefresh, + Kind: KindEpisode, + Reason: reason, + Series: title, + SeriesYear: payload.Series.Year, + Season: episode.SeasonNumber, + Episode: episode.EpisodeNumber, + }) + } + return out + + case "rename": + if title == "" { + return nil + } + // A rename names files rather than episodes, and Sonarr does not say which episode + // each file held. The series is the unit of work: one re-read of the show's + // episodes settles every file that moved, and a season rename would otherwise be + // one request per episode for the same answer. + return []IngestRequest{{ + Key: fmt.Sprintf("sonarr:series:%d:rename", payload.Series.ID), + Action: ActionRefresh, + Kind: KindSeries, + Reason: ReasonRename, + Series: title, + SeriesYear: payload.Series.Year, + }} + + case "episodefiledelete", "episodefiledeleted": + if title == "" { + return nil + } + season, episode := deletedEpisodeNumbers(payload) + if episode <= 0 { + return nil + } + return []IngestRequest{{ + Key: fmt.Sprintf("sonarr:episodefile:%d:delete", payload.EpisodeFile.ID), + Action: ActionRemove, + Kind: KindEpisode, + Reason: ReasonDelete, + Series: title, + SeriesYear: payload.Series.Year, + Season: season, + Episode: episode, + }} + + case "seriesdelete", "seriesdeleted": + // Only a delete that took the files. A series unfollowed in Sonarr while its + // episodes stay on disk is still in the library and must stay in the catalogue. + if title == "" || !payload.DeletedFiles { + return nil + } + return []IngestRequest{{ + Key: fmt.Sprintf("sonarr:series:%d:delete", payload.Series.ID), + Action: ActionRemove, + Kind: KindSeries, + Reason: ReasonDelete, + Series: title, + SeriesYear: payload.Series.Year, + }} + } + return nil +} + +// sonarrEpisodeKey prefers the file id, which is the thing that actually changed. Sonarr +// omits it on some versions of the import event, and the episode id is then the only +// stable identity available — coarser, since it does not change when the file is +// replaced, but a repeated delivery still collapses, which is what the key is for. +func sonarrEpisodeKey(fileID, episodeID int) string { + if fileID > 0 { + return fmt.Sprintf("sonarr:episodefile:%d:%d", fileID, episodeID) + } + return fmt.Sprintf("sonarr:episode:%d", episodeID) +} + +// deletedEpisodeNumbers reads the position of a deleted file. The episode list is +// preferred because it carries the episode number; the file's own season number stands in +// where the list is absent. +func deletedEpisodeNumbers(payload SonarrWebhook) (int, int) { + for _, episode := range payload.Episodes { + if episode.EpisodeNumber > 0 { + season := episode.SeasonNumber + if season == 0 && payload.EpisodeFile.SeasonNumber > 0 { + season = payload.EpisodeFile.SeasonNumber + } + return season, episode.EpisodeNumber + } + } + return payload.EpisodeFile.SeasonNumber, 0 +} + +// RadarrRequests turns one Radarr notification into the work it implies. +func RadarrRequests(payload RadarrWebhook) []IngestRequest { + event := strings.ToLower(strings.TrimSpace(payload.EventType)) + title := strings.TrimSpace(payload.Movie.Title) + if title == "" || payload.Movie.ID <= 0 { + return nil + } + fileID := payload.MovieFile.ID + if fileID <= 0 { + fileID = payload.MovieFileID + } + + switch event { + case "download", "moviefileimported": + reason := ReasonImport + if payload.IsUpgrade { + reason = ReasonUpgrade + } + return []IngestRequest{{ + Key: radarrFileKey(payload.Movie.ID, fileID, reason), + Action: ActionRefresh, + Kind: KindMovie, + Reason: reason, + Title: title, + Year: payload.Movie.Year, + }} + + case "rename": + return []IngestRequest{{ + Key: fmt.Sprintf("radarr:movie:%d:rename", payload.Movie.ID), + Action: ActionRefresh, + Kind: KindMovie, + Reason: ReasonRename, + Title: title, + Year: payload.Movie.Year, + }} + + case "moviefiledelete", "moviefiledeleted": + return []IngestRequest{{ + Key: radarrFileKey(payload.Movie.ID, fileID, ReasonDelete), + Action: ActionRemove, + Kind: KindMovie, + Reason: ReasonDelete, + Title: title, + Year: payload.Movie.Year, + }} + + case "moviedelete", "moviedeleted": + if !payload.DeletedFiles { + return nil + } + return []IngestRequest{{ + Key: fmt.Sprintf("radarr:movie:%d:delete", payload.Movie.ID), + Action: ActionRemove, + Kind: KindMovie, + Reason: ReasonDelete, + Title: title, + Year: payload.Movie.Year, + }} + } + return nil +} + +func radarrFileKey(movieID, fileID int, reason string) string { + if fileID > 0 { + return fmt.Sprintf("radarr:moviefile:%d:%s", fileID, reason) + } + return fmt.Sprintf("radarr:movie:%d:%s", movieID, reason) +} + +// NormalizedTitle strips a title down to the letters and digits it shares with whatever +// the other system calls it, so "Marvel's Daredevil" and "Marvels Daredevil" are one show. +// +// It lives here because both halves of the gateway need it and there must be exactly one +// answer to "which show is this": the schedule row matches Sonarr titles against the Emby +// catalogue with it, and the ingest worker matches the same titles against Emby itself. +func NormalizedTitle(value string) string { + return strings.Map(func(r rune) rune { + if r >= 'A' && r <= 'Z' { + return r + ('a' - 'A') + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + return r + } + return -1 + }, value) +} diff --git a/server/internal/library/events_test.go b/server/internal/library/events_test.go new file mode 100644 index 0000000..860faf8 --- /dev/null +++ b/server/internal/library/events_test.go @@ -0,0 +1,229 @@ +package library + +import "testing" + +func sonarrDownload(upgrade bool) SonarrWebhook { + var payload SonarrWebhook + payload.EventType = "Download" + payload.IsUpgrade = upgrade + payload.Series.ID = 12 + payload.Series.Title = "Blue Bloods" + payload.Series.Year = 2010 + payload.EpisodeFile.ID = 8123 + payload.Episodes = append(payload.Episodes, struct { + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + }{ID: 551, SeasonNumber: 6, EpisodeNumber: 7}) + return payload +} + +func TestSonarrImportBecomesOneEpisodeRefresh(t *testing.T) { + requests := SonarrRequests(sonarrDownload(false)) + if len(requests) != 1 { + t.Fatalf("expected one request, got %d", len(requests)) + } + request := requests[0] + if request.Action != ActionRefresh || request.Kind != KindEpisode { + t.Fatalf("unexpected shape: %+v", request) + } + if request.Reason != ReasonImport { + t.Fatalf("expected an import, got %q", request.Reason) + } + if request.Series != "Blue Bloods" || request.Season != 6 || request.Episode != 7 { + t.Fatalf("unexpected identity: %+v", request) + } +} + +// An upgrade is silent as *news* and is still a reason to re-read the row: the file +// genuinely changed. Conflating those two judgements is how a replaced file would keep a +// catalogue entry describing the copy it replaced. +func TestSonarrUpgradeStillRefreshes(t *testing.T) { + requests := SonarrRequests(sonarrDownload(true)) + if len(requests) != 1 || requests[0].Reason != ReasonUpgrade { + t.Fatalf("expected one upgrade refresh, got %+v", requests) + } + if requests[0].Action != ActionRefresh { + t.Fatalf("an upgrade must refresh, got %q", requests[0].Action) + } +} + +// The key names the file, so two deliveries of one import are one piece of work. Both +// *arrs re-notify on retry and neither promises exactly-once. +func TestRepeatedDeliveryKeepsOneKey(t *testing.T) { + first := SonarrRequests(sonarrDownload(false)) + second := SonarrRequests(sonarrDownload(false)) + if first[0].Key != second[0].Key { + t.Fatalf("the same import produced two keys: %q and %q", first[0].Key, second[0].Key) + } + // A different file for the same episode is different work, or a replacement would be + // swallowed by the row its predecessor left behind. + replaced := sonarrDownload(true) + replaced.EpisodeFile.ID = 9001 + if SonarrRequests(replaced)[0].Key == first[0].Key { + t.Fatal("a replacement file must not reuse the previous file's key") + } +} + +// A multi-episode file names several episodes and each is its own row, because each may +// well be delivered again on its own. +func TestSonarrMultiEpisodeFileProducesOneRequestEach(t *testing.T) { + payload := sonarrDownload(false) + payload.Episodes = append(payload.Episodes, struct { + ID int `json:"id"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + }{ID: 552, SeasonNumber: 6, EpisodeNumber: 8}) + requests := SonarrRequests(payload) + if len(requests) != 2 { + t.Fatalf("expected two requests, got %d", len(requests)) + } + if requests[0].Key == requests[1].Key { + t.Fatal("two episodes of one file collapsed onto one key") + } +} + +// A rename is series-wide because Sonarr does not say which episode each moved file held, +// and it is a refresh rather than an invalidation: the Emby id survives a move. +func TestSonarrRenameRefreshesTheSeries(t *testing.T) { + var payload SonarrWebhook + payload.EventType = "Rename" + payload.Series.ID = 12 + payload.Series.Title = "Blue Bloods" + requests := SonarrRequests(payload) + if len(requests) != 1 { + t.Fatalf("expected one request, got %d", len(requests)) + } + if requests[0].Kind != KindSeries || requests[0].Action != ActionRefresh { + t.Fatalf("unexpected shape: %+v", requests[0]) + } + if requests[0].Reason != ReasonRename { + t.Fatalf("expected a rename, got %q", requests[0].Reason) + } +} + +func TestSonarrEpisodeDeleteRemovesThatEpisode(t *testing.T) { + payload := sonarrDownload(false) + payload.EventType = "EpisodeFileDelete" + requests := SonarrRequests(payload) + if len(requests) != 1 || requests[0].Action != ActionRemove { + t.Fatalf("expected one removal, got %+v", requests) + } + if requests[0].Season != 6 || requests[0].Episode != 7 { + t.Fatalf("unexpected position: %+v", requests[0]) + } +} + +// A series removed from Sonarr's list while its files stay on disk is still in the +// library. Only a delete that took the media with it removes anything. +func TestSonarrSeriesDeleteOnlyCountsWhenFilesWent(t *testing.T) { + var payload SonarrWebhook + payload.EventType = "SeriesDelete" + payload.Series.ID = 12 + payload.Series.Title = "Blue Bloods" + + if requests := SonarrRequests(payload); len(requests) != 0 { + t.Fatalf("an unfollowed series must not be removed: %+v", requests) + } + payload.DeletedFiles = true + requests := SonarrRequests(payload) + if len(requests) != 1 || requests[0].Action != ActionRemove || requests[0].Kind != KindSeries { + t.Fatalf("expected a series removal, got %+v", requests) + } +} + +func TestSonarrIgnoresEventsThatChangeNothing(t *testing.T) { + for _, event := range []string{"Grab", "Health", "ApplicationUpdate", "", "ManualInteractionRequired"} { + var payload SonarrWebhook + payload.EventType = event + payload.Series.Title = "Blue Bloods" + if requests := SonarrRequests(payload); len(requests) != 0 { + t.Fatalf("%q produced work: %+v", event, requests) + } + } +} + +func radarrDownload(upgrade bool) RadarrWebhook { + var payload RadarrWebhook + payload.EventType = "Download" + payload.IsUpgrade = upgrade + payload.Movie.ID = 44 + payload.Movie.Title = "Arrival" + payload.Movie.Year = 2016 + payload.MovieFile.ID = 441 + return payload +} + +func TestRadarrImportAndUpgradeBothRefresh(t *testing.T) { + imported := RadarrRequests(radarrDownload(false)) + if len(imported) != 1 || imported[0].Reason != ReasonImport || imported[0].Kind != KindMovie { + t.Fatalf("unexpected import: %+v", imported) + } + upgraded := RadarrRequests(radarrDownload(true)) + if len(upgraded) != 1 || upgraded[0].Reason != ReasonUpgrade { + t.Fatalf("unexpected upgrade: %+v", upgraded) + } + // The two are separate work: an upgrade of a file already imported must not be + // swallowed by the settled row its import left behind. + if imported[0].Key == upgraded[0].Key { + t.Fatal("an upgrade reused the import's key") + } +} + +func TestRadarrDeleteReadsTheTopLevelFileID(t *testing.T) { + var payload RadarrWebhook + payload.EventType = "MovieFileDelete" + payload.Movie.ID = 44 + payload.Movie.Title = "Arrival" + payload.MovieFileID = 441 + requests := RadarrRequests(payload) + if len(requests) != 1 || requests[0].Action != ActionRemove { + t.Fatalf("expected one removal, got %+v", requests) + } + if requests[0].Key != "radarr:moviefile:441:delete" { + t.Fatalf("unexpected key: %q", requests[0].Key) + } +} + +func TestRadarrMovieDeleteOnlyCountsWhenFilesWent(t *testing.T) { + var payload RadarrWebhook + payload.EventType = "MovieDelete" + payload.Movie.ID = 44 + payload.Movie.Title = "Arrival" + if requests := RadarrRequests(payload); len(requests) != 0 { + t.Fatalf("an unmonitored film must not be removed: %+v", requests) + } + payload.DeletedFiles = true + if requests := RadarrRequests(payload); len(requests) != 1 { + t.Fatalf("expected a removal, got %+v", requests) + } +} + +// The Test button must be answered without recording work about something that does not +// exist, which is what makes it mean "reachable". +func TestTestEventIsRecognisedFromEitherArr(t *testing.T) { + if !IsTestEvent("Test") || !IsTestEvent(" test ") { + t.Fatal("a test event was not recognised") + } + if IsTestEvent("Download") { + t.Fatal("an import was read as a test") + } + var sonarrTest SonarrWebhook + sonarrTest.EventType = "Test" + sonarrTest.Series.Title = "Test Title" + if requests := SonarrRequests(sonarrTest); len(requests) != 0 { + t.Fatalf("the test event produced work: %+v", requests) + } +} + +func TestNormalizedTitleIgnoresPunctuationAndCase(t *testing.T) { + if NormalizedTitle("Marvel's Daredevil") != NormalizedTitle("Marvels Daredevil") { + t.Fatal("punctuation changed the answer") + } + if NormalizedTitle("The Pitt") != "thepitt" { + t.Fatalf("unexpected normalisation: %q", NormalizedTitle("The Pitt")) + } + if NormalizedTitle(" ") != "" { + t.Fatal("a blank title must normalise to nothing") + } +} diff --git a/server/internal/library/ingest.go b/server/internal/library/ingest.go new file mode 100644 index 0000000..e67bd6e --- /dev/null +++ b/server/internal/library/ingest.go @@ -0,0 +1,740 @@ +package library + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// The worker that drains what Sonarr and Radarr told us. +// +// The scheduled import asks Emby "what has changed since an hour ago" and pages through +// the answer. This asks Emby "where is this one episode", which is a request whose size +// does not grow with the library, and it asks only because something that actually puts +// files on disk said there was a reason to. +// +// Emby is the *lookup* here and never the discovery mechanism. Nothing in this file +// enumerates a library, and the one thing that still does — Syncer.Schedule — is demoted +// to reconciliation for media the *arrs do not manage. + +const ( + // defaultSettleDelay is how long after a webhook the first attempt is made. Sonarr + // fires On Import the moment it has moved the file; Emby has not scanned it yet, and + // asking immediately would spend a request to learn that. + defaultSettleDelay = 60 * time.Second + + // idlePoll is how often the worker looks for due work. Coarse on purpose: everything + // here is already late by a settle delay, and a tight loop against Postgres on an idle + // NAS is exactly the background cost this replaces. + idlePoll = 20 * time.Second + + // claimBatch bounds one pass. A season pack arrives as a dozen notifications at once + // and there is no hurry: draining a few per pass keeps Emby's request rate flat. + claimBatch = 4 + + // maxAttempts is where a piece of work is given up on. With the backoff below that is + // most of a day, after which the item is the reconciliation sweep's problem — which is + // the honest answer, since something other than timing is wrong by then. + maxAttempts = 7 + + // jobBudget bounds one piece of work end to end. + jobBudget = 60 * time.Second +) + +// IngestStore is the slice of the store this needs. Narrow so the whole worker can be +// exercised against maps in a test, and so it is visible at a glance that the only things +// it writes are catalogue rows and the queue's own state. +type IngestStore interface { + EnqueueIngest(ctx context.Context, job store.IngestJob) (bool, error) + ClaimIngest(ctx context.Context, now time.Time, limit int) ([]store.IngestJob, error) + FinishIngest(ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time) error + UpsertLibraryItems(ctx context.Context, items []store.LibraryItem, syncedAt time.Time) (int64, error) + DeleteLibraryItem(ctx context.Context, itemID string) (int64, error) + SeriesRefs(ctx context.Context) ([]store.SeriesRef, error) + CreditsSeriesEpisodes(ctx context.Context, seriesIDs []string) ([]store.CreditsEpisodeRow, error) + LibraryItemsByName(ctx context.Context, itemType, name string) ([]store.NamedItem, error) +} + +// EmbySource is the slice of Emby this needs: two reads and one nudge. +type EmbySource interface { + Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error) + Episodes(ctx context.Context, cred emby.Credentials, seriesID string, params url.Values) (*emby.ItemsResult, error) + RefreshItem(ctx context.Context, cred emby.Credentials, itemID string) error +} + +// Ingester drains the durable queue. +type Ingester struct { + Store IngestStore + Emby EmbySource + Credentials func(ctx context.Context) (emby.Credentials, error) + Log *slog.Logger + // Paused is the server-wide quiet-time gate. The queue is durable precisely so this can + // say no: a webhook that arrives during quiet hours is recorded and read afterwards, + // where the old arrangement answered it 503 and lost the event outright. + Paused func() bool + // Settle is the delay applied when work is enqueued. Held here so the hook and the + // worker cannot disagree about it. + Settle time.Duration + // Announce is told about a finished import, so the news reaches the televisions from + // the moment the title is actually there rather than from the moment the *arr said it + // would be. Installed from main.go, like syncer.SetAfterSync and for the same reason: + // library has no business knowing what an alert is. Nil is ordinary — a gateway with + // nothing to announce to, and every test in this package. + Announce func(ctx context.Context, result IngestResult) +} + +func (i *Ingester) log() *slog.Logger { + if i == nil || i.Log == nil { + return slog.Default() + } + return i.Log +} + +// SettleDelay is what the hook stamps onto a new row. +func (i *Ingester) SettleDelay() time.Duration { + if i == nil || i.Settle <= 0 { + return defaultSettleDelay + } + return i.Settle +} + +// Run is the worker. One goroutine for the whole gateway. +func (i *Ingester) Run(ctx context.Context) { + if i == nil || i.Store == nil || i.Emby == nil || i.Credentials == nil { + return + } + i.log().Info("library ingest worker started", "settle", i.SettleDelay().String()) + for { + if ctx.Err() != nil { + return + } + worked := false + if i.Paused == nil || !i.Paused() { + worked = i.drain(ctx) + } + if worked { + continue + } + if !sleep(ctx, idlePoll) { + return + } + } +} + +// drain works everything currently due and reports whether it did anything, so a busy +// queue is emptied without waiting a poll interval between rows. +func (i *Ingester) drain(ctx context.Context) bool { + jobs, err := i.Store.ClaimIngest(ctx, time.Now().UTC(), claimBatch) + if err != nil { + if ctx.Err() == nil { + i.log().Warn("could not read the ingest queue", "error", err) + } + return false + } + if len(jobs) == 0 { + return false + } + for _, job := range jobs { + if ctx.Err() != nil { + return false + } + jobCtx, cancel := context.WithTimeout(ctx, jobBudget) + i.work(jobCtx, job) + cancel() + } + return true +} + +// work is one row, start to finish. Every exit records an outcome, because the row *is* +// the operator's answer to "why was this item re-read, and did it work". +func (i *Ingester) work(ctx context.Context, job store.IngestJob) { + var request IngestRequest + if err := json.Unmarshal(job.Payload, &request); err != nil { + i.settle(ctx, job, store.IngestFailed, "invalid", "", err) + return + } + request.Key, request.Action = job.Key, job.Action + request.Kind, request.Reason = job.Kind, job.Reason + + cred, err := i.Credentials(ctx) + if err != nil { + // Nobody has signed in yet, so there is no way to ask Emby anything. That is a + // deferral rather than a failure: the work is still valid, it simply cannot be + // done until a television signs in. + i.defer_(ctx, job, "no_credentials", err) + return + } + + if job.Action == ActionRemove { + i.remove(ctx, job, request) + return + } + i.refresh(ctx, job, request, cred) +} + +// refresh is the ordinary path: find the item in Emby and write it into the catalogue. +func (i *Ingester) refresh( + ctx context.Context, job store.IngestJob, request IngestRequest, cred emby.Credentials, +) { + items, itemID, err := i.resolve(ctx, request, cred) + if err != nil { + i.defer_(ctx, job, "lookup_failed", err) + return + } + if len(items) == 0 { + // Emby has not scanned the file in yet, which on a fresh import is the expected + // first answer rather than a fault. One nudge, then wait: the backoff is what turns + // "not yet" into "not ever" without a request per minute in between. + i.nudge(ctx, request, cred) + i.defer_(ctx, job, "not_found", nil) + return + } + + written := make([]store.LibraryItem, 0, len(items)) + for _, raw := range items { + if item, ok := toLibraryItem(raw); ok { + written = append(written, item) + } + } + if len(written) == 0 { + i.defer_(ctx, job, "not_found", nil) + return + } + // Stamped now, like any other import, so a title written here is never the victim of a + // full pass that happens to be running. + if _, err := i.Store.UpsertLibraryItems(ctx, written, time.Now().UTC()); err != nil { + i.defer_(ctx, job, "write_failed", err) + return + } + i.settle(ctx, job, store.IngestDone, "imported", itemID, nil) + i.log().Info("arr ingest", + "event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason, + "kind", job.Kind, "outcome", "imported", "items", len(written), + "item", itemID, "attempts", job.Attempts+1) + // After the row is recorded, never before: the announcement is a claim that the title + // is in the catalogue, and it must not be made by a pass that then failed to record it. + i.announce(ctx, job, request, written, itemID) +} + +// announce reports a finished scan, if anybody is listening. +// +// Whether a given import is worth a banner is deliberately not decided here — that is a +// question about what viewers should be told, which belongs with the rest of the alert +// wording. This says what happened; the API package decides what to say about it. +func (i *Ingester) announce( + ctx context.Context, job store.IngestJob, request IngestRequest, + written []store.LibraryItem, itemID string, +) { + if i.Announce == nil { + return + } + result := IngestResult{ + Source: job.Source, + Kind: job.Kind, + Reason: job.Reason, + ItemID: itemID, + SeriesName: request.Series, + Season: request.Season, + Episode: request.Episode, + Name: request.Title, + Year: request.Year, + } + // Emby's own record of the item outranks what the *arr called it: they disagree about + // punctuation and about years often enough that the banner and the card underneath it + // would otherwise name the same thing two ways. + if item, found := findWritten(written, itemID); found { + result.Name = item.Name + result.ImageTag = primaryImageTag(item.Payload) + if item.SeriesName != "" { + result.SeriesName = item.SeriesName + } + if item.ProductionYear != nil { + result.Year = *item.ProductionYear + } + } + i.Announce(ctx, result) +} + +func findWritten(written []store.LibraryItem, itemID string) (store.LibraryItem, bool) { + if itemID == "" { + return store.LibraryItem{}, false + } + for _, item := range written { + if item.ID == itemID { + return item, true + } + } + return store.LibraryItem{}, false +} + +// primaryImageTag digs the poster tag out of the payload that was just stored, so a banner +// can carry artwork without a second lookup. An absent tag is ordinary and costs nothing: +// the alert simply travels without one. +func primaryImageTag(payload json.RawMessage) string { + var parsed struct { + ImageTags map[string]string `json:"ImageTags"` + } + if json.Unmarshal(payload, &parsed) != nil { + return "" + } + return parsed.ImageTags["Primary"] +} + +// remove takes a deleted title out of the catalogue. +// +// It resolves against the *local* catalogue rather than against Emby, which is the one +// place in this file that is deliberately the other way round: the thing being removed is +// a row in Memby's copy, and Emby — having had the file deleted underneath it — is the +// least likely place to still be able to name it. +func (i *Ingester) remove(ctx context.Context, job store.IngestJob, request IngestRequest) { + itemID, err := i.localItemID(ctx, request) + if err != nil { + i.defer_(ctx, job, "lookup_failed", err) + return + } + if itemID == "" { + // Nothing to remove. Ordinary rather than a failure: the catalogue may never have + // held it, or a previous delivery of this event already did the work. + i.settle(ctx, job, store.IngestDone, "absent", "", nil) + i.log().Info("arr ingest", + "event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason, + "kind", job.Kind, "outcome", "absent") + return + } + removed, err := i.Store.DeleteLibraryItem(ctx, itemID) + if err != nil { + i.defer_(ctx, job, "delete_failed", err) + return + } + i.settle(ctx, job, store.IngestDone, "removed", itemID, nil) + i.log().Info("arr ingest", + "event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason, + "kind", job.Kind, "outcome", "removed", "rows", removed, "item", itemID) +} + +// resolve turns what the *arr said into Emby items, narrowly. +// +// The second return is the item the work was about, for the log and the console. It is +// empty for a series-wide refresh, which is about a show rather than about one file. +func (i *Ingester) resolve( + ctx context.Context, request IngestRequest, cred emby.Credentials, +) ([]json.RawMessage, string, error) { + switch request.Kind { + case KindMovie: + return i.resolveMovie(ctx, request, cred) + case KindEpisode, KindSeries: + return i.resolveFromSeries(ctx, request, cred) + } + return nil, "", fmt.Errorf("library: unknown ingest kind %q", request.Kind) +} + +func (i *Ingester) resolveMovie( + ctx context.Context, request IngestRequest, cred emby.Credentials, +) ([]json.RawMessage, string, error) { + page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{ + "SearchTerm": {request.Title}, + "IncludeItemTypes": {"Movie"}, + "Recursive": {"true"}, + "Limit": {"20"}, + })) + if err != nil { + return nil, "", err + } + if page == nil { + return nil, "", nil + } + match, id := pickByTitle(page.Items, request.Title, request.Year) + if match == nil { + return nil, "", nil + } + return []json.RawMessage{match}, id, nil +} + +// resolveFromSeries handles both an episode and a whole-series refresh, because they share +// the expensive half: working out which Emby show this is. +func (i *Ingester) resolveFromSeries( + ctx context.Context, request IngestRequest, cred emby.Credentials, +) ([]json.RawMessage, string, error) { + seriesID, seriesPayload, err := i.seriesItem(ctx, request, cred) + if err != nil { + return nil, "", err + } + if seriesID == "" { + return nil, "", nil + } + + params := itemQuery(url.Values{}) + if request.Kind == KindEpisode && request.Season > 0 { + // One season rather than a show. A long-running series is a thousand records and + // this runs per imported file. + params.Set("Season", strconv.Itoa(request.Season)) + } + page, err := i.Emby.Episodes(ctx, cred, seriesID, params) + if err != nil { + return nil, "", err + } + + out := make([]json.RawMessage, 0, 8) + if seriesPayload != nil { + // A show Emby has only just created has no row here yet, and its episodes would be + // imported as children of a series the catalogue has never heard of. + out = append(out, seriesPayload) + } + if page == nil { + return out, "", nil + } + if request.Kind == KindSeries { + // A rename moved files; which files is not something Sonarr says, so the show is + // the unit of work and one re-read settles all of them. + return append(out, page.Items...), seriesID, nil + } + + for _, raw := range page.Items { + var parsed struct { + ID string `json:"Id"` + IndexNumber *int `json:"IndexNumber"` + ParentIndexNumber *int `json:"ParentIndexNumber"` + } + if json.Unmarshal(raw, &parsed) != nil || parsed.IndexNumber == nil { + continue + } + if *parsed.IndexNumber != request.Episode { + continue + } + if parsed.ParentIndexNumber != nil && *parsed.ParentIndexNumber != request.Season { + continue + } + return append(out, raw), parsed.ID, nil + } + // The series is there and the episode is not: Emby has the show but has not scanned the + // new file. Reporting nothing found keeps that on the deferral path — but the series + // payload is still worth writing if it was new. + if len(out) > 0 { + if _, err := i.Store.UpsertLibraryItems(ctx, seriesItems(out), time.Now().UTC()); err != nil { + i.log().Debug("could not write the series row ahead of its episode", "error", err) + } + } + return nil, "", nil +} + +// seriesItem answers which Emby series this is, preferring the catalogue. +// +// The local index is one query the gateway already makes elsewhere and it is right for +// every show that has ever been imported. Emby is asked only when it misses, which is +// exactly the case this feature exists for — a brand-new show whose first episode has just +// landed — and the payload comes back with it so the series row can be written too. +func (i *Ingester) seriesItem( + ctx context.Context, request IngestRequest, cred emby.Credentials, +) (string, json.RawMessage, error) { + if id := i.localSeriesID(ctx, request.Series, request.SeriesYear); id != "" { + return id, nil, nil + } + page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{ + "SearchTerm": {request.Series}, + "IncludeItemTypes": {"Series"}, + "Recursive": {"true"}, + "Limit": {"20"}, + })) + if err != nil { + return "", nil, err + } + if page == nil { + return "", nil, nil + } + match, id := pickByTitle(page.Items, request.Series, request.SeriesYear) + return id, match, nil +} + +func (i *Ingester) localSeriesID(ctx context.Context, title string, year int) string { + refs, err := i.Store.SeriesRefs(ctx) + if err != nil { + i.log().Debug("series index unavailable for ingest", "error", err) + return "" + } + return matchByTitle(refs, title, year) +} + +// localItemID resolves a delete against the catalogue. +func (i *Ingester) localItemID(ctx context.Context, request IngestRequest) (string, error) { + switch request.Kind { + case KindMovie: + named, err := i.Store.LibraryItemsByName(ctx, "Movie", request.Title) + if err != nil { + return "", err + } + return matchNamed(named, request.Title, request.Year), nil + + case KindSeries: + return i.localSeriesID(ctx, request.Series, request.SeriesYear), nil + + case KindEpisode: + seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear) + if seriesID == "" { + return "", nil + } + episodes, err := i.Store.CreditsSeriesEpisodes(ctx, []string{seriesID}) + if err != nil { + return "", err + } + for _, episode := range episodes { + if episode.Episode == request.Episode && episode.Season == request.Season { + return episode.ItemID, nil + } + } + } + return "", nil +} + +// nudge asks Emby to look at the folder the file landed in. +// +// Best-effort and deliberately unreported: it is the same trick the subtitle download uses +// after Bazarr writes a sidecar, and a household whose Emby scans on its own does not need +// it. Refusing to nudge without a parent is the important half — a refresh of nothing is a +// request that cannot help. +func (i *Ingester) nudge(ctx context.Context, request IngestRequest, cred emby.Credentials) { + if request.Kind == KindMovie { + return + } + seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear) + if seriesID == "" { + return + } + if err := i.Emby.RefreshItem(ctx, cred, seriesID); err != nil { + i.log().Debug("could not ask emby to rescan a series", "series", seriesID, "error", err) + } +} + +// defer_ schedules another attempt, or gives up. +func (i *Ingester) defer_(ctx context.Context, job store.IngestJob, outcome string, cause error) { + attempts := job.Attempts + 1 + if attempts >= maxAttempts { + i.settle(ctx, job, store.IngestFailed, outcome, "", cause) + i.log().Warn("arr ingest gave up", + "event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason, + "kind", job.Kind, "outcome", outcome, "attempts", attempts, "error", errorText(cause)) + return + } + retryAt := time.Now().UTC().Add(IngestRetryDelay(attempts)) + if err := i.Store.FinishIngest( + ctx, job.Key, store.IngestPending, outcome, "", errorText(cause), retryAt, + ); err != nil { + i.log().Warn("could not reschedule ingest work", "key", job.Key, "error", err) + } + i.log().Debug("arr ingest deferred", + "event", "arr_ingest", "key", job.Key, "reason", job.Reason, "outcome", outcome, + "attempts", attempts, "retry_in", IngestRetryDelay(attempts).String(), + "error", errorText(cause)) +} + +func (i *Ingester) settle( + ctx context.Context, job store.IngestJob, state, outcome, itemID string, cause error, +) { + // Detached from the job's own budget: a row that timed out must still record that it + // did, or the next pass claims it again immediately and the backoff never applies. + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := i.Store.FinishIngest( + writeCtx, job.Key, state, outcome, itemID, errorText(cause), time.Now().UTC(), + ); err != nil { + i.log().Warn("could not record ingest outcome", "key", job.Key, "error", err) + } +} + +// IngestRetryDelay is the backoff, and it is a step function rather than an exponent so +// the schedule can be read off the page: a minute, five, twenty, an hour, then four-hourly +// out to the attempt limit. The early steps are short because the common cause is Emby not +// having scanned yet, which resolves in minutes; the late ones are long because by then the +// cause is something a faster retry cannot fix. +func IngestRetryDelay(attempts int) time.Duration { + switch { + case attempts <= 1: + return time.Minute + case attempts == 2: + return 5 * time.Minute + case attempts == 3: + return 20 * time.Minute + case attempts == 4: + return time.Hour + default: + return 4 * time.Hour + } +} + +// itemQuery is the field set every lookup here uses, and it is deliberately the scheduled +// import's own. +// +// Thinning it would leave an event-imported title without People, MediaStreams or +// ProviderIds — so no cast on its page, no ratings lookup and no format badges — until Emby +// next reported it changed, which for a film nobody edits again is never. Syncer.Find makes +// the same promise for the same reason. +func itemQuery(params url.Values) url.Values { + params.Set("Fields", syncFields) + params.Set("ImageTypeLimit", "1") + params.Set("EnableImages", "true") + params.Set("EnableImageTypes", syncImageTypes) + params.Set("EnableTotalRecordCount", "false") + params.Set("EnableUserData", "false") + return params +} + +// pickByTitle chooses the item a title and year names. +// +// Year-qualified first and title-only as the fallback, the rule the schedule row's series +// index already applies: an *arr and Emby disagree about a show's year far more often than +// they disagree about its name, but where both know the year it is what separates a remake +// from its original. +func pickByTitle(items []json.RawMessage, title string, year int) (json.RawMessage, string) { + want := NormalizedTitle(title) + if want == "" { + return nil, "" + } + var fallback json.RawMessage + var fallbackID string + for _, raw := range items { + var parsed struct { + ID string `json:"Id"` + Name string `json:"Name"` + ProductionYear *int `json:"ProductionYear"` + } + if json.Unmarshal(raw, &parsed) != nil || parsed.ID == "" { + continue + } + if NormalizedTitle(parsed.Name) != want { + continue + } + if year > 0 && parsed.ProductionYear != nil && *parsed.ProductionYear == year { + return raw, parsed.ID + } + if fallback == nil { + fallback, fallbackID = raw, parsed.ID + } + } + return fallback, fallbackID +} + +func matchByTitle(refs []store.SeriesRef, title string, year int) string { + want := NormalizedTitle(title) + if want == "" { + return "" + } + fallback := "" + for _, ref := range refs { + if NormalizedTitle(ref.Name) != want { + continue + } + if year > 0 && ref.Year == year { + return ref.ID + } + if fallback == "" { + fallback = ref.ID + } + } + return fallback +} + +func matchNamed(items []store.NamedItem, title string, year int) string { + want := NormalizedTitle(title) + if want == "" { + return "" + } + fallback := "" + for _, item := range items { + if NormalizedTitle(item.Name) != want { + continue + } + if year > 0 && item.Year == year { + return item.ID + } + if fallback == "" { + fallback = item.ID + } + } + return fallback +} + +// seriesItems is the series payload on its own, for the case where the episode has not +// appeared yet but the show has. +func seriesItems(payloads []json.RawMessage) []store.LibraryItem { + out := make([]store.LibraryItem, 0, len(payloads)) + for _, raw := range payloads { + if item, ok := toLibraryItem(raw); ok && item.Type == "Series" { + out = append(out, item) + } + } + return out +} + +func errorText(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +func sleep(ctx context.Context, duration time.Duration) bool { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +// Enqueue records work a webhook implied, and answers how much of it was news. +// +// It is the hook's whole job. Everything expensive happens later, on the worker, which is +// what lets the hook answer Sonarr in a millisecond and — more importantly — what lets it +// answer at all during quiet hours, when the work itself must wait. +func (i *Ingester) Enqueue( + ctx context.Context, source string, requests []IngestRequest, +) (int, error) { + if i == nil || i.Store == nil || len(requests) == 0 { + return 0, nil + } + due := time.Now().UTC().Add(i.SettleDelay()) + fresh := 0 + var firstErr error + for _, request := range requests { + payload, err := json.Marshal(request) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + inserted, err := i.Store.EnqueueIngest(ctx, store.IngestJob{ + Key: request.Key, + Action: request.Action, + Kind: request.Kind, + Reason: request.Reason, + Source: source, + Payload: payload, + DueAt: due, + }) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if inserted { + fresh++ + continue + } + // A repeat delivery is ordinary — both *arrs re-notify on retry — so it is DEBUG, + // the same stance the per-keystroke search line takes. + i.log().Debug("arr ingest already queued", + "event", "arr_ingest", "key", request.Key, "source", source, "reason", request.Reason) + } + return fresh, firstErr +} diff --git a/server/internal/library/ingest_test.go b/server/internal/library/ingest_test.go new file mode 100644 index 0000000..d72ed1c --- /dev/null +++ b/server/internal/library/ingest_test.go @@ -0,0 +1,507 @@ +package library + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/url" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// fakeStore is the queue and the catalogue as maps. Everything the worker does is visible +// in it, which is the point of the store being an interface here. +type fakeStore struct { + jobs map[string]*store.IngestJob + order []string + items map[string]store.LibraryItem + deleted []string + series []store.SeriesRef + episodes []store.CreditsEpisodeRow + named []store.NamedItem + failNext error +} + +func newFakeStore() *fakeStore { + return &fakeStore{jobs: map[string]*store.IngestJob{}, items: map[string]store.LibraryItem{}} +} + +func (f *fakeStore) EnqueueIngest(_ context.Context, job store.IngestJob) (bool, error) { + existing, found := f.jobs[job.Key] + if found { + // The real table's ON CONFLICT: one row, and a re-delivery never pulls the settle + // delay forward. + if job.DueAt.After(existing.DueAt) { + existing.DueAt = job.DueAt + } + existing.State = store.IngestPending + return false, nil + } + stored := job + stored.State = store.IngestPending + f.jobs[job.Key] = &stored + f.order = append(f.order, job.Key) + return true, nil +} + +func (f *fakeStore) ClaimIngest(_ context.Context, now time.Time, limit int) ([]store.IngestJob, error) { + out := []store.IngestJob{} + for _, key := range f.order { + job := f.jobs[key] + if job.State != store.IngestPending || job.DueAt.After(now) { + continue + } + out = append(out, *job) + if len(out) == limit { + break + } + } + return out, nil +} + +func (f *fakeStore) FinishIngest( + _ context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time, +) error { + job, found := f.jobs[key] + if !found { + return errors.New("no such job") + } + job.State, job.Outcome, job.ItemID = state, outcome, itemID + job.LastError, job.DueAt = errorText, retryAt + job.Attempts++ + return nil +} + +func (f *fakeStore) UpsertLibraryItems( + _ context.Context, items []store.LibraryItem, _ time.Time, +) (int64, error) { + if f.failNext != nil { + err := f.failNext + f.failNext = nil + return 0, err + } + for _, item := range items { + f.items[item.ID] = item + } + return int64(len(items)), nil +} + +func (f *fakeStore) DeleteLibraryItem(_ context.Context, itemID string) (int64, error) { + f.deleted = append(f.deleted, itemID) + delete(f.items, itemID) + return 1, nil +} + +func (f *fakeStore) SeriesRefs(context.Context) ([]store.SeriesRef, error) { return f.series, nil } + +func (f *fakeStore) CreditsSeriesEpisodes( + context.Context, []string, +) ([]store.CreditsEpisodeRow, error) { + return f.episodes, nil +} + +func (f *fakeStore) LibraryItemsByName( + _ context.Context, _, _ string, +) ([]store.NamedItem, error) { + return f.named, nil +} + +// fakeEmby answers the two lookups and counts the nudges. +type fakeEmby struct { + items []json.RawMessage + episodes []json.RawMessage + refreshed []string + itemQueries []url.Values + seasons []string + err error +} + +func (f *fakeEmby) Items( + _ context.Context, _ emby.Credentials, params url.Values, +) (*emby.ItemsResult, error) { + f.itemQueries = append(f.itemQueries, params) + if f.err != nil { + return nil, f.err + } + return &emby.ItemsResult{Items: f.items}, nil +} + +func (f *fakeEmby) Episodes( + _ context.Context, _ emby.Credentials, _ string, params url.Values, +) (*emby.ItemsResult, error) { + f.seasons = append(f.seasons, params.Get("Season")) + if f.err != nil { + return nil, f.err + } + return &emby.ItemsResult{Items: f.episodes}, nil +} + +func (f *fakeEmby) RefreshItem(_ context.Context, _ emby.Credentials, itemID string) error { + f.refreshed = append(f.refreshed, itemID) + return nil +} + +func testIngester(st *fakeStore, source *fakeEmby) *Ingester { + return &Ingester{ + Store: st, + Emby: source, + Credentials: func(context.Context) (emby.Credentials, error) { + return emby.Credentials{UserID: "u", Token: "t"}, nil + }, + Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + Settle: time.Minute, + } +} + +func episodePayload(id string, season, episode int) json.RawMessage { + raw, _ := json.Marshal(map[string]any{ + "Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series", + "ParentIndexNumber": season, "IndexNumber": episode, + }) + return raw +} + +func enqueueOne(t *testing.T, ingester *Ingester, request IngestRequest) { + t.Helper() + if _, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}); err != nil { + t.Fatalf("enqueue: %v", err) + } +} + +func episodeRequest() IngestRequest { + return IngestRequest{ + Key: "sonarr:episodefile:8123:551", Action: ActionRefresh, Kind: KindEpisode, + Reason: ReasonImport, Series: "Blue Bloods", SeriesYear: 2010, Season: 6, Episode: 7, + } +} + +// The ordinary path: the series is already in the catalogue, so Emby is asked for one +// season and the episode is written. +func TestImportWritesTheEpisodeFromOneSeasonLookup(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + source := &fakeEmby{episodes: []json.RawMessage{ + episodePayload("emby-ep-6", 6, 6), + episodePayload("emby-ep-7", 6, 7), + }} + ingester := testIngester(st, source) + enqueueOne(t, ingester, episodeRequest()) + + ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"]) + + if _, written := st.items["emby-ep-7"]; !written { + t.Fatalf("the episode was not written: %v", st.items) + } + if _, extra := st.items["emby-ep-6"]; extra { + t.Fatal("an episode nobody asked about was written") + } + if len(source.seasons) != 1 || source.seasons[0] != "6" { + t.Fatalf("expected one season-scoped lookup, got %v", source.seasons) + } + // A series already in the catalogue costs no search at all. + if len(source.itemQueries) != 0 { + t.Fatalf("the catalogue was not used for the series: %v", source.itemQueries) + } + job := st.jobs["sonarr:episodefile:8123:551"] + if job.State != store.IngestDone || job.Outcome != "imported" { + t.Fatalf("unexpected outcome: %+v", job) + } +} + +// The field set must be the scheduled import's own, or an event-imported title arrives +// without the cast, streams and provider ids everything downstream reads. +func TestLookupsAskForTheFullSyncFields(t *testing.T) { + st := newFakeStore() + source := &fakeEmby{} + ingester := testIngester(st, source) + enqueueOne(t, ingester, IngestRequest{ + Key: "radarr:moviefile:441:import", Action: ActionRefresh, Kind: KindMovie, + Reason: ReasonImport, Title: "Arrival", Year: 2016, + }) + ingester.work(context.Background(), *st.jobs["radarr:moviefile:441:import"]) + + if len(source.itemQueries) != 1 { + t.Fatalf("expected one lookup, got %d", len(source.itemQueries)) + } + query := source.itemQueries[0] + if query.Get("Fields") != syncFields { + t.Fatalf("a thinner field set was requested: %q", query.Get("Fields")) + } + if query.Get("EnableUserData") != "false" { + t.Fatal("the shared catalogue must never carry one viewer's user data") + } +} + +// Emby not having scanned the file yet is the expected first answer, not a fault: the row +// waits, one nudge is sent, and the backoff widens. +func TestNotFoundDefersWithABackoffRatherThanFailing(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + source := &fakeEmby{} + ingester := testIngester(st, source) + enqueueOne(t, ingester, episodeRequest()) + key := "sonarr:episodefile:8123:551" + + before := time.Now().UTC() + ingester.work(context.Background(), *st.jobs[key]) + + job := st.jobs[key] + if job.State != store.IngestPending || job.Outcome != "not_found" { + t.Fatalf("expected a deferral, got %+v", job) + } + if !job.DueAt.After(before) { + t.Fatal("the next attempt was not scheduled into the future") + } + if len(source.refreshed) != 1 || source.refreshed[0] != "emby-series" { + t.Fatalf("expected one rescan nudge at the series, got %v", source.refreshed) + } + if len(st.items) != 0 { + t.Fatal("nothing should have been written") + } +} + +// Attempts are given up on eventually, because past the last step of the backoff the cause +// is not timing and a row retrying for ever is one nobody looks at. +func TestRepeatedFailureIsEventuallyGivenUpOn(t *testing.T) { + st := newFakeStore() + source := &fakeEmby{err: errors.New("emby is not answering")} + ingester := testIngester(st, source) + enqueueOne(t, ingester, episodeRequest()) + key := "sonarr:episodefile:8123:551" + + for attempt := 0; attempt < maxAttempts; attempt++ { + ingester.work(context.Background(), *st.jobs[key]) + } + if st.jobs[key].State != store.IngestFailed { + t.Fatalf("expected the row to be given up on, got %+v", st.jobs[key]) + } + if st.jobs[key].LastError == "" { + t.Fatal("a failed row must record why") + } +} + +func TestRetryDelayWidensAndSettles(t *testing.T) { + previous := time.Duration(0) + for attempt := 1; attempt <= 6; attempt++ { + delay := IngestRetryDelay(attempt) + if delay < previous { + t.Fatalf("the backoff narrowed at attempt %d: %s after %s", attempt, delay, previous) + } + previous = delay + } + if IngestRetryDelay(1) != time.Minute { + t.Fatalf("the first retry should be quick, got %s", IngestRetryDelay(1)) + } +} + +// A delete resolves against the catalogue, not against Emby: the file is gone, and Emby is +// the least likely thing to still be able to name it. +func TestDeleteRemovesTheEpisodeFromTheCatalogue(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + st.episodes = []store.CreditsEpisodeRow{ + {ItemID: "emby-ep-6", SeriesID: "emby-series", Season: 6, Episode: 6}, + {ItemID: "emby-ep-7", SeriesID: "emby-series", Season: 6, Episode: 7}, + } + source := &fakeEmby{} + ingester := testIngester(st, source) + request := episodeRequest() + request.Action, request.Reason, request.Key = ActionRemove, ReasonDelete, "sonarr:episodefile:8123:delete" + enqueueOne(t, ingester, request) + + ingester.work(context.Background(), *st.jobs[request.Key]) + + if len(st.deleted) != 1 || st.deleted[0] != "emby-ep-7" { + t.Fatalf("unexpected deletions: %v", st.deleted) + } + if len(source.itemQueries) != 0 || len(source.seasons) != 0 { + t.Fatal("a delete must not need to ask Emby anything") + } +} + +// A delete of something the catalogue never held is settled rather than retried: there is +// nothing to remove and no later attempt could change that. +func TestDeleteOfSomethingAbsentSettlesQuietly(t *testing.T) { + st := newFakeStore() + ingester := testIngester(st, &fakeEmby{}) + request := IngestRequest{ + Key: "radarr:moviefile:9:delete", Action: ActionRemove, Kind: KindMovie, + Reason: ReasonDelete, Title: "Never Imported", Year: 1999, + } + enqueueOne(t, ingester, request) + ingester.work(context.Background(), *st.jobs[request.Key]) + + job := st.jobs[request.Key] + if job.State != store.IngestDone || job.Outcome != "absent" { + t.Fatalf("expected a quiet settle, got %+v", job) + } + if len(st.deleted) != 0 { + t.Fatalf("something was deleted: %v", st.deleted) + } +} + +// A brand-new show is the case the local index cannot answer, and it is exactly the case +// this feature exists for. Emby is asked, and the series row is written beside its episode +// so the episode is not a child of a show the catalogue has never heard of. +func TestANewSeriesIsResolvedThroughEmbyAndWrittenToo(t *testing.T) { + st := newFakeStore() + seriesRaw, _ := json.Marshal(map[string]any{ + "Id": "emby-series", "Name": "Blue Bloods", "Type": "Series", "ProductionYear": 2010, + }) + source := &fakeEmby{ + items: []json.RawMessage{seriesRaw}, + episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)}, + } + ingester := testIngester(st, source) + enqueueOne(t, ingester, episodeRequest()) + ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"]) + + if _, written := st.items["emby-series"]; !written { + t.Fatalf("the new series row was not written: %v", st.items) + } + if _, written := st.items["emby-ep-7"]; !written { + t.Fatal("the episode was not written") + } +} + +// The year separates a remake from its original where both systems know it, and the title +// alone is the fallback because they disagree about years more often than about names. +func TestMovieMatchingPrefersTheYearAndFallsBackToTheTitle(t *testing.T) { + original, _ := json.Marshal(map[string]any{ + "Id": "old", "Name": "The Thing", "Type": "Movie", "ProductionYear": 1982, + }) + remake, _ := json.Marshal(map[string]any{ + "Id": "new", "Name": "The Thing", "Type": "Movie", "ProductionYear": 2011, + }) + items := []json.RawMessage{original, remake} + + if _, id := pickByTitle(items, "The Thing", 2011); id != "new" { + t.Fatalf("the year did not decide: %q", id) + } + if _, id := pickByTitle(items, "The Thing", 0); id != "old" { + t.Fatalf("expected the first title match as the fallback, got %q", id) + } + if _, id := pickByTitle(items, "Something Else", 0); id != "" { + t.Fatalf("an unrelated title matched: %q", id) + } +} + +// A repeated webhook is one row, and it never pulls the settle delay forward — the whole +// point of the delay is that the file has finished being written. +func TestRepeatedEnqueueIsOneRowAndKeepsTheSettleDelay(t *testing.T) { + st := newFakeStore() + ingester := testIngester(st, &fakeEmby{}) + request := episodeRequest() + + fresh, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}) + if err != nil || fresh != 1 { + t.Fatalf("first delivery: fresh=%d err=%v", fresh, err) + } + first := st.jobs[request.Key].DueAt + + fresh, err = ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}) + if err != nil || fresh != 0 { + t.Fatalf("a repeat was treated as news: fresh=%d err=%v", fresh, err) + } + if len(st.jobs) != 1 { + t.Fatalf("a repeat produced %d rows", len(st.jobs)) + } + if st.jobs[request.Key].DueAt.Before(first) { + t.Fatal("a repeat pulled the settle delay forward") + } +} + +// Quiet time stands the worker down without touching the queue, which is the arrangement +// that lets the hook accept an event at any hour. +func TestQuietTimeStopsTheWorkerAndNotTheQueue(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + source := &fakeEmby{episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)}} + ingester := testIngester(st, source) + ingester.Paused = func() bool { return true } + enqueueOne(t, ingester, episodeRequest()) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { ingester.Run(ctx); close(done) }() + time.Sleep(50 * time.Millisecond) + cancel() + <-done + + if len(st.items) != 0 { + t.Fatal("work was done during quiet time") + } + if st.jobs["sonarr:episodefile:8123:551"].State != store.IngestPending { + t.Fatal("the queued work was lost rather than deferred") + } +} + +// A finished scan is the moment there is something truthful to announce, which is why the +// hook fires from here rather than from the webhook. It must carry Emby's own names: the +// *arr and Emby disagree about punctuation often enough that a banner built from the +// webhook would name the same thing differently from the card underneath it. +func TestAFinishedImportIsAnnouncedWithEmbysOwnNames(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + source := &fakeEmby{episodes: []json.RawMessage{episodeWithArtwork("emby-ep-7", 6, 7)}} + ingester := testIngester(st, source) + + var announced []IngestResult + ingester.Announce = func(_ context.Context, result IngestResult) { + announced = append(announced, result) + } + enqueueOne(t, ingester, episodeRequest()) + ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"]) + + if len(announced) != 1 { + t.Fatalf("expected one announcement, got %d", len(announced)) + } + result := announced[0] + if result.ItemID != "emby-ep-7" || result.Name != "The Job" { + t.Errorf("announced %q/%q, want Emby's id and episode title", result.ItemID, result.Name) + } + if result.SeriesName != "Blue Bloods" { + t.Errorf("series = %q, want Emby's series name", result.SeriesName) + } + if result.Season != 6 || result.Episode != 7 { + t.Errorf("position = S%02dE%02d, want S06E07", result.Season, result.Episode) + } + if result.ImageTag != "poster-tag" { + t.Errorf("image tag = %q, want the poster from the stored payload", result.ImageTag) + } + if result.Reason != ReasonImport || result.Kind != KindEpisode { + t.Errorf("result = %+v, want the import reason and kind carried through", result) + } +} + +// Nothing is announced for work that did not land. The banner claims the title is in the +// catalogue, so a lookup that found nothing must stay silent and simply be retried. +func TestNothingIsAnnouncedWhenEmbyHasNotScannedYet(t *testing.T) { + st := newFakeStore() + st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}} + ingester := testIngester(st, &fakeEmby{}) + + announcements := 0 + ingester.Announce = func(context.Context, IngestResult) { announcements++ } + enqueueOne(t, ingester, episodeRequest()) + ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"]) + + if announcements != 0 { + t.Fatalf("announced %d times for an episode Emby has not scanned", announcements) + } +} + +func episodeWithArtwork(id string, season, episode int) json.RawMessage { + raw, _ := json.Marshal(map[string]any{ + "Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series", + "SeriesName": "Blue Bloods", + "ParentIndexNumber": season, "IndexNumber": episode, + "ImageTags": map[string]string{"Primary": "poster-tag"}, + }) + return raw +} diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go index 4860e83..1887190 100644 --- a/server/internal/library/syncer.go +++ b/server/internal/library/syncer.go @@ -250,6 +250,14 @@ func (s *Syncer) run( return result, nil } +// EmbyCredentials is how the ingest worker borrows the same account the scheduled import +// uses. One rule for "who does the gateway talk to Emby as" rather than two, so a +// household with a service account configured never has an event-driven read appear in +// somebody's Emby history as their television. +func (s *Syncer) EmbyCredentials(ctx context.Context) (emby.Credentials, error) { + return s.credentials(ctx) +} + // credentials prefers the configured service account and otherwise borrows the most // recent TV session. func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) { @@ -333,44 +341,74 @@ func (s *Syncer) Find(ctx context.Context, term string, limit int) ([]json.RawMe return found, nil } +// disabledSyncPoll is how often a switched-off schedule wakes to ask whether it still is. +// A setting an operator has just changed must not need a restart, which is the same reason +// the Emby reachability probe keeps ticking slowly while it is off. +const disabledSyncPoll = 5 * time.Minute + // Schedule runs an incremental import on an interval until ctx is cancelled. // -// New episodes tend to land through the day and films weekly; an hourly incremental pass -// covers both without ever asking Emby for the whole catalogue again. -func (s *Syncer) Schedule(ctx context.Context, interval time.Duration, paused ...func() bool) { - if interval <= 0 { +// It is reconciliation now rather than discovery. Where the *arr webhooks are configured, a +// file is in the catalogue within a minute of Sonarr or Radarr putting it there and this +// pass exists for what they do not manage — media dropped in by hand, a title edited in +// Emby, a webhook that never arrived because the gateway was down. Where they are not, it +// is still the only thing that notices anything, which is why the interval is the +// operator's rather than a constant. +// +// interval is a function rather than a value because it is read every cycle: an operator +// who has just lengthened the sweep must see that take effect without restarting the +// container. Zero means switched off, and this keeps waking to ask. +func (s *Syncer) Schedule( + ctx context.Context, interval func() time.Duration, paused ...func() bool, +) { + if interval == nil { s.log.Info("library auto-sync disabled") return } - ticker := time.NewTicker(interval) - defer ticker.Stop() - - s.log.Info("library auto-sync scheduled", "interval", interval.String()) + s.log.Info("library auto-sync scheduled", "interval", durationLabel(interval())) for { + wait := interval() + disabled := wait <= 0 + if disabled { + wait = disabledSyncPoll + } + timer := time.NewTimer(wait) select { case <-ctx.Done(): + timer.Stop() return - case <-ticker.C: - if len(paused) > 0 && paused[0] != nil && paused[0]() { - s.log.Debug("skipping scheduled sync; quiet time is active") + case <-timer.C: + } + timer.Stop() + if disabled { + continue + } + if len(paused) > 0 && paused[0] != nil && paused[0]() { + s.log.Debug("skipping scheduled sync; quiet time is active") + continue + } + if s.Running() { + s.log.Info("skipping scheduled sync; one is already running") + continue + } + if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil { + if errors.Is(err, ErrNoCredentials) { + // Nobody has signed in yet. Not worth an error-level log every hour. + s.log.Info("skipping scheduled sync; no credentials yet") continue } - if s.Running() { - s.log.Info("skipping scheduled sync; one is already running") - continue - } - if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil { - if errors.Is(err, ErrNoCredentials) { - // Nobody has signed in yet. Not worth an error-level log every hour. - s.log.Info("skipping scheduled sync; no credentials yet") - continue - } - s.log.Error("scheduled sync failed", "error", err) - } + s.log.Error("scheduled sync failed", "error", err) } } } +func durationLabel(value time.Duration) string { + if value <= 0 { + return "off" + } + return value.String() +} + // syncItem mirrors the Emby fields promoted to columns. type syncItem struct { ID string `json:"Id"` diff --git a/server/internal/store/gateway_settings.go b/server/internal/store/gateway_settings.go index 4b3cce1..12d8ad3 100644 --- a/server/internal/store/gateway_settings.go +++ b/server/internal/store/gateway_settings.go @@ -46,6 +46,14 @@ type GatewaySettings struct { RadarrAlertMinutes int `json:"radarrAlertMinutes"` // EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there. EmbyHealthSeconds int `json:"embyHealthSeconds"` + // LibrarySyncMinutes is how often the catalogue sweep asks Emby what changed. + // + // It is an override worth having because the answer now depends on the household's + // wiring rather than on the gateway: with both *arr webhooks configured, a new file is + // in the catalogue within a minute of landing and the sweep is reconciliation for + // media Sonarr and Radarr do not manage — six hours rather than one. With no webhooks + // it is still the only way anything is discovered and must stay frequent. + LibrarySyncMinutes int `json:"librarySyncMinutes"` UpdatedAt time.Time `json:"updatedAt"` UpdatedBy string `json:"updatedBy,omitempty"` @@ -78,6 +86,9 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings { settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true) settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true) settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true) + // A day is the ceiling rather than a week: however well the webhooks are working, the + // sweep is the only thing that ever notices a file somebody moved by hand. + settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true) return settings } diff --git a/server/internal/store/library.go b/server/internal/store/library.go index f13202a..b0ef892 100644 --- a/server/internal/store/library.go +++ b/server/internal/store/library.go @@ -120,6 +120,70 @@ func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time) return tag.RowsAffected(), nil } +// NamedItem is the least a caller can be told about a catalogue row and still identify +// it: what it is called and, where the library knows, when it came out. +type NamedItem struct { + ID string + Name string + Year int +} + +// LibraryItemsByName finds catalogue rows by title, case-insensitively. +// +// The comparison that decides the answer is not this one: the caller normalises both +// sides (punctuation and spacing are where an *arr and Emby actually differ) and picks by +// year. This is the narrowing query — a handful of rows out of twenty thousand — so that +// the matching rule can stay a pure function with one definition. +func (s *Store) LibraryItemsByName(ctx context.Context, itemType, name string) ([]NamedItem, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT id, name, COALESCE(production_year, 0) + FROM library_items + WHERE type = $1 AND lower(name) = lower($2) + LIMIT 50`, itemType, trimmed) + if err != nil { + return nil, fmt.Errorf("store: library items by name: %w", err) + } + defer rows.Close() + out := []NamedItem{} + for rows.Next() { + var item NamedItem + if err := rows.Scan(&item.ID, &item.Name, &item.Year); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +// DeleteLibraryItem removes one item and anything derived from it. +// +// The credits marker goes with it, and that is the point of doing this in one place: the +// marker table is keyed on the item id and nothing else prunes it, so a title deleted from +// the library would otherwise leave a Skip Credits position behind for a file that no +// longer exists — and if that id were ever reused, in front of the wrong programme. +// +// Deleting a series takes its episodes with it, because Emby's own hierarchy is the only +// thing that made those rows meaningful. +func (s *Store) DeleteLibraryItem(ctx context.Context, itemID string) (int64, error) { + if strings.TrimSpace(itemID) == "" { + return 0, nil + } + tag, err := s.pool.Exec(ctx, + `DELETE FROM library_items WHERE id = $1 OR series_id = $1`, itemID) + if err != nil { + return 0, fmt.Errorf("store: delete library item: %w", err) + } + if _, err := s.pool.Exec(ctx, + `DELETE FROM credits_markers WHERE item_id = $1 OR series_id = $1`, itemID); err != nil { + return 0, fmt.Errorf("store: delete credits markers: %w", err) + } + return tag.RowsAffected(), nil +} + // SearchLibrary answers from the imported library rather than Emby. // // Full-text match first, with a trailing ILIKE so partial words ("sever") still hit diff --git a/server/internal/store/library_ingest.go b/server/internal/store/library_ingest.go new file mode 100644 index 0000000..be4b24b --- /dev/null +++ b/server/internal/store/library_ingest.go @@ -0,0 +1,216 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// The durable side of event-driven ingest. +// +// One table, five queries, and the only interesting one is the insert: it is written +// ON CONFLICT on a key derived from the file, which is the whole of what makes repeated +// webhook delivery safe. Sonarr and Radarr both re-notify on retry and neither guarantees +// exactly-once, so "the same news twice" has to be an ordinary event rather than a +// duplicate row and a duplicate Emby lookup. + +// Ingest states. +const ( + IngestPending = "pending" + IngestDone = "done" + IngestFailed = "failed" +) + +// IngestRetention is how long settled rows are kept. Long enough that an operator asking +// "did the webhook fire when that episode landed last week" gets an answer, short enough +// that a household importing all day does not accumulate a table nobody reads. +const IngestRetention = 14 * 24 * time.Hour + +// IngestJob is one row of work. +type IngestJob struct { + Key string `json:"key"` + Action string `json:"action"` + Kind string `json:"kind"` + Reason string `json:"reason"` + Source string `json:"source"` + Payload json.RawMessage `json:"payload"` + State string `json:"state"` + Outcome string `json:"outcome"` + ItemID string `json:"itemId"` + Attempts int `json:"attempts"` + LastError string `json:"lastError"` + DueAt time.Time `json:"dueAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// IngestCounts is what the console reads beside the list. +type IngestCounts struct { + Pending int `json:"pending"` + Done int `json:"done"` + Failed int `json:"failed"` +} + +// EnqueueIngest records a piece of work, or refreshes one already waiting. +// +// The second return reports whether this delivery was news. A repeat is not an error and +// not a second row — it moves the existing row's due time no earlier and is logged at +// DEBUG, because a Sonarr that retried is an ordinary occurrence and not something an +// operator needs told about. +// +// A key that has already been *settled* is deliberately re-opened: the same file can +// legitimately be imported, deleted and imported again, and a row left at 'done' would +// swallow the second import for ever. +func (s *Store) EnqueueIngest(ctx context.Context, job IngestJob) (bool, error) { + if job.Key == "" || job.Action == "" { + return false, fmt.Errorf("store: ingest job needs a key and an action") + } + if len(job.Payload) == 0 { + job.Payload = json.RawMessage(`{}`) + } + if job.DueAt.IsZero() { + job.DueAt = time.Now().UTC() + } + var inserted bool + err := s.pool.QueryRow(ctx, ` + INSERT INTO library_ingest_queue + (key, action, kind, reason, source, payload, state, due_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, now(), now()) + ON CONFLICT (key) DO UPDATE SET + action = EXCLUDED.action, + kind = EXCLUDED.kind, + reason = EXCLUDED.reason, + source = EXCLUDED.source, + payload = EXCLUDED.payload, + state = 'pending', + outcome = '', + last_error = '', + -- A re-delivery must never pull the settle delay forward: the point of it is + -- that the file has finished being written, and an eager retry would ask Emby + -- about a file it has not scanned yet. + due_at = GREATEST(library_ingest_queue.due_at, EXCLUDED.due_at), + -- Attempts reset only when the row had settled. A retry storm against a row + -- still being worked must not reset its backoff. + attempts = CASE WHEN library_ingest_queue.state = 'pending' + THEN library_ingest_queue.attempts ELSE 0 END, + updated_at = now() + RETURNING (xmax = 0)`, + job.Key, job.Action, job.Kind, job.Reason, job.Source, job.Payload, job.DueAt.UTC(), + ).Scan(&inserted) + if err != nil { + return false, fmt.Errorf("store: enqueue ingest: %w", err) + } + return inserted, nil +} + +// ClaimIngest takes the work that is due, oldest first. +// +// It marks nothing: the worker is single and in-process, so a claim flag would be state to +// get wrong (a row left claimed by a container that was killed) in exchange for protecting +// against a second worker that does not exist. FinishIngest is what moves a row on. +func (s *Store) ClaimIngest(ctx context.Context, now time.Time, limit int) ([]IngestJob, error) { + if limit <= 0 { + limit = 10 + } + rows, err := s.pool.Query(ctx, ` + SELECT key, action, kind, reason, source, payload, state, outcome, item_id, + attempts, last_error, due_at, created_at, updated_at + FROM library_ingest_queue + WHERE state = 'pending' AND due_at <= $1 + ORDER BY due_at + LIMIT $2`, now.UTC(), limit) + if err != nil { + return nil, fmt.Errorf("store: claim ingest: %w", err) + } + defer rows.Close() + return scanIngestJobs(rows) +} + +// FinishIngest settles a row, or schedules the next attempt. +// +// state is 'done', 'failed' or 'pending' — the last being a deferral, which is the +// ordinary answer for a file Emby has not scanned in yet. +func (s *Store) FinishIngest( + ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time, +) error { + due := retryAt + if due.IsZero() { + due = time.Now().UTC() + } + _, err := s.pool.Exec(ctx, ` + UPDATE library_ingest_queue + SET state = $2, outcome = $3, item_id = $4, last_error = $5, + attempts = attempts + 1, due_at = $6, updated_at = now() + WHERE key = $1`, key, state, outcome, itemID, errorText, due.UTC()) + if err != nil { + return fmt.Errorf("store: finish ingest: %w", err) + } + return nil +} + +// RecentIngests is the console's read: newest activity first, whatever its state. +func (s *Store) RecentIngests(ctx context.Context, limit int) ([]IngestJob, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.pool.Query(ctx, ` + SELECT key, action, kind, reason, source, payload, state, outcome, item_id, + attempts, last_error, due_at, created_at, updated_at + FROM library_ingest_queue + ORDER BY updated_at DESC + LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("store: recent ingests: %w", err) + } + defer rows.Close() + return scanIngestJobs(rows) +} + +// IngestStateCounts is the summary above that list. +func (s *Store) IngestStateCounts(ctx context.Context) (IngestCounts, error) { + var counts IngestCounts + err := s.pool.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER (WHERE state = 'pending'), + COUNT(*) FILTER (WHERE state = 'done'), + COUNT(*) FILTER (WHERE state = 'failed') + FROM library_ingest_queue`).Scan(&counts.Pending, &counts.Done, &counts.Failed) + if err != nil { + return IngestCounts{}, fmt.Errorf("store: ingest counts: %w", err) + } + return counts, nil +} + +// PruneIngests removes settled rows past their retention. Pending work is never pruned: +// a row still waiting is work nobody has done, however old it is. +func (s *Store) PruneIngests(ctx context.Context, retention time.Duration) (int64, error) { + if retention <= 0 { + return 0, nil + } + tag, err := s.pool.Exec(ctx, ` + DELETE FROM library_ingest_queue + WHERE state <> 'pending' AND updated_at < $1`, time.Now().UTC().Add(-retention)) + if err != nil { + return 0, fmt.Errorf("store: prune ingests: %w", err) + } + return tag.RowsAffected(), nil +} + +func scanIngestJobs(rows pgx.Rows) ([]IngestJob, error) { + out := []IngestJob{} + for rows.Next() { + var job IngestJob + if err := rows.Scan( + &job.Key, &job.Action, &job.Kind, &job.Reason, &job.Source, &job.Payload, + &job.State, &job.Outcome, &job.ItemID, &job.Attempts, &job.LastError, + &job.DueAt, &job.CreatedAt, &job.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, job) + } + return out, rows.Err() +} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index 1f4ed89..fdc3193 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -792,3 +792,42 @@ 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); + +-- Work Sonarr and Radarr told the gateway about. +-- +-- This is the one queue in the schema that is durable, and the reason is that a webhook is +-- gone once it has been dropped: a Tracearr-derived credits candidate is rebuilt from one +-- query on restart, while "Sonarr imported this at 19:05" cannot be rederived from +-- anything. A container restarted during the settle delay must still re-read the file. +-- +-- The key is derived from the *file* rather than from the delivery, so ON CONFLICT is what +-- makes repeated webhook delivery safe: two notifications about one import collapse onto +-- one row, while a file deleted and re-imported is a different file and its own work. +-- +-- Completed rows are kept rather than deleted. They are the operator's record of why an +-- item was re-read, which is the question the Imports page exists to answer; housekeeping +-- prunes them. +CREATE TABLE IF NOT EXISTS library_ingest_queue ( + key TEXT PRIMARY KEY, + action TEXT NOT NULL, + kind TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + state TEXT NOT NULL DEFAULT 'pending', + outcome TEXT NOT NULL DEFAULT '', + item_id TEXT NOT NULL DEFAULT '', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + due_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The worker's only query: what is due. Partial, because settled rows outnumber pending +-- ones by orders of magnitude within a day of the feature being switched on. +CREATE INDEX IF NOT EXISTS library_ingest_pending_idx + ON library_ingest_queue (due_at) + WHERE state = 'pending'; +CREATE INDEX IF NOT EXISTS library_ingest_recent_idx + ON library_ingest_queue (updated_at DESC);