diff --git a/.gitignore b/.gitignore index c6be924..ce44d07 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ local.properties # The installed APK a deploy script pulls off a television to compare against. /.tmp-memby-installed-base.apk +.tmp-go-cache \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8dfa2..b9e168d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ +## 0.2.77 - 2026-08-19 +- New: Films on the "Upcoming Movie releases" shelf now open a page of their own. +- New: A trailer can be played from an upcoming film's page. +- Improved: Backend of notifications now go through one service. +- Improved: Design of Notifications. +- Fixed: A bug where the short indent overlapping at the start of the show. +- Fixed: Bug with Continue Watching not remembering playstate, in some cases. +- Fixed: Backend server fixes. + +## 0.2.76 - 2026-08-18 +- Fixed: Backend server fixes. + ## 0.2.75 - 2026-08-18 -- Chore: Upgrade player dependencies. +- Improved: Upgrade player version. +- Fixed: Backend server fixes. ## 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. diff --git a/CLAUDE.md b/CLAUDE.md index 4e16569..4d19fbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -550,14 +550,61 @@ behind it. Things to preserve: - **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My Alerts row inside the picker. The page is one level in now, so without the mark out on the rail nothing on the launcher would ever say there was news waiting. +- **Inbox and Seen, and there is no off switch.** The page is laid out as My Requests is — + the marked heading, a tab strip, one pane at a time — because the two answer the same shape + of question about a person's own list. `AlertsTab` (pure, tested) is the whole of it: + membership is the *read* flag and nothing else, so both panes and both counts derive from + the one list the caller already holds and there is no third state to keep in step. The + count is on the tab because it is the question a viewer opens the page with, and zero is + printed rather than hidden — a count that disappeared when it emptied would read as one + that had failed. Turning notifications off used to be a chip on this page, which was the + page offering a way to make itself permanently useless; the reason to reach for it was a + single list mixing new news with everything already read, which is what the split fixes. + The stored preference is still honoured and still says so in the empty state — it is + simply no longer the viewer's to switch from here. - **A press dismisses, and the focused row says so.** This is the only page whose whole job is emptying itself; a confirmation press per alert is what made the panel it replaced not - worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag. - "Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and - empties the list optimistically, or a row lingers under a thumb that will press it again. + worth opening. **Nothing moves under the remote by being looked at**: focus used to mark a + row read, which was harmless while the list was one list and would now empty the Inbox + merely by somebody scrolling it, so Seen is a state a viewer puts a row into. "Dismiss + all" is the same per-alert call in a loop — the gateway has no bulk route — and takes + **only the pane on screen**, or emptying Seen would throw away an Inbox nobody had read; + it empties optimistically, or a row lingers under a thumb that will press it again. +- **A row holds two focus targets, and that is what makes a seen toggle possible.** A remote + has one confirm key and it was already spent on dismissing, so the body keeps that press + and Right reaches a toggle beside it (`alertSeenActionLabel`, `POST + /v1/notifications/{id}/unread` → `store.MarkNotificationUnread`). Down still moves to the + next row from either, so the second target costs nothing to somebody who never wants it. + Three things to preserve: the toggle **names the action, not the state** — a button + labelled with the state it is in reads as a claim rather than as something to press; the + lit surface is the *row's*, driven by `hasFocus` rather than `isFocused`, or a row goes + dark the moment the remote steps sideways into its own control; and marking unread is a + plain assignment where marking read is a `COALESCE`, because read is set by merely looking + at a row and must not move on a second glance, while unread is only ever somebody saying so. +- **The toggle is what moves a row between the two halves, so focus comes back to the + toggle.** Marking a row seen takes it out of the pane it was in, exactly as a dismissal + does, and the page re-aims focus the same way — but onto the *toggle* of the row that took + its place rather than onto the body, because a run of "Mark as seen" presses that landed on + a body would be a run that ended in a dismissal. That is why a row carries two focus + requesters and `pendingFocusToggle` records which of them the change was aimed at. +- **The list is paged on the television, not on the wire** (`ui/alerts/AlertsPaging.kt`, + pure and tested). The gateway answers with the whole undismissed list and the page cuts it + up locally, which is what lets a page flip cost nothing on a weak box, lets the locally + held update notice merge into page one without making a server's page boundaries lie about + it, and keeps a dismissal optimistic instead of needing the page it left refetched. + `AlertsPageSize` is **four**, measured against a 540dp television that spends a third of + its height on the heading and the controls — `my-alerts-paged-crowded.png` is the capture + that figure is answerable to. Two rules: `alertPageAfterChange` **clamps rather than + resets**, because the common change here is the last row of the last page going away and a + viewer sent back to page one for it would lose their place every time they finished a + page; and an arrow at either end is **not drawn** rather than drawn dead, the stance the TV + calendar's month controls take, with both ends keeping a fixed-width slot so the page + indicator is not the one thing on the row that moves. - **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and - the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. + the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. What + the page does hold is only ever about the remote in the room — which tab is open, which + page of it, and which row a dismissal or a seen toggle aimed focus at. - **A cancellation is a transition, not a status read in isolation.** `sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every @@ -567,6 +614,50 @@ behind it. Things to preserve: enabling the scanner would announce every show that had already ended as new news, and without durable history a gateway restart could announce the same change again. +**Every outbound notification leaves through one door.** `server/internal/notify` is that +door and `internal/api/notifications.go` is the Memby half of it, so the flow is +`feature/event → notify.Service → Deliverer → notification log`. Before it, each feature +both decided to notify somebody *and* performed the delivery: the Sonarr lifecycle scanner +wrote into `user_notifications`, the ingester pushed a banner into Redis, the integrations +dispatcher posted to Discord. Each knew how to deliver and none knew the others existed, so +"what did Memby send, to whom, and did it work" could only be answered by reading three +subsystems' log lines and hoping each had logged. The console reads the trail at +`/admin/notifications` over `admin_notification_log.go` and `store/notifications.go`. Things +to preserve: + +- **Logging never blocks delivery, and that is an ordering rather than a promise.** `Send` + delivers *first* and records afterwards, on a context detached from the caller's, and a + write that fails is logged and swallowed. A history written first would be a claim rather + than a record; one written inside the delivery path could fail the delivery. +- **A skip is the most useful row on the page.** "Memby never told me" and "Memby decided + not to tell you" are the same silence from outside, so a viewer's preferences declining a + notification, a source key that had already been used, and an operator's alert window set + to zero are all *recorded* with their reason (`declineUser`, `StatusSkipped`) rather than + being an early return. That is why `announceImportedMovie` no longer short-circuits on a + zero window — `deliverBroadcast` records it instead. +- **Sent and Delivered are different answers.** Most channels here are stores, finished the + moment they return with nobody to acknowledge them; `Delivered` is reserved for a provider + that actually confirmed, which today means a webhook answering 2xx. Collapsing them would + have the console claim an acknowledgement nothing gave. +- **Nothing secret is ever recorded.** A webhook URL is the credential, so `Notification` + carries `Target` — a destination's *name* — and never its address, and `Redact` drops any + metadata key that looks like one on the way in. It is a match on the *key*, because a + token is not recognisable by looking at it and what a caller reliably gets right is what + they called the field. +- **The integrations dispatcher reports rather than being driven.** It is the one producer + that calls `notify.Log` instead of `Send`: it is a bus subscriber with its own queue, + pacing and transport registry, and routing its posts through the audit trail would make + the trail the thing deciding what Discord receives. +- **`UpsertNotification` reports whether it inserted.** The digest job fires hourly and + re-sends the same weekly key all evening on purpose, so without that boolean every + catch-up pass would read as a second summary nobody received. +- **The facets are built from what has been sent**, not from a list of constants, and over + the whole retention window rather than the current filter — a dropdown whose options + vanish as you narrow the table is one you cannot use to widen the question again. +- **`store.NotificationRetention` (90 days) is the one figure.** The housekeeping task + prunes to it and the console derives its widest window from it, so a page can never offer + a range the prune has already emptied. + **Watch time is Tracearr's, and it is never counted twice.** `store.watchedMsExpr` in `internal/store/watch_time.go` is the one definition of "how long was this actually watched" — the greater of Tracearr's `durationMs` and `progressMs`, capped at the title's own length — @@ -1616,6 +1707,40 @@ therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the p same work which is. Moving the core past 1.9 means finding a matching extension first, or deciding to do without DTS. +**Where a title resumes from is the television's own answer, not the card's.** The launcher +hands `PlaybackRequest` the position it read off the card, and *nothing downstream corrects +it*: the direct path resolves no position at all, and the gateway takes `resumePositionMs` +as a hint (`playbackHint`) and echoes it back rather than paying a second Emby lookup. So a +card is the whole answer, and a card is only as fresh as the last home refresh — which is +how a short session came to be lost. Somebody watched twenty seconds, left, and pressed Play +again inside the second or two the stop report and the refresh take, and was sent back to +where they had started. `EmbyRepository.localResume` is what closes that window: the playhead +is recorded **before** the report is attempted, from `enqueuePlaybackStopped` synchronously +and from every progress report, and `launchResumePositionMs` consults it at +`playbackRequest()` — the one funnel every launch passes through, and therefore also what +opens the player and what the gateway is given as its hint. Things to preserve: + +- **It is a *greatest*, never a preference**, which is what retires the record with no + bookkeeping at all: once a refresh brings the card back carrying that position — or a later + one, watched on another set — the card is at least as current and the local record can no + longer change the answer. `LOCAL_RESUME_MAX_AGE_MS` is only a backstop for a set that + recorded a position and was then left alone. +- **A completed title is forgotten rather than remembered.** The server resets a finished + title's position, so a record kept past that would drop somebody into the closing minutes of + something they had deliberately started again. That is the only reason `durationMs` is + carried through `PlaybackStopWorker.enqueue` — zero means the runtime was not known, never + that the title is zero long. Marking watched or unwatched, and hiding a title from the + shelf, forget it for the same reason. +- **The launcher is told before the server is.** `repository.playbackPositions` is emitted as + the player exits and `HomeViewModel.applyPlaybackPosition` moves the card's progress bar + immediately, ahead of the `playbackStops` refresh that replaces it with the server's own + answer — and it never moves a card backwards, because a stop and the ten-second report + before it can arrive in either order. It is also what puts the new position into the + persisted `HomeCache`, so a cold start does not draw the old one. +- **`clearPlayableCache` deliberately does not touch it.** A playback stop clears that cache, + and outliving it to answer the launch that follows is the entire point. Session changes call + `clearLocalResume` beside it — another viewer's playheads are not this one's. + **Playback position has one ordered exit path.** Ten-second progress updates, pause/seek updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a slow older Progress request cannot complete after Stop and move Emby's saved playhead back. @@ -1641,6 +1766,51 @@ record the pre-roll, the first-frame ident and the backdrop loading state under `build/screenshots/sonarr-preroll/`, `build/screenshots/playback-identity/` and `build/screenshots/playback-loading/`. +**The station ident is one announcement, and the corner it uses has one owner.** It read as +duplicated or overlapping — for films and episodes alike, intermittently — and nothing was +ever drawn twice: the ident (`player_playback_identity`, activity-owned, above the +`PlayerView`, top-start at 48/34dp) and the transport controller's own +`player_now_playing_group` (inside `memby_player_controls.xml`, top-start at 48/30dp) are the +same logo and the same title, four density pixels apart, bound by two methods +(`setUpPlaybackIdentity` and `bindTitleArtwork`) that had never heard of each other. Whenever +the transport happened to be up inside the ident's five seconds — a remote press, a pause, +closing the cast or subtitle overlay, media3's own `auto_show` — both drew, which is exactly +why it was intermittent. `ui/player/PlaybackIdentity.kt` holds the rule instead. Things to +preserve: + +- **`playerIdentitySlot` is the whole of the priority**, pure and tested: paused outranks the + transport, the transport outranks the ident. Pause is `NONE` because the pause overlay + already carries the poster, the title and the synopsis — a logo above it is the same + programme said twice in two type sizes. `applyIdentityRegion` is the only thing that sets + `nowPlayingGroup`'s visibility; `updatePauseOverlay` setting it directly is what the defect + looked like from the transport's side. +- **The transport's arrival *ends* the ident** rather than moving or fading it. Of the three + possible rules that is the only one that never has two answers to "what is playing" on + screen at once, and it needs no measured choreography between two layouts that know nothing + about each other. `PlayerView.ControllerVisibilityListener` is the only honest source of + that state — media3 raises the transport for reasons the activity never hears about. +- **The ident is a phase, never a boolean** (`PlaybackIdentityPhase`, `shouldRaiseIdent`). + "Has not opened yet" and "has already had its turn" are different answers to an arriving + playback-started event, and every launch reports one at least once — first frame, pre-roll + hand-off, a recovery re-prepare. A withheld ident is *spent*, not deferred, or it would + appear seconds into the programme when the controls timed out. +- **`setUpPlaybackIdentity` binds and never shows.** `adoptPlayable` re-binds on every gateway + launch once the server settles which episode it is, so a bind that could also raise the + ident is a second one. +- **`resetPlaybackIdentity` is called wherever the *subject* changes inside a player the + viewer never left** — an episode advance, a trailer resolving, a next-episode preview and + the return from a failed one. Those are the paths that would otherwise carry the outgoing + title's spent ident into the incoming title, or leave the outgoing one's fade running over + it. +- **The logo and its text fallback both start hidden.** The fallback was `visible` in the + layout and only hidden when Coil reported success, so a title with a logo showed its name + and then its logo — a swap in the same corner that reads as the ident drawing twice. +- **The episode line is subordinate and fixed in place.** The logo sits in a fixed 82dp box + aligned to its bottom, so `S01E01 — Bob Smith` lands at the same height whatever the + artwork's proportions are and whether there is artwork at all; the line itself is a compact + white-on-near-black plate, one line, ellipsised, so a long episode title cannot reach the + middle of the screen. A film has no line at all — nothing is reserved for one. + **The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare on the main queue's idle handler, so decoder construction and the local resource read never @@ -2909,6 +3079,69 @@ availability badge above it answers a different question (has the household's co downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead of everything else on a followed show: nothing else on that card matters as much. +**A film the household does not own has a page of its own.** The "Upcoming Movie releases" +row is Radarr's, and its cards were the one thing on the launcher that did nothing at all +when pressed: `MembyPlayable` is false, `scheduleSeriesStub` answers only for Sonarr, and +there is no Emby item behind them to open. `GET /v1/radarr/movies/{id}` +(`server/internal/api/radarr_detail.go` → `ui/RadarrMovieDetailsOverlay.kt`) is what they +open instead — the artwork, the description, the genres, the certificate, the studio, the +stored review scores, when it is expected, and a trailer. Things to preserve: + +- **Nothing manufactures an Emby item to reuse the movie page.** `radarrMovieDetail` is its + own type on both sides, because the ordinary page's whole shape — Play, resume, a progress + bar, watched state, tabs of cast and extras — is built around a file that exists, and a + page carrying those over a film nobody can watch would be four lies arranged as furniture. + The card itself is passed in for artwork and for the title, which is what makes the page + appear on the press rather than after the request; it is the row's own card, not a stand-in. +- **Which page opens is the gateway's answer, not the television's.** `MembyMovieItemId` is + the `MembySeriesItemId` arrangement — resolved in `embyMovieIndex` by **TMDb id**, since + Radarr writes one and the library import already asks Emby for `ProviderIds`, so unlike the + Sonarr row there is nothing here to match by title. A card carrying one opens the ordinary + movie page through `scheduleMovieStub`; a card carrying none is `BaseItem.isRadarrOnly` and + opens this one. Neither is inert, which is what the card used to be. +- **The detail route resolves the link a second time, and that is not redundancy.** The home + row is cached for the day, so a film imported at lunchtime still arrives on a card with no + Emby id until midnight. `radarrEmbyStub` reads the live answer and hands the viewer to the + ordinary page — which is the whole of "once it is in Emby, the card follows the normal + path", with nothing on either side to invalidate. +- **The trailer joins the existing chain rather than starting a second one.** + `trailerManifest` recognises a `radarr:` id and builds its candidate list from Radarr's + `youTubeTrailerId`, so `/v1/items/{id}/trailers`, `/resolve`, `/report`, the client's + `hasTrailer` cache and the player's walk through candidates all work unchanged on a subject + Emby has never heard of. `trailerAvailable` rides the detail response for the same reason + `subtitleDownloadAvailable` rides the playback one: the button is decided before it is + drawn, so it can never be one that fails after being pressed. +- **Most of the release wording is about refusing to be precise.** `radarrExpectedLabel` + prints a published digital date to the day and the schedule row's cinema-plus-a-month + estimate only to the month — "Expected November 2026" is true where "Expected 14 November + 2026" is a date somebody would plan an evening around — and a film with neither is told + plainly rather than guessed at. It is the gateway's wording, the stance every schedule + label takes, so a phrasing added next month reads correctly on today's build; the three + Radarr dates are listed beside it so a viewer can see which one the headline came from. +- **`radarrMovieState` answers about the household's copy, where the lifecycle tag answers + about the film.** Coming Soon, Not Yet Available, Awaiting Release, Not Tracked and Almost + Ready are five different reasons a viewer cannot watch this tonight, and the line under the + state must not repeat the date above it — an unannounced film said "Release date not yet + announced" twice before it said anything about downloading. +- **Ratings come from the household's own store**, keyed by the TMDb or IMDb id Radarr + already holds, rather than from Radarr's own ratings block: two different numbers for one + film under one provider's name is worse than no strip. +- **Radarr is asked for nothing the catalogue can answer.** `radarrMovie` reads the cached + household catalogue first — one request already shared by the whole house — and only falls + back to `/api/v3/movie/{id}` for a title added since. `HomeViewModel.warmDetailPage` warms + it on focus, which is the only warm a schedule card has any use for. +- **The long-press menu is built from a list now, not from hand-written indices.** A Radarr + card has no Emby record to favourite or mark watched and has a trailer where an ordinary + card does not, so what belongs on that menu varies — `QuickAction` derives the focus + indices from the entries rather than leaving four pieces of arithmetic to hold in step. +- **There is no second implementation on the direct path**, the stance the TV calendar takes + and for the same reason: the answer is Radarr's, which a television holds no credential for + and Emby knows nothing about. With no gateway the row does not exist either. +- Screenshots are `RadarrMovieDetailScreenshotTest` → `build/screenshots/radarr-movie/`. + The claim the page makes is that an unwatchable film reads as *deliberately* unavailable + rather than as a page whose Play button failed to load, which is not a thing a unit test + can check. + **The TV calendar is the schedule row's other shape.** The launcher's row answers "what is on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`) answers "what is on this month, and when does it come back", which is a question no shelf diff --git a/DESIGN_FIXES.md b/DESIGN_FIXES.md deleted file mode 100644 index 9b98742..0000000 --- a/DESIGN_FIXES.md +++ /dev/null @@ -1,216 +0,0 @@ -# Design fixes — home screen and detail pages - -Audit of 2026-08-01. Findings only; no product code was changed. The three items under -"Confirmed layout bugs" were reproduced by rendering the real composables at TV 1080p -(`w960dp-h540dp-television-xhdpi`) through the existing Roborazzi harness — everything -else is read from the source. - -**All of it is implemented.** The findings below are left as written — they are the -diagnosis, and each one says why the fix is shaped the way it is. What was done: - -| # | Fixed in | -|---|----------| -| 1 | `HomeMovieHero.kt` — column padding 28→22dp, and a wrapped title stands the synopsis down (`onTextLayout` line count) so Play is never what gets cut. Captured as `df_home-movie-hero-long-title.png`. | -| 2 | `DetailPageComponents.detailPaneHeight()` — the slot is derived from the viewport (250–420dp) instead of a hard 250dp. Every technical spec now renders; `df_detail-pane-cast-details.png`. Studio, which both columns claimed, is dropped from the technical column. | -| 3 | `DetailFoldPeek` (34dp) holds the tab strip off the bottom edge. | -| 4 | The hero's private `heroFacts`/runtime formatter is gone; it calls `detail/DetailFacts.kt`. `HomeComponents.formatTvRuntime` too — one formatter left in the app. | -| 5 | `MembyScore` token, and every rating goes through `ratingLabel` (`Locale.US`). The home metadata panel renders the score as its own run of text so it can carry the same gold. | -| 6 | "No favourite shows yet" / "Mark a series as a favourite…". | -| 7 | `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. | -| 8 | `ui/theme/DesignTokens.kt`; `HomeComponents` and `DetailPageComponents` colours are aliases of it, and `Theme.kt` uses the same near-blacks. | -| 9 | Same — the detail page picked up the raised TV neutrals. | -| 10 | Three radii: `MembyChipCorner` 8, `MembyCardCorner` 10, `MembyPanelCorner` 14. | -| 11 | `ui/MembyButtons.kt` — `MembyPlayButton`, `MembyPlayChip`, `MembyChoiceChip`. The hero chip, the detail Play button, the metadata panel's "▶ Resume" and the For You time budget all use them. | -| 12 | `DetailHero` honours `Settings.showTitleLogo` (new `EmbyRepository.showTitleLogo`) and shares `useTextTitleForLogo` with the screensaver (`ui/TitleLogo.kt`). | -| 13 | One `UHD_MIN_WIDTH` (3800) for the badge and the `(4K)` suffix. Unit-tested. | -| 14 | `mediaBadges` reads `dynamicRangeLabel`, so HDR10+ stays HDR10+. Unit-tested. | -| 15 | One `FocusRequester` per pane in both overlays; none is attached to two live nodes. | -| 16 | A series passes `mediaBadges(item)`, and `DetailFactRow` takes 4 badges so the airing badge is not squeezed out. | -| 17 | `HomeRowHeaderIcon` / `HomeRowHeaderIconGap` / `HomeRowHeaderSpacing`, used by `MediaRow`, `MyShowsStrip` and `RecentSearchesRow` — which also gained the vertical padding its focus-scaled chips needed. | -| 18 | `HomeHeroPick` carries the row a title was drawn from; the caption is no longer the card's slot. Unit-tested. | -| 19 | Deleted (≈260 lines: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData`, `HomeRowSkeleton` and the two runtime formatters only they used). | -| 20 | The peek under the strip plus a chevron at its end. | -| Docs | `CLAUDE.md`'s detail-page section rewritten to describe this code, with the token, button and header conventions above it. | - -Screenshots of the result are `app/build/screenshots/df_*.png` -(`.\gradlew.bat :app:testDebugUnitTest --tests "*ScreenshotTest"`). - ---- - -## Confirmed layout bugs - -These clip real content on a real TV. Fix these first. - -### 1. The featured home hero drops its Play button when the title wraps to two lines - -`ui/HomeMovieHero.kt:171-236`, `ui/MainActivity.kt:137` (`homeHeaderHeight`) - -The card's content column measures ~229dp with a one-line title and ~261dp with two. -`homeHeaderHeight(540dp, showHero = true)` yields 248dp, minus the hero row's 16dp top and -10dp bottom padding, so the card gets **222dp** — and `FocusScaleContainer` clips it to a -14dp rounded rect. Rendered with a two-line title, the kicker, title, fact line and -synopsis draw and the green Play chip is **gone entirely**. A one-line title is already -7dp over budget; it only survives because the part cut off is the chip's shadow. - -Fix direction: the column is `align(Alignment.CenterStart)` inside a fixed-height box, so -overflow is split top and bottom and the button is always the first thing lost. Either -give the hero a height derived from its content, cap the title at one line, or drop the -synopsis when the title wraps. - -### 2. The Cast & Details tab silently discards every technical spec - -`ui/DetailPageComponents.kt:258-278` (the 250dp pane), `:563-580` (`DetailFocusablePane`), -`:613-643` (`DetailCastAndDetailsPane`) - -The tab content slot is a hard `.height(250.dp)` and `DetailFocusablePane` applies -`.clip(RoundedCornerShape(10.dp))`. `releaseAndTechnical` builds Released / Certificate / -Runtime and then `addAll(specs)` — Video, Codec, Audio, Subtitles, Studio. Rendered at the -real slot geometry, only the first three rows survive; the entire output of -`technicalSpecs()` is clipped below the fold of a pane that cannot scroll. That is the -whole reason the tab exists. - -`DetailOverviewPane` shares the ceiling: a five-line synopsis plus the series "Up next" -supporting line pushes its credit rows past the same boundary. - -Fix direction: the pane needs a height budget that accounts for its worst case, or the -two-column meta block needs to page/scroll. Note the pane deliberately does not scroll -(one screen per tab), so the honest fix is probably fewer rows per column, not a scroller. - -### 3. The detail tab strip sits flush against the bottom screen edge - -`ui/DetailPageComponents.kt:209` (`heroHeight = maxHeight - DetailTabHeight`), `:494-550` - -The selection underline is cut in half at y=1080 in both `detail-movie-more-like-this.png` -and `detail-series-cast-details.png`. On a TV with overscan the underline and part of the -labels are off-screen. This is the only element in the app with zero safe-area inset — -gutters are 36-58dp and the home clock keeps 18dp. - ---- - -## Copy and formatting - -### 4. The home hero formats runtime differently from everywhere else - -`ui/HomeMovieHero.kt:351` is a private `heroFacts` shadowing `ui/detail/DetailFacts.kt:62`. -Different field order (year · certificate · runtime vs year · runtime · certificate) and -`"${it}m"` instead of `formatRuntime`. The checked-in `home-movie-hero.png` shows -**"2026 • M • 124m"** in the hero and **"2026 • 2h 4m"** on the card directly beneath it. - -There are three runtime formatters in the app: `DetailFacts.formatRuntime`, -`HomeComponents.formatTvRuntime` (private, identical) and this one. - -### 5. The community score changes colour and locale by screen - -Gold `0xFFF5C518` on detail (`DetailPageComponents.kt:484`), grey `MutedText` on home -(`HomeComponents.kt:1025`). `HomeMovieHero.kt:355` uses `"★ %.1f".format(it)` with the -default locale while every other rating goes through `Locale.US` — a comma decimal in a -non-US locale. - -### 6. American spelling in two user-facing strings - -`ui/HomeComponents.kt:1549` "No **favorite** shows yet" and `:1555` "Mark a series as a -**favorite** and it'll be waiting here." The rail says Favourites, the quick menu says -"Add to favourites", the detail hero action says "Add to Favourites". - -### 7. Four separator styles for the same kind of fact list - -`" • "` (home metadata), `" • "` (detail fact row, schedule metadata, home hero), -`" · "` (home genres), `", "` (credit rows). - ---- - -## Design-token drift - -### 8. Four near-blacks - -Theme `background 0xFF0B0E11` and `surface 0xFF101418` (`ui/theme/Theme.kt`, effectively -unused), home `0xFF090B0D`, detail `0xFF080A0C`. The accent green is duplicated four ways: -`EmbyGreen`, `DetailAccent`, and the hero's `0xFF69C762` / `0xFF7BD574`. - -### 9. Secondary-text contrast diverged between the two screens - -`ui/HomeComponents.kt:129-132` carries a comment about raising the neutrals for TV distance -(`MutedText 0xFFD0D6DB`, `QuietText 0xFFAEB7BF`). The detail page still uses the pre-fix -values (`DetailMutedText 0xFFB6BDC3`, `DetailQuietText 0xFF8C959D`). The two sit side by -side the moment a detail page is opened from a row. - -### 10. Corner radii are ad hoc - -9dp home cards, 8dp related posters, 8/10dp cast cards, 14dp featured hero, 11dp mini hero, -12dp overlays, 7dp chips, 999dp search chips. - -### 11. Three button languages - -The hand-rolled `DetailPlayButton` (23/12dp padding, 16sp), the hero's hand-rolled play chip -(12/7dp, 13sp — same look, different metrics), and raw `androidx.tv.material3.Button` with -glyph text in `MediaMetadataPanel` ("▶ Resume") and `ForYouTimeBudget` ("✓ 30 min"), which -picks up theme colours nothing else in the app uses. - ---- - -## Logic and behaviour - -### 12. `showTitleLogo` is ignored by the detail pages - -The Settings copy promises "shows each title's logo artwork from Emby instead of plain -text", but only `ui/screensaver/ScreensaverContent.kt:773` honours it; -`ui/DetailPageComponents.kt:322` always fetches the logo. The screensaver also has -`useTextTitleForLogo`, a fallback for logos too dark to read — the detail hero has no -equivalent, so a dark logo is invisible on the near-black scrim. - -### 13. Two different 4K thresholds - -The badge fires at video width ≥ 3800 (`HomeComponents.kt:1184`); the `(4K)` suffix at -≥ 3400 (`DetailFacts.kt:190`). A 3600-wide file is 4K in the spec row and not on the badge. - -### 14. HDR10+ is named in `dynamicRangeLabel` but collapses to plain "HDR" in `mediaBadges` - -### 15. One `FocusRequester` attached to two live nodes - -`informationPane` is attached by the Overview pane, the Cast & Details pane and the Episodes -empty states (`ui/MediaDetailsOverlay.kt:202-216`, `ui/SeriesDetailsOverlay.kt:291-329`). -`AnimatedContent`'s 80ms fade-out keeps the outgoing pane composed, so a Down press landing -in that window can request focus on a pane that is disappearing. - -### 16. A series can never show format badges - -`ui/SeriesDetailsOverlay.kt:248` passes `badges = emptyList()` where movies pass real ones. -Related: `DetailFactRow` does `badges.take(3)`, so on a 4K/HDR/HEVC movie the airing badge -appended in `ui/MediaDetailsOverlay.kt:147` is silently dropped. - -### 17. Home row headers do not align - -`MediaRow` and `MyShowsStrip` lead with a 28dp icon chip plus 10dp; `RecentSearchesRow` -(`ui/MainActivity.kt:2084`) has no chip, so its title starts 38dp further left. It also uses -9dp header spacing against everyone else's 6dp and gives its `LazyRow` no vertical padding, -so focus-scaled chips have no room to grow. - -### 18. The mini hero labels are positional fiction - -`ui/HomeMovieHero.kt:124`: `listOf("POPULAR", "NEW RELEASE", "TRENDING")[index]`. But -`selectHomeHeroMovies` interleaves new releases and popular picks and then falls back to -every movie in the response. In the current screenshot a 2025 title is labelled NEW RELEASE -and a 2026 one POPULAR. - -### 19. A dead second home implementation - -`ui/MainActivity.kt:2752-2900`: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData` and -`HomeRowSkeleton` are unreferenced (only the gateway *model* named `HomeRow` is in use). -They carry a competing 48dp gutter and SemiBold header style — a live-looking template for -the wrong conventions. - -### 20. Tab content sits entirely below the fold with no affordance - -By design per the code comments, but nothing on screen tells the viewer that Down reveals -anything. - ---- - -## Documentation - -`CLAUDE.md`'s detail-page section no longer describes this code. It documents a poster-left -layout with Play hanging off the poster's bottom-right corner, "nothing scrolls vertically", -a tab list of "Overview, Episodes, Cast, Details", and a `DetailReasonStrip` of several -short phrases. The code is a full-bleed scrolling hero with Overview / Episodes / More Like -This / Cast & Details and a single reason line (`ui/DetailPageComponents.kt:375-385`). -Worth correcting before it misleads the next change. diff --git a/admin-ui/dist/assets/index-BmnCg8np.js b/admin-ui/dist/assets/index-BmnCg8np.js deleted file mode 100644 index 0d8daf3..0000000 --- a/admin-ui/dist/assets/index-BmnCg8np.js +++ /dev/null @@ -1,11 +0,0 @@ -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-cNUhbl7V.css b/admin-ui/dist/assets/index-C5rUVO6U.css similarity index 53% rename from admin-ui/dist/assets/index-cNUhbl7V.css rename to admin-ui/dist/assets/index-C5rUVO6U.css index b7b7b80..7806102 100644 --- a/admin-ui/dist/assets/index-cNUhbl7V.css +++ b/admin-ui/dist/assets/index-C5rUVO6U.css @@ -1 +1 @@ -@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--idle: #4a8f60;--idle-ink: #86c79a;--idle-wash: rgba(74, 143, 96, .13);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:grid;place-items:center;width:38px;height:38px;flex:0 0 38px;padding:0;border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:default;opacity:1}.topbar-status .dot{width:9px;height:9px;border-radius:50%;background:var(--quiet);box-shadow:0 0 0 3px #7d85901f}.topbar-status[data-tone=ok] .dot{background:var(--accent-ink);box-shadow:0 0 0 3px var(--accent-wash),0 0 9px #56d36447}.topbar-status[data-tone=bad] .dot{background:var(--danger);box-shadow:0 0 0 3px var(--danger-wash)}.account-menu{position:relative;display:flex;align-items:center;height:38px;flex:0 0 auto}.account-trigger{max-width:190px;height:38px;padding:0 9px 0 5px;border-color:var(--line);background:var(--surface);color:var(--muted)}.account-trigger:hover:not(:disabled),.account-menu[data-open=true] .account-trigger{border-color:#56d36480;background:var(--surface-hi);color:var(--text)}.account-avatar{display:grid;place-items:center;width:27px;height:27px;flex:0 0 27px;border:1px solid rgba(86,211,100,.36);border-radius:50%;background:var(--accent-wash);color:var(--accent-ink);font-size:11px;font-weight:750}.account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}.account-caret{width:12px;height:12px;transition:transform .16s ease}.account-menu[data-open=true] .account-caret{transform:rotate(180deg)}.account-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(280px,calc(100vw - 24px));padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.account-identity{display:flex;align-items:center;gap:11px;min-width:0;padding:9px 10px 12px;border-bottom:1px solid var(--line-soft)}.account-avatar-large{width:34px;height:34px;flex-basis:34px;font-size:13px}.account-identity span:last-child{min-width:0}.account-identity small,.account-identity b{display:block}.account-identity small{color:var(--quiet);font-size:11px}.account-identity b{overflow:hidden;text-overflow:ellipsis;font-size:13.5px}.account-panel>a,.account-panel form button{display:flex;align-items:center;gap:9px;justify-content:flex-start;width:100%;height:38px;padding:0 10px;border-radius:8px;color:var(--muted);font-size:13px;text-decoration:none}.account-panel>a:hover{background:var(--surface-hi);color:var(--text)}.account-panel>a{margin-top:6px}.account-panel form{margin-top:2px}.account-panel form button{justify-content:flex-start;width:100%;height:38px;border-color:transparent;background:transparent;color:var(--muted)}.account-panel form button:hover:not(:disabled){background:var(--surface-hi);color:var(--text)}@media (max-width: 900px){.topbar-version{display:none}}.omni{position:relative;width:100%}.omni-input{display:flex;align-items:center;gap:8px;width:100%;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.omni[data-open=true] .omni-input{border-color:var(--accent)}.omni-input input[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right:auto;left:0;width:100%;max-height:min(50vh,420px);overflow-y:auto;padding:6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.omni-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--radius-sm);color:var(--text);text-decoration:none}.omni-item .ico{color:var(--quiet);flex:0 0 16px}.omni-item.on,.omni-item:hover{background:var(--surface-hi)}.omni-item b{font-size:13.5px;font-weight:600}.omni-item small{display:block;color:var(--quiet);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-item span{flex:1 1 auto;min-width:0}.omni-group{flex:0 0 auto;color:var(--quiet);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.bell{position:relative;display:flex;align-items:center;height:38px}.bell-button{position:relative;display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}.bell-button:hover{background:var(--surface-hi);color:var(--text)}.bell[data-open=true] .bell-button{border-color:var(--accent);color:var(--text)}.bell-badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.bell-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(420px,92vw);border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40;overflow:hidden}.bell-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line)}.bell-head b{font-size:13px}.bell-list{max-height:min(58vh,460px);overflow-y:auto}.bell-item{display:flex;gap:11px;padding:11px 14px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.bell-item:last-child{border-bottom:0}.bell-item:hover{background:var(--surface-lift)}.bell-item[data-unread=true]{box-shadow:inset 2px 0 0 var(--accent)}.bell-item .glyph{flex:0 0 auto}.bell-body{flex:1 1 auto;min-width:0}.bell-body b{display:block;font-size:13px;font-weight:600}.bell-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px;overflow-wrap:anywhere}.bell-body time{display:block;margin-top:3px;color:var(--quiet);font-size:11px}.bell-foot{padding:9px 14px;border-top:1px solid var(--line);text-align:center}.bell-foot a{font-size:12.5px;text-decoration:none}.rail{position:fixed;top:var(--top);bottom:0;left:0;width:var(--rail);padding:12px 12px max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);min-width:0;margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=idle]{background:var(--idle-wash);color:var(--idle-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=idle]{border-color:#4a8f6059;background:var(--idle-wash);color:var(--idle-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=idle]{color:var(--idle-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-key,.topbar-version{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{width:40px;flex-basis:40px;min-width:40px;height:40px;border:1px solid var(--line);background:var(--surface)}.omni-input,.bell,.bell-button,.account-menu,.account-trigger{height:40px}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 1100px){.account-trigger{width:40px;padding:0}.account-name,.account-caret{display:none}}@media (max-width: 820px){.brand-word{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}@media (max-width: 680px){:root{--top: calc(60px + var(--safe-top))}.topbar{align-items:center;flex-wrap:nowrap;gap:clamp(4px,1.5vw,8px);padding:calc(var(--safe-top) + 8px) max(8px,var(--safe-right)) 8px max(8px,var(--safe-left))}.rail-toggle{order:1}.topbar-brand{order:2;height:40px;flex:0 0 auto}.topbar .omni{order:3;flex:1 1 96px;width:0;min-width:0;max-width:none}.topbar-status{order:4;margin-left:0}.bell{order:5}.account-menu{order:6}.brand-mark{width:30px;height:30px;flex-basis:30px}.account-panel,.bell-panel{position:fixed;top:calc(var(--top) + 8px)}.account-panel{right:max(12px,var(--safe-right))}}@media (max-width: 370px){.tiles{grid-template-columns:minmax(0,1fr)}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;margin-bottom:10px}.logbar-filters{display:flex;flex:1 1 520px;flex-wrap:wrap;align-items:center;gap:6px;min-width:0}.logbar-filters select{width:auto;min-width:0;height:30px;padding:0 26px 0 9px;font-size:12px}.logbar-actions{display:flex;flex:0 0 auto;gap:6px;margin-left:auto}.logsearch{position:relative;display:flex;flex:1 1 260px;align-items:center;min-width:200px}.logsearch svg{position:absolute;left:9px;width:14px;height:14px;color:var(--quiet);pointer-events:none}.logsearch input{width:100%;height:30px;padding-left:28px;font-size:12px}.logchips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}.logchip{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font:500 11px/1 var(--sans)}.logchip svg{width:11px;height:11px;opacity:.7}.logchip:hover:not(:disabled){border-color:var(--danger);background:var(--danger-wash);color:var(--danger-ink)}.logchip-clear{border-style:dashed;color:var(--quiet)}.logshell{position:relative}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.5 var(--sans);contain:layout paint style}.loghead,.logrow{display:grid;grid-template-columns:92px 52px minmax(150px,190px) minmax(240px,1fr) minmax(96px,150px) 68px;gap:12px;padding:0 12px}.loghead{position:sticky;top:0;z-index:2;align-items:center;height:31px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:900px}.loghead>:last-child{text-align:right}.logbody{position:relative;min-width:900px}.logday{position:absolute;top:0;right:0;left:0;display:flex;align-items:center;height:26px;padding:0 12px;background:linear-gradient(to bottom,rgba(255,255,255,.03),transparent);color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logday span{padding-right:10px;background:#080a0e}.logday:after{content:"";flex:1;height:1px;background:var(--line-soft)}.logrow{position:absolute;top:0;right:0;left:0;align-items:center;border-bottom:1px solid rgba(255,255,255,.03);contain:strict}.logrow:hover{background:#ffffff09}.logrow[data-selected]{background:var(--accent-wash)}.logrow[data-level=ERROR]{box-shadow:inset 2px 0 0 var(--danger);background:#e5534b0d}.logrow[data-level=WARN]{box-shadow:inset 2px 0 0 var(--warn)}.logrow-time{color:var(--quiet);font:11px/1 var(--mono);font-variant-numeric:tabular-nums}.logfacet{height:auto;min-width:0;padding:0;overflow:hidden;border:0;background:none;color:inherit;font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logfacet:hover:not(:disabled){border:0;background:none;text-decoration:underline;text-underline-offset:3px}.logfacet:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.logrow-level{color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.06em}.logrow-level[data-level=ERROR]{color:var(--danger-ink)}.logrow-level[data-level=WARN]{color:var(--warn-ink)}.logrow-level[data-level=INFO]{color:var(--muted)}.logrow-level[data-level=DEBUG],.logrow-level[data-level=TRACE]{color:var(--quiet)}.logrow-place{display:flex;align-items:baseline;gap:5px;min-width:0}.logrow-service{flex:0 0 auto;max-width:96px;overflow:hidden;color:var(--quiet);font:700 10px/1.4 var(--sans);letter-spacing:.07em;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.logrow-service[data-tone=info]{color:var(--info-ink)}.logrow-service[data-tone=note]{color:var(--note-ink)}.logrow-service[data-tone=data]{color:var(--data-ink)}.logrow-service[data-tone=idle]{color:var(--idle-ink)}.logrow-service[data-tone=quiet]{color:var(--muted)}.logrow-sep{flex:0 0 auto;color:var(--line)}.logrow-component{flex:1 1 auto;color:var(--quiet);font-size:11.5px}.logrow-summary{display:flex;flex-direction:column;gap:2px;justify-content:center;width:100%;height:100%;min-width:0;padding:0;overflow:hidden;border:0;background:none;font:inherit;text-align:left}.logrow-summary:hover:not(:disabled){border:0;background:none}.logrow-summary:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}.logrow-line{display:flex;align-items:baseline;gap:8px;min-width:0}.logrow-action{flex:0 0 auto;color:var(--muted);font:600 10.5px/1.4 var(--mono);letter-spacing:.04em}.logrow-action[data-method=POST],.logrow-action[data-method=PUT],.logrow-action[data-method=PATCH]{color:var(--info-ink)}.logrow-action[data-method=DELETE]{color:var(--danger-ink)}.logrow-text{flex:1 1 auto;min-width:0;overflow:hidden;color:var(--text);text-overflow:ellipsis;white-space:nowrap}.logrow-error{overflow:hidden;color:var(--danger-ink);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.logrow-context{overflow:hidden;color:var(--quiet);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.logrow-result{min-width:0}.logrow-verdict{display:inline-block;max-width:100%;color:var(--quiet);font:500 11px/1.5 var(--mono)}.logrow-verdict[data-tone=ok]{color:var(--muted)}.logrow-verdict[data-tone=info]{color:var(--info-ink)}.logrow-verdict[data-tone=data]{color:var(--data-ink)}.logrow-verdict[data-tone=warn],.logrow-verdict[data-tone=bad]{padding:1px 6px;border-radius:var(--radius-xs);font-weight:600}.logrow-verdict[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.logrow-verdict[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.logrow-duration{color:var(--quiet);font:11px/1 var(--mono);font-variant-numeric:tabular-nums;text-align:right}.logrow-duration[data-tone=warn]{color:var(--warn-ink)}.logrow-duration[data-tone=bad]{color:var(--danger-ink);font-weight:600}.logtail{position:absolute;right:18px;bottom:14px;z-index:3;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border:1px solid var(--accent);border-radius:999px;background:var(--surface-lift);color:var(--accent-ink);font:600 11px/1 var(--sans);box-shadow:0 6px 16px #00000080}.logtail svg{width:12px;height:12px;transform:rotate(90deg)}.logtail:hover:not(:disabled){border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.logdrawer{margin-top:12px;padding:14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.logdrawer-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:1px solid var(--line)}.logdrawer-head>div:first-child{min-width:0}.logdrawer-head b{display:block;margin-top:4px;font-size:14px;overflow-wrap:anywhere}.logdrawer-place{display:flex;align-items:baseline;gap:5px;margin:0;color:var(--quiet);font-size:11px}.logdrawer-error{margin:6px 0 0;color:var(--danger-ink);font:12px/1.5 var(--mono);overflow-wrap:anywhere}.logdrawer-actions{display:flex;flex:0 0 auto;gap:6px}.logdrawer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px 24px;margin-top:12px}.logdrawer-section h4{margin:0 0 6px;color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logdrawer-section dl{display:grid;grid-template-columns:minmax(88px,max-content) minmax(0,1fr);gap:4px 12px;margin:0;font:12px/1.5 var(--mono)}.logdrawer-section dt{color:var(--muted)}.logdrawer-section dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdrawer-raw{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}.logdrawer-raw summary{cursor:pointer;color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logdrawer-raw pre{max-height:320px;margin:10px 0 0;overflow:auto;padding:10px;border:1px solid var(--line);border-radius:var(--radius-xs);background:#080a0e;font:11px/1.6 var(--mono)}@media (max-width: 1180px){.loghead,.logrow{grid-template-columns:84px 46px minmax(130px,170px) minmax(200px,1fr) minmax(88px,130px);gap:10px}.loghead>:last-child,.logrow-duration{display:none}.loghead,.logbody{min-width:640px}}@media (max-width: 900px){.loghead,.logrow{grid-template-columns:46px minmax(110px,140px) minmax(180px,1fr) minmax(72px,110px);gap:8px}.loghead>:first-child,.logrow-time{display:none}.loghead,.logbody{min-width:520px}}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=idle]{background:var(--idle)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.logview{height:min(66dvh,720px)}.logbar-actions{margin-left:0}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:0}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar .account-trigger{width:44px;height:44px;padding:0}.topbar .bell,.topbar .account-menu,.topbar .omni-input{height:44px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (pointer: fine) and (min-width: 821px) and (max-height: 800px){.page{padding-top:20px;padding-bottom:52px}.page-head{margin-bottom:16px}.card{padding-top:14px;padding-bottom:14px}.card-head{margin-bottom:11px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}} +@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/admin/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{color-scheme:dark;--oled: #000;--bg: #080b10;--rail-bg: var(--oled);--top-bg: rgba(1, 4, 9, .94);--surface: #0d1117;--surface-lift: #161b22;--surface-hi: #21262d;--line: #30363d;--line-soft: #21262d;--text: #f0f6fc;--muted: #b1bac4;--quiet: #7d8590;--accent: #2ea043;--accent-ink: #56d364;--accent-wash: rgba(46, 160, 67, .16);--idle: #4a8f60;--idle-ink: #86c79a;--idle-wash: rgba(74, 143, 96, .13);--danger: #e5534b;--danger-ink: #ff9b94;--danger-wash: rgba(229, 83, 75, .13);--warn: #e0ad4e;--warn-ink: #f2cb78;--warn-wash: rgba(239, 196, 107, .13);--info: #4f9cd8;--info-ink: #93cbef;--info-wash: rgba(79, 156, 216, .14);--note: #a07ce8;--note-ink: #bda1f5;--note-wash: rgba(160, 124, 232, .14);--data: #3fc2b6;--data-ink: #6fdcd0;--data-wash: rgba(63, 194, 182, .13);--rail: 236px;--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px);--top: calc(58px + var(--safe-top));--radius: 8px;--radius-sm: 6px;--radius-xs: 4px;--sans: "Inter Variable", Inter, "Segoe UI", sans-serif;--mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:radial-gradient(circle at 72% -20%,rgba(63,194,182,.045),transparent 38rem),var(--bg);color:var(--text);font:16.5px/1.55 var(--sans);font-weight:450;letter-spacing:0;-webkit-font-smoothing:antialiased;min-width:320px;min-height:100dvh;background:var(--bg);-webkit-tap-highlight-color:transparent}a{color:var(--accent-ink)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}}.skip{position:absolute;left:-9999px;top:8px;z-index:60;padding:8px 14px;border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);text-decoration:none}.skip:focus{left:8px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--radius-xs)}.topbar{position:fixed;inset:0 0 auto 0;z-index:30;height:var(--top);display:flex;align-items:center;gap:12px;padding:var(--safe-top) max(clamp(14px,2vw,22px),var(--safe-right)) 0 var(--safe-left);background:var(--top-bg);border-bottom:1px solid var(--line);box-shadow:0 1px #f0f6fc05,0 8px 24px #0003;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px)}.topbar-brand{display:flex;align-items:center;gap:11px;flex:0 0 var(--rail);height:100%;padding:0 22px;color:var(--text);text-decoration:none}.brand-mark{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--accent);color:#fff;font-size:16px;font-weight:800;letter-spacing:-.06em}.brand-word{font-size:17px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}.topbar-spacer{flex:1 1 auto;min-width:0}.topbar-tools{display:flex;align-items:center;gap:10px;min-width:0}.topbar .omni{position:absolute;left:50%;transform:translate(-50%);width:min(820px,calc(100vw - var(--rail) - 380px))}.topbar-version{font:11.5px/1 var(--mono);color:var(--quiet);white-space:nowrap}.topbar-status{display:grid;place-items:center;width:38px;height:38px;flex:0 0 38px;padding:0;border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:inherit;cursor:pointer;transition:background .16s ease,border-color .16s ease,color .16s ease}.topbar-status:hover:not(:disabled),.topbar-status:focus-visible{background:var(--surface-lift)}.topbar-status:disabled{cursor:default;opacity:1}.topbar-status .dot{width:9px;height:9px;border-radius:50%;background:var(--quiet);box-shadow:0 0 0 3px #7d85901f}.topbar-status[data-tone=ok] .dot{background:var(--accent-ink);box-shadow:0 0 0 3px var(--accent-wash),0 0 9px #56d36447}.topbar-status[data-tone=bad] .dot{background:var(--danger);box-shadow:0 0 0 3px var(--danger-wash)}.account-menu{position:relative;display:flex;align-items:center;height:38px;flex:0 0 auto}.account-trigger{max-width:190px;height:38px;padding:0 9px 0 5px;border-color:var(--line);background:var(--surface);color:var(--muted)}.account-trigger:hover:not(:disabled),.account-menu[data-open=true] .account-trigger{border-color:#56d36480;background:var(--surface-hi);color:var(--text)}.account-avatar{display:grid;place-items:center;width:27px;height:27px;flex:0 0 27px;border:1px solid rgba(86,211,100,.36);border-radius:50%;background:var(--accent-wash);color:var(--accent-ink);font-size:11px;font-weight:750}.account-name{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:12.5px}.account-caret{width:12px;height:12px;transition:transform .16s ease}.account-menu[data-open=true] .account-caret{transform:rotate(180deg)}.account-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(280px,calc(100vw - 24px));padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.account-identity{display:flex;align-items:center;gap:11px;min-width:0;padding:9px 10px 12px;border-bottom:1px solid var(--line-soft)}.account-avatar-large{width:34px;height:34px;flex-basis:34px;font-size:13px}.account-identity span:last-child{min-width:0}.account-identity small,.account-identity b{display:block}.account-identity small{color:var(--quiet);font-size:11px}.account-identity b{overflow:hidden;text-overflow:ellipsis;font-size:13.5px}.account-panel>a,.account-panel form button{display:flex;align-items:center;gap:9px;justify-content:flex-start;width:100%;height:38px;padding:0 10px;border-radius:8px;color:var(--muted);font-size:13px;text-decoration:none}.account-panel>a:hover{background:var(--surface-hi);color:var(--text)}.account-panel>a{margin-top:6px}.account-panel form{margin-top:2px}.account-panel form button{justify-content:flex-start;width:100%;height:38px;border-color:transparent;background:transparent;color:var(--muted)}.account-panel form button:hover:not(:disabled){background:var(--surface-hi);color:var(--text)}@media (max-width: 900px){.topbar-version{display:none}}.omni{position:relative;width:100%}.omni-input{display:flex;align-items:center;gap:8px;width:100%;height:38px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.omni[data-open=true] .omni-input{border-color:var(--accent)}.omni-input input[type=search]{flex:1 1 auto;min-width:0;height:100%;padding:0;border:0;border-radius:0;background:none;color:var(--text);font:inherit;font-size:14px;outline:none}.omni-input input::placeholder{color:var(--quiet)}.omni-input .ico{color:var(--quiet);flex:0 0 15px}.omni-key{flex:0 0 auto;padding:2px 6px;border:1px solid var(--line);border-radius:4px;font:10.5px/1.3 var(--mono);color:var(--quiet)}.omni-panel{position:absolute;top:calc(100% + 6px);right:auto;left:0;width:100%;max-height:min(50vh,420px);overflow-y:auto;padding:6px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40}.omni-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:var(--radius-sm);color:var(--text);text-decoration:none}.omni-item .ico{color:var(--quiet);flex:0 0 16px}.omni-item.on,.omni-item:hover{background:var(--surface-hi)}.omni-item b{font-size:13.5px;font-weight:600}.omni-item small{display:block;color:var(--quiet);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.omni-item span{flex:1 1 auto;min-width:0}.omni-group{flex:0 0 auto;color:var(--quiet);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.bell{position:relative;display:flex;align-items:center;height:38px}.bell-button{position:relative;display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}.bell-button:hover{background:var(--surface-hi);color:var(--text)}.bell[data-open=true] .bell-button{border-color:var(--accent);color:var(--text)}.bell-badge{position:absolute;top:-6px;right:-6px;min-width:17px;height:17px;padding:0 4px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.bell-panel{position:absolute;top:calc(100% + 8px);right:0;width:min(420px,92vw);border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 18px 44px #0000008c;z-index:40;overflow:hidden}.bell-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:11px 14px;border-bottom:1px solid var(--line)}.bell-head b{font-size:13px}.bell-list{max-height:min(58vh,460px);overflow-y:auto}.bell-item{display:flex;gap:11px;padding:11px 14px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.bell-item:last-child{border-bottom:0}.bell-item:hover{background:var(--surface-lift)}.bell-item[data-unread=true]{box-shadow:inset 2px 0 0 var(--accent)}.bell-item .glyph{flex:0 0 auto}.bell-body{flex:1 1 auto;min-width:0}.bell-body b{display:block;font-size:13px;font-weight:600}.bell-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px;overflow-wrap:anywhere}.bell-body time{display:block;margin-top:3px;color:var(--quiet);font-size:11px}.bell-foot{padding:9px 14px;border-top:1px solid var(--line);text-align:center}.bell-foot a{font-size:12.5px;text-decoration:none}.rail{position:fixed;top:var(--top);bottom:0;left:0;width:var(--rail);padding:12px 12px max(20px,var(--safe-bottom));overflow-y:auto;background:var(--rail-bg);border-right:1px solid var(--line);z-index:20}.rail-group+.rail-group{margin-top:4px}.rail-group:first-child{margin-bottom:9px;padding-bottom:9px;border-bottom:1px solid var(--line-soft)}.rail-head{display:flex;align-items:center;gap:6px;width:100%;padding:9px 10px 5px;border:0;background:none;color:var(--quiet);font:600 10.5px/1 var(--sans);letter-spacing:.1em;text-transform:uppercase;cursor:pointer}.rail-head .caret{margin-left:auto;transition:transform .12s ease}.rail-head[aria-expanded=false] .caret{transform:rotate(-90deg)}.rail-head-static{cursor:default;color:var(--muted)}.rail a{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:var(--radius-sm);color:var(--muted);font-size:13.5px;text-decoration:none}.rail a:hover{background:var(--surface);color:var(--text)}.rail a[aria-current=page]{background:var(--accent-wash);color:var(--accent-ink);font-weight:600}.rail a .ico{flex:0 0 17px;opacity:.85}.rail a[aria-current=page] .ico{opacity:1}.rail-badge{margin-left:auto;min-width:18px;padding:0 5px;border-radius:9px;background:var(--danger);color:#fff;font:700 10.5px/17px var(--sans);text-align:center}.page{width:min(calc(100vw - var(--rail)),1920px);min-width:0;margin:var(--top) 0 0 calc(var(--rail) + max(0px,(100vw - var(--rail) - 1920px) / 2));padding:clamp(24px,2.4vw,44px) clamp(22px,3vw,56px) 80px}.page-head{margin-bottom:20px}.page-head h1{margin:0;font-size:22px;font-weight:650;letter-spacing:-.02em}.page-head p{margin:5px 0 0;max-width:68ch;color:var(--muted);font-size:13.5px}.page-head-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}.page-head-title{display:flex;align-items:flex-start;gap:12px;min-width:0}.page-head-icon{display:grid;place-items:center;flex:0 0 40px;width:40px;height:40px;border:1px solid var(--line);border-radius:10px;background:var(--accent-wash);color:var(--accent-ink)}.page-head-icon .ico{width:20px;height:20px}.page-head-text{min-width:0}.page-head-row .page-head-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}.crumbs{display:flex;align-items:center;gap:6px;margin-bottom:8px;color:var(--quiet);font-size:12px}.crumbs a{color:var(--muted);text-decoration:none}.crumbs a:hover{color:var(--text)}@media (max-width: 1400px){:root{--rail: 0px}.rail{transform:translate(-100%);transition:transform .2s cubic-bezier(.22,1,.36,1);width:min(320px,calc(100vw - 72px));padding-right:14px;padding-left:max(14px,var(--safe-left));background:#080c12fa;box-shadow:none}.rail[data-open=true]{transform:none;box-shadow:24px 0 80px #00000094}.topbar-brand{flex:0 0 auto;padding:0 4px 0 12px}.page{margin-left:0}}.rail-scrim{display:none}@media (max-width: 1400px){.rail-scrim{position:fixed;inset:var(--top) 0 0 0;z-index:19;display:block;width:auto;height:auto;padding:0;border:0;border-radius:0;background:#01040994;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);animation:rail-scrim-in .16s ease-out}.rail-scrim:hover:not(:disabled){border:0;background:#01040994}}@keyframes rail-scrim-in{0%{opacity:0}}.rail-toggle{display:none;width:34px;height:34px;margin-left:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);cursor:pointer}@media (max-width: 1400px){.rail-toggle{display:grid;place-items:center}}.banner{display:flex;align-items:center;gap:10px;margin-bottom:16px;padding:11px 14px;border:1px solid var(--danger);border-radius:var(--radius-sm);background:var(--danger-wash);color:var(--danger-ink);font-size:13.5px}.banner button{margin-left:auto;border:0;background:none;color:inherit;cursor:pointer;font:inherit}.card{padding:16px 18px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:16px}.card-head{display:flex;align-items:center;gap:11px;margin-bottom:14px}.card-head h2{margin:0;font-size:14.5px;font-weight:620;letter-spacing:-.01em}.card-head p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.card-head-text{min-width:0}.card-head-actions{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.card-foot{margin-top:14px;padding-top:13px;border-top:1px solid var(--line-soft);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}.grid[data-cols="2"]{grid-template-columns:repeat(auto-fit,minmax(400px,1fr))}.grid[data-cols=wide]{grid-template-columns:minmax(0,2fr) minmax(300px,1fr)}@media (max-width: 1100px){.grid[data-cols=wide]{grid-template-columns:1fr}}.tiles{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));margin-bottom:16px}.tile{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.tile .glyph{margin:0 0 9px}.tile b{display:block;font-size:24px;font-weight:650;letter-spacing:-.03em;line-height:1.15}.tile b.small{font-size:14.5px;font-weight:600;letter-spacing:-.01em;line-height:1.4}.tile span{display:block;margin-top:3px;color:var(--muted);font-size:12px}a.tile{color:inherit;text-decoration:none}a.tile:hover{border-color:var(--accent);background:var(--surface-lift)}.ico{display:block;width:1em;height:1em;font-size:17px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}.glyph{display:grid;place-items:center;width:30px;height:30px;flex:0 0 30px;border-radius:var(--radius-sm);background:var(--surface-hi);color:var(--muted)}.tile .glyph .ico{width:18px;height:18px;margin:auto}.loading-page{display:grid;gap:20px}.loading-heading{display:grid;gap:10px;max-width:48rem}.loading-heading .skeleton:first-child{height:34px;width:28%}.loading-heading .skeleton:last-child{height:18px;width:72%}.loading-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.loading-tiles .skeleton{height:132px}.loading-card{display:grid;gap:12px;min-height:180px;padding:22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.loading-card .skeleton:first-child{height:20px;width:32%}.loading-card .skeleton:not(:first-child){height:14px}@media (max-width: 820px){.loading-tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}.glyph .ico{display:block}.glyph[data-tone=ok]{background:var(--accent-wash);color:var(--accent-ink)}.glyph[data-tone=idle]{background:var(--idle-wash);color:var(--idle-ink)}.glyph[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.glyph[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.glyph[data-tone=info]{background:var(--info-wash);color:var(--info-ink)}.glyph[data-tone=note]{background:var(--note-wash);color:var(--note-ink)}.glyph[data-tone=data]{background:var(--data-wash);color:var(--data-ink)}.tag{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font-size:11.5px;font-weight:600;white-space:nowrap}.tag[data-tone]:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.tag[data-tone=ok]{border-color:#52b54b59;background:var(--accent-wash);color:var(--accent-ink)}.tag[data-tone=idle]{border-color:#4a8f6059;background:var(--idle-wash);color:var(--idle-ink)}.tag[data-tone=warn]{border-color:#efc46b59;background:var(--warn-wash);color:var(--warn-ink)}.tag[data-tone=bad]{border-color:#e5534b59;background:var(--danger-wash);color:var(--danger-ink)}.tag[data-tone=info]{border-color:#4f9cd859;background:var(--info-wash);color:var(--info-ink)}.tag[data-tone=note]{border-color:#a07ce859;background:var(--note-wash);color:var(--note-ink)}.tag[data-tone=data]{border-color:#3fc2b659;background:var(--data-wash);color:var(--data-ink)}.chip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font:11.5px/1.5 var(--mono);white-space:nowrap}.chip[data-tone=ok]{color:var(--accent-ink)}.chip[data-tone=idle]{color:var(--idle-ink)}.chip[data-tone=warn]{color:var(--warn-ink)}.chip[data-tone=bad]{color:var(--danger-ink)}.chip[data-tone=info]{color:var(--info-ink)}.chip[data-tone=note]{color:var(--note-ink)}.chip[data-tone=data]{color:var(--data-ink)}.chips{display:flex;flex-wrap:wrap;gap:6px}.table-wrap{overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;margin:0 -18px -16px;padding:0 18px 16px}table{width:100%;min-width:max-content;border-collapse:collapse;font-size:13px}@media (max-width: 1400px){.topbar{display:flex;gap:10px;height:var(--top);padding:var(--safe-top) max(16px,var(--safe-right)) 0 max(16px,var(--safe-left))}.topbar-brand{padding:0}.rail-toggle{margin-left:0}.topbar-spacer{display:none}.topbar .omni{position:relative;left:auto;transform:none;flex:1 1 auto;width:auto;max-width:640px;min-width:0}.omni-key,.topbar-version{display:none}.topbar-tools{display:contents}.bell-button,.rail-toggle{width:40px;height:40px}.topbar-status{width:40px;flex-basis:40px;min-width:40px;height:40px;border:1px solid var(--line);background:var(--surface)}.omni-input,.bell,.bell-button,.account-menu,.account-trigger{height:40px}.page{width:100%;padding:clamp(24px,3vw,34px) max(24px,calc(var(--safe-right) + 10px)) max(64px,calc(var(--safe-bottom) + 36px)) max(24px,calc(var(--safe-left) + 10px))}.table-wrap{margin:0 -18px -16px;padding:0 18px 18px;scrollbar-gutter:stable}table{font-size:13px}th,td{padding-right:16px}}@media (max-width: 1100px){.account-trigger{width:40px;padding:0}.account-name,.account-caret{display:none}}@media (max-width: 820px){.brand-word{display:none}.topbar-status{display:grid;place-items:center;width:40px;padding:0}.page{padding:22px max(16px,var(--safe-right)) max(56px,calc(var(--safe-bottom) + 28px)) max(16px,var(--safe-left))}.grid,.grid[data-cols="2"],.grid[data-cols=wide],.grid.two{grid-template-columns:minmax(0,1fr)}.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}.table-wrap{margin-right:-16px;margin-left:-16px;padding-right:16px;padding-left:16px}}@media (max-width: 680px){:root{--top: calc(60px + var(--safe-top))}.topbar{align-items:center;flex-wrap:nowrap;gap:clamp(4px,1.5vw,8px);padding:calc(var(--safe-top) + 8px) max(8px,var(--safe-right)) 8px max(8px,var(--safe-left))}.rail-toggle{order:1}.topbar-brand{order:2;height:40px;flex:0 0 auto}.topbar .omni{order:3;flex:1 1 96px;width:0;min-width:0;max-width:none}.topbar-status{order:4;margin-left:0}.bell{order:5}.account-menu{order:6}.brand-mark{width:30px;height:30px;flex-basis:30px}.account-panel,.bell-panel{position:fixed;top:calc(var(--top) + 8px)}.account-panel{right:max(12px,var(--safe-right))}}@media (max-width: 370px){.tiles{grid-template-columns:minmax(0,1fr)}}th{padding:0 12px 8px 0;border-bottom:1px solid var(--line);color:var(--quiet);font-weight:600;font-size:11px;letter-spacing:.07em;text-transform:uppercase;text-align:left;white-space:nowrap}td{padding:9px 12px 9px 0;border-bottom:1px solid var(--line-soft);vertical-align:middle}tr:last-child td{border-bottom:0}tbody tr:hover td{background:var(--surface-lift)}th:last-child,td:last-child{padding-right:0}td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}td.mono{font:12px/1.5 var(--mono);color:var(--muted)}td.nowrap,th.nowrap{white-space:nowrap}td.muted{color:var(--muted)}th button{display:inline-flex;align-items:center;gap:4px;border:0;padding:0;background:none;color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer}th button:hover{color:var(--text)}th[aria-sort] button{color:var(--accent-ink)}.table-row-link{color:var(--text);text-decoration:none;font-weight:600}.table-row-link:hover{color:var(--accent-ink)}.list{display:flex;flex-direction:column;gap:1px}.list-item{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--line-soft)}.list-item:last-child{border-bottom:0}.list-item .list-body{flex:1 1 auto;min-width:0}.list-item b{display:block;font-size:13.5px;font-weight:600}.list-item p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.list-item .list-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}button,.btn{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 13px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift);color:var(--text);font:600 13px/1 var(--sans);text-decoration:none;cursor:pointer;white-space:nowrap}button:hover:not(:disabled),.btn:hover{background:var(--surface-hi);border-color:#2c3542}button:disabled{opacity:.45;cursor:not-allowed}button[data-variant=primary]{border-color:var(--accent);background:var(--accent);color:#06240a}button[data-variant=primary]:hover:not(:disabled){background:var(--accent-ink);border-color:var(--accent-ink)}button[data-variant=danger]{border-color:#e5534b66;background:var(--danger-wash);color:var(--danger-ink)}button[data-variant=danger]:hover:not(:disabled){background:#e5534b38}button[data-variant=quiet]{border-color:transparent;background:none;color:var(--muted)}button[data-variant=quiet]:hover:not(:disabled){background:var(--surface-lift);color:var(--text)}button[data-size=sm]{height:26px;padding:0 9px;font-size:12px}.field{display:block;min-width:0}.field>span{display:block;margin-bottom:5px;color:var(--muted);font-size:12px;font-weight:600}.field>small{display:block;margin-top:5px;color:var(--quiet);font-size:11.5px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select,textarea{width:100%;height:32px;padding:0 10px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:inherit;font-size:13px}textarea{height:auto;min-height:74px;padding:8px 10px;resize:vertical}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}input::placeholder,textarea::placeholder{color:var(--quiet)}select{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:26px;background-image:linear-gradient(45deg,transparent 50%,var(--quiet) 50%),linear-gradient(135deg,var(--quiet) 50%,transparent 50%);background-position:calc(100% - 14px) 14px,calc(100% - 9px) 14px;background-size:5px 5px;background-repeat:no-repeat}.fields{display:grid;gap:13px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.field-row{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap}.check{display:flex;align-items:flex-start;gap:11px;padding:10px 0;cursor:pointer}.check input{position:absolute;opacity:0;pointer-events:none}.check .switch{position:relative;width:34px;height:20px;flex:0 0 34px;margin-top:1px;border-radius:999px;background:var(--surface-hi);border:1px solid var(--line);transition:background .12s ease,border-color .12s ease}.check .switch:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--muted);transition:transform .12s ease,background .12s ease}.check input:checked+.switch{background:var(--accent-wash);border-color:var(--accent)}.check input:checked+.switch:after{transform:translate(14px);background:var(--accent)}.check input:focus-visible+.switch{outline:2px solid var(--accent);outline-offset:2px}.check input:disabled+.switch{opacity:.45}.check-body b{display:block;font-size:13.5px;font-weight:600}.check-body p{margin:2px 0 0;color:var(--muted);font-size:12.5px}.segments{display:inline-flex;padding:2px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.segments button{height:26px;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:600}.segments button[aria-pressed=true]{background:var(--surface-hi);color:var(--text)}.filters{display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap;margin-bottom:14px;padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface)}.filters .field{flex:1 1 160px;max-width:260px}.filters .field.grow{flex:2 1 240px;max-width:none}.filters .filter-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.filter-summary{color:var(--quiet);font-size:12px;white-space:nowrap}.bars{display:flex;align-items:flex-end;gap:3px;height:92px;padding-top:6px}.bar{position:relative;flex:1 1 0;min-width:3px;border-radius:2px 2px 0 0;background:var(--accent-wash);border-top:2px solid var(--accent);min-height:2px}.bar[data-tone=bad]{background:var(--danger-wash);border-top-color:var(--danger)}.bar[data-empty=true]{background:var(--line-soft);border-top-color:var(--line)}.bars-axis{display:flex;justify-content:space-between;margin-top:6px;color:var(--quiet);font-size:11px}.meter{height:6px;border-radius:3px;background:var(--surface-hi);overflow:hidden}.meter>div{height:100%;background:var(--accent)}.meter[data-tone=warn]>div{background:var(--warn)}.meter[data-tone=bad]>div{background:var(--danger)}.logbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;margin-bottom:10px}.logbar-filters{display:flex;flex:1 1 520px;flex-wrap:wrap;align-items:center;gap:6px;min-width:0}.logbar-filters select{width:auto;min-width:0;height:30px;padding:0 26px 0 9px;font-size:12px}.logbar-actions{display:flex;flex:0 0 auto;gap:6px;margin-left:auto}.logsearch{position:relative;display:flex;flex:1 1 260px;align-items:center;min-width:200px}.logsearch svg{position:absolute;left:9px;width:14px;height:14px;color:var(--quiet);pointer-events:none}.logsearch input{width:100%;height:30px;padding-left:28px;font-size:12px}.logchips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}.logchip{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--surface-lift);color:var(--muted);font:500 11px/1 var(--sans)}.logchip svg{width:11px;height:11px;opacity:.7}.logchip:hover:not(:disabled){border-color:var(--danger);background:var(--danger-wash);color:var(--danger-ink)}.logchip-clear{border-style:dashed;color:var(--quiet)}.logshell{position:relative}.logview{height:62vh;min-height:320px;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;font:12px/1.5 var(--sans);contain:layout paint style}.loghead,.logrow{display:grid;grid-template-columns:92px 52px 190px minmax(240px,1fr) 150px 68px;gap:12px;padding:0 12px}.loghead{position:sticky;top:0;z-index:2;align-items:center;height:31px;border-bottom:1px solid var(--line);background:#10131a;color:var(--quiet);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;min-width:900px}.loghead>:last-child{text-align:right}.logbody{position:relative;min-width:900px}.logday{position:absolute;top:0;right:0;left:0;display:flex;align-items:center;height:26px;padding:0 12px;background:linear-gradient(to bottom,rgba(255,255,255,.03),transparent);color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logday span{padding-right:10px;background:#080a0e}.logday:after{content:"";flex:1;height:1px;background:var(--line-soft)}.logrow{position:absolute;top:0;right:0;left:0;align-items:start;padding-top:7px;padding-bottom:7px;border-bottom:1px solid rgba(255,255,255,.03);contain:strict}.logrow>*,.logrow-line{line-height:16px}.logrow:hover{background:#ffffff09}.logrow[data-selected]{background:var(--accent-wash)}.logrow[data-level=ERROR]{box-shadow:inset 2px 0 0 var(--danger);background:#e5534b0d}.logrow[data-level=WARN]{box-shadow:inset 2px 0 0 var(--warn)}.logrow-time{color:var(--quiet);font:11px/16px var(--mono);font-variant-numeric:tabular-nums}.logfacet{height:auto;min-width:0;padding:0;overflow:hidden;border:0;background:none;color:inherit;font:inherit;text-align:left;text-overflow:ellipsis;white-space:nowrap}.logfacet:hover:not(:disabled){border:0;background:none;text-decoration:underline;text-underline-offset:3px}.logfacet:focus-visible{outline:1px solid var(--accent);outline-offset:2px}.logrow-level{color:var(--quiet);font:700 10px/16px var(--sans);letter-spacing:.06em}.logrow-level[data-level=ERROR]{color:var(--danger-ink)}.logrow-level[data-level=WARN]{color:var(--warn-ink)}.logrow-level[data-level=INFO]{color:var(--muted)}.logrow-level[data-level=DEBUG],.logrow-level[data-level=TRACE]{color:var(--quiet)}.logrow-place{display:flex;align-items:center;gap:5px;min-width:0;height:16px;overflow:hidden}.logrow-service{flex:0 0 auto;max-width:96px;overflow:hidden;color:var(--quiet);font:700 10px/16px var(--sans);letter-spacing:.07em;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}.logrow-service[data-tone=info]{color:var(--info-ink)}.logrow-service[data-tone=note]{color:var(--note-ink)}.logrow-service[data-tone=data]{color:var(--data-ink)}.logrow-service[data-tone=idle]{color:var(--idle-ink)}.logrow-service[data-tone=quiet]{color:var(--muted)}.logrow-sep{flex:0 0 auto;color:var(--line)}.logrow-component{flex:1 1 auto;color:var(--quiet);font-size:11.5px;line-height:16px}.logrow-summary{display:flex;flex-direction:column;gap:2px;align-items:stretch;justify-content:flex-start;width:100%;min-width:0;padding:0;overflow:hidden;border:0;background:none;font:inherit;text-align:left}.logrow-summary:hover:not(:disabled){border:0;background:none}.logrow-summary:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}.logrow-line{display:flex;align-items:baseline;gap:8px;min-width:0;height:16px}.logrow-action{flex:0 0 auto;color:var(--muted);font:600 10.5px/16px var(--mono);letter-spacing:.04em}.logrow-action[data-method=POST],.logrow-action[data-method=PUT],.logrow-action[data-method=PATCH]{color:var(--info-ink)}.logrow-action[data-method=DELETE]{color:var(--danger-ink)}.logrow-text{flex:1 1 auto;min-width:0;overflow:hidden;color:var(--text);text-overflow:ellipsis;white-space:nowrap}.logrow-trail{flex:0 1 auto;min-width:0;max-width:38%;overflow:hidden;color:var(--quiet);font-size:11px;text-overflow:ellipsis;white-space:nowrap}.logrow-second{height:15px;overflow:hidden;color:var(--quiet);font-size:11px;line-height:15px;text-overflow:ellipsis;white-space:nowrap}.logrow-second[data-tone=error]{color:var(--danger-ink)}.logrow-result{min-width:0;height:16px;overflow:hidden}.logrow-verdict{display:inline-block;max-width:100%;color:var(--quiet);font:500 11px/14px var(--mono)}.logrow-verdict[data-tone=ok]{color:var(--muted)}.logrow-verdict[data-tone=info]{color:var(--info-ink)}.logrow-verdict[data-tone=data]{color:var(--data-ink)}.logrow-verdict[data-tone=warn],.logrow-verdict[data-tone=bad]{padding:1px 6px;border-radius:var(--radius-xs);font-weight:600}.logrow-verdict[data-tone=warn]{background:var(--warn-wash);color:var(--warn-ink)}.logrow-verdict[data-tone=bad]{background:var(--danger-wash);color:var(--danger-ink)}.logrow-duration{color:var(--quiet);font:11px/16px var(--mono);font-variant-numeric:tabular-nums;text-align:right}.logrow-duration[data-tone=warn]{color:var(--warn-ink)}.logrow-duration[data-tone=bad]{color:var(--danger-ink);font-weight:600}.logtail{position:absolute;right:18px;bottom:14px;z-index:3;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border:1px solid var(--accent);border-radius:999px;background:var(--surface-lift);color:var(--accent-ink);font:600 11px/1 var(--sans);box-shadow:0 6px 16px #00000080}.logtail svg{width:12px;height:12px;transform:rotate(90deg)}.logtail:hover:not(:disabled){border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.logdrawer{margin-top:12px;padding:14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface)}.logdrawer-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:1px solid var(--line)}.logdrawer-head>div:first-child{min-width:0}.logdrawer-head b{display:block;margin-top:4px;font-size:14px;overflow-wrap:anywhere}.logdrawer-place{display:flex;align-items:baseline;gap:5px;margin:0;color:var(--quiet);font-size:11px}.logdrawer-error{margin:6px 0 0;color:var(--danger-ink);font:12px/1.5 var(--mono);overflow-wrap:anywhere}.logdrawer-actions{display:flex;flex:0 0 auto;gap:6px}.logdrawer-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px 24px;margin-top:12px}.logdrawer-section h4{margin:0 0 6px;color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logdrawer-section dl{display:grid;grid-template-columns:minmax(88px,max-content) minmax(0,1fr);gap:4px 12px;margin:0;font:12px/1.5 var(--mono)}.logdrawer-section dt{color:var(--muted)}.logdrawer-section dd{min-width:0;margin:0;overflow-wrap:anywhere}.logdrawer-raw{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}.logdrawer-raw summary{cursor:pointer;color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.logdrawer-raw pre{max-height:320px;margin:10px 0 0;overflow:auto;padding:10px;border:1px solid var(--line);border-radius:var(--radius-xs);background:#080a0e;font:11px/1.6 var(--mono)}@media (max-width: 1180px){.loghead,.logrow{grid-template-columns:84px 46px 170px minmax(200px,1fr) 130px;gap:10px}.loghead>:last-child,.logrow-duration{display:none}.loghead,.logbody{min-width:680px}}@media (max-width: 900px){.loghead,.logrow{grid-template-columns:46px 140px minmax(180px,1fr) 110px;gap:8px}.loghead>:first-child,.logrow-time{display:none}.loghead,.logbody{min-width:520px}}.logdetails{min-width:0}.logdetails summary{cursor:pointer;list-style:none;overflow-wrap:anywhere}.logdetails summary::-webkit-details-marker{display:none}.logdetails summary:before{content:"›";display:inline-block;margin-right:6px;color:var(--accent)}.logdetails[open] summary:before{transform:rotate(90deg)}.logdetails dl{display:grid;grid-template-columns:minmax(130px,max-content) minmax(0,1fr);gap:4px 12px;margin:7px 0 2px;padding:8px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#ffffff06}.logdetails dt{color:var(--muted)}.logdetails dd{min-width:0;margin:0;color:var(--text);overflow-wrap:anywhere}pre.code{margin:0;padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-sm);background:#080a0e;color:var(--muted);font:12px/1.6 var(--mono);overflow-x:auto}.empty{margin:0;padding:22px 0;color:var(--quiet);font-size:13px;text-align:center}.muted{color:var(--muted)}.quiet{color:var(--quiet)}.mono{font-family:var(--mono);font-size:12px}.note{margin:0;padding:10px 13px;border:1px solid var(--line);border-left:2px solid var(--info);border-radius:var(--radius-xs);background:var(--surface-lift);color:var(--muted);font-size:12.5px}.note[data-tone=warn]{border-left-color:var(--warn)}.note[data-tone=bad]{border-left-color:var(--danger)}.note[data-tone=ok]{border-left-color:var(--accent)}.stack{display:flex;flex-direction:column;gap:12px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.tight{gap:6px}.spacer{flex:1 1 auto}.spinner{width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.skeleton{border-radius:var(--radius-sm);background:linear-gradient(90deg,var(--surface) 25%,var(--surface-lift) 50%,var(--surface) 75%);background-size:300% 100%;animation:shimmer 1.3s ease-in-out infinite}@keyframes shimmer{to{background-position:-300% 0}}.toasts{position:fixed;right:18px;bottom:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:min(420px,90vw)}.toast{display:flex;align-items:flex-start;gap:10px;padding:11px 14px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-lift);box-shadow:0 14px 34px #00000080;font-size:13px;animation:toast-in .14s ease}.toast[data-tone=bad]{border-left-color:var(--danger)}.toast[data-tone=warn]{border-left-color:var(--warn)}.toast button{margin-left:auto;height:auto;padding:0;border:0;background:none;color:var(--quiet)}@keyframes toast-in{0%{opacity:0;transform:translateY(6px)}}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:45;display:grid;place-items:center;padding:20px;background:#040609b3;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.dialog{width:min(520px,100%);max-height:86vh;overflow-y:auto;padding:20px 22px;border:1px solid var(--line);border-radius:var(--radius);background:var(--surface);box-shadow:0 24px 60px #0009}.dialog h2{margin:0 0 6px;font-size:16px}.dialog p{margin:0 0 16px;color:var(--muted);font-size:13.5px}.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.kv{display:flex;flex-direction:column}.kv-row{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--line-soft);font-size:13.5px}.kv-row:last-child{border-bottom:0}.kv-row>span:first-child{color:var(--muted)}.kv-row>span:last-child{margin-left:auto;text-align:right;min-width:0}.tiles.plain{margin-bottom:12px;grid-template-columns:repeat(auto-fit,minmax(120px,1fr))}.tiles.plain .tile{padding:10px 12px;border:0;background:var(--surface-lift)}.tiles.plain .tile b{font-size:18px}.hint{margin:0;color:var(--quiet);font-size:12px}.grid.two{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}@media (max-width: 980px){.grid.two{grid-template-columns:1fr}}.list-row{display:flex;align-items:center;gap:12px;padding:12px 18px;border-bottom:1px solid var(--line-soft);color:var(--text);text-decoration:none}.list-row:last-child{border-bottom:0}.list-row:hover{background:var(--surface-lift)}.list-main{display:flex;align-items:center;gap:12px;flex:1 1 auto;min-width:0}.list-title{display:flex;align-items:center;gap:7px;font-size:14px;font-weight:600}.list-meta{display:block;margin-top:2px;color:var(--muted);font-size:12.5px}.list-row .list-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.card.flush{padding:0}.avatar{display:grid;place-items:center;width:34px;height:34px;flex:0 0 34px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12.5px;font-weight:700;letter-spacing:.02em}.dot-state{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--quiet)}.dot-state[data-tone=ok]{background:var(--accent)}.dot-state[data-tone=idle]{background:var(--idle)}.dot-state[data-tone=warn]{background:var(--warn)}.dot-state[data-tone=bad]{background:var(--danger)}.checks.columns{display:grid;gap:0 24px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}.crumb{color:var(--muted);font-size:12.5px;text-decoration:none;white-space:nowrap}.crumb:hover{color:var(--accent-ink)}.versions{display:flex;flex-wrap:wrap;gap:4px}.visit{padding:16px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.visit+.visit{margin-top:10px}.visit header{display:flex;align-items:center;gap:12px;margin-bottom:14px}.visit header b{font-size:13.5px}.visit header span{display:block;color:var(--quiet);font-size:12px}.visit header .tag{margin-left:auto}.journey-answers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:0 0 15px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-xs);background:var(--line)}.journey-answer{position:relative;min-width:0;padding:11px 12px 11px 38px;background:var(--surface)}.journey-answer .ico{position:absolute;top:13px;left:12px;width:16px;height:16px;color:var(--quiet)}.journey-answer[data-kind=entry] .ico,.journey-answer[data-kind=outcome] .ico{color:var(--accent)}.journey-answer[data-kind=selection] .ico{color:var(--note-ink)}.journey-answer span,.journey-answer b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.journey-answer span{margin-bottom:3px;color:var(--quiet);font-size:11px;font-weight:650;letter-spacing:.04em;text-transform:uppercase}.journey-answer b{color:var(--text);font-size:13px;font-weight:600}.journey-timeline{margin:0;padding:0 0 0 14px;list-style:none;border-left:1px solid var(--line)}.journey-timeline li{position:relative;display:flex;align-items:baseline;gap:10px;padding:7px 0 7px 5px;font-size:12.5px}.timeline-dot{position:absolute;left:-18px;top:12px;width:7px;height:7px;border-radius:50%;background:var(--accent)}.timeline-dot[data-action=select],.timeline-dot[data-action=open]{background:var(--note-ink)}.timeline-dot[data-action=close]{background:var(--quiet)}.journey-timeline li>div{min-width:0}.journey-timeline li b{display:inline;font-weight:600}.journey-timeline li div span{display:block;color:var(--muted)}.journey-timeline li time{margin-left:auto;padding-left:10px;color:var(--quiet);white-space:nowrap}.visits{max-height:60vh;overflow-y:auto}@media (max-width: 640px){.journey-answers{grid-template-columns:1fr}.journey-timeline li{align-items:flex-start;flex-wrap:wrap}.journey-timeline li time{width:100%;margin:2px 0 0;padding-left:0}}.summary{padding:13px 15px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface-lift)}.summary-head{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}.summary-head b{font-size:13px}.summary-head strong{margin-left:auto;font-size:22px;font-weight:650;letter-spacing:-.03em}.summary p{margin:9px 0 0;color:var(--muted);font-size:12.5px}.summary-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));margin-top:14px}.table-sub{display:block;color:var(--quiet);font-size:11.5px}.route-arrow{color:var(--quiet)}details summary{cursor:pointer;font-size:12.5px;padding:6px 0}.swatch{position:relative;width:34px;height:22px;flex:0 0 34px;margin-top:1px;border-radius:var(--radius-xs);background:var(--swatch-surface, var(--surface-hi));border:1px solid var(--swatch-hairline, var(--line));overflow:hidden}.swatch i{position:absolute;inset:auto 0 0 0;height:7px;background:var(--swatch-accent, var(--accent))}.group-label{margin:4px 0 2px;color:var(--quiet);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.group+.group{margin-top:14px;padding-top:14px;border-top:1px solid var(--line-soft)}.hero-pins{display:grid;gap:8px;margin-bottom:16px}.hero-pin{display:grid;grid-template-columns:30px minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:52px;padding:8px 10px;border:1px solid var(--line-soft);border-radius:var(--radius-sm);background:var(--surface-lift)}.hero-pin-order{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:var(--note-wash);color:var(--note-ink);font-size:12px;font-weight:750}.hero-pin>span:nth-child(2){min-width:0}.hero-pin b,.hero-pin small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.hero-pin b{font-size:13.5px}.hero-pin small{margin-top:2px;color:var(--quiet);font-size:11.5px}.hero-schedule-list{display:grid;gap:10px}.hero-schedule{position:relative;display:grid;grid-template-columns:100px minmax(0,1fr) auto;align-items:center;gap:16px;padding:15px;overflow:hidden;border:1px solid var(--line);border-radius:11px;background:linear-gradient(135deg,var(--surface-lift),rgba(13,17,23,.74))}.hero-schedule:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--quiet);opacity:.4}.hero-schedule[data-enabled=true]:before{background:var(--info);opacity:1}.hero-schedule-time{align-self:stretch;display:flex;flex-direction:column;justify-content:center;padding-right:14px;border-right:1px solid var(--line-soft)}.hero-schedule-time b{color:var(--info-ink);font-size:11px;letter-spacing:.08em;text-transform:uppercase}.hero-schedule-time span{margin-top:4px;color:var(--muted);font:12px/1.4 var(--mono)}.hero-schedule-main{min-width:0}.hero-schedule-title{display:flex;align-items:center;gap:8px}.hero-schedule-title h3{min-width:0;margin:0;overflow:hidden;color:var(--text);font-size:14px;font-weight:650;text-overflow:ellipsis;white-space:nowrap}.hero-schedule-main>p{margin:4px 0 8px;color:var(--muted);font-size:12.5px}.hero-schedule-actions{display:flex;align-items:center;gap:6px}.hero-schedule-dialog{width:min(760px,100%);padding:0;overflow-x:hidden}.hero-schedule-dialog-head{padding:24px 26px 20px;border-bottom:1px solid var(--line);background:radial-gradient(circle at 90% -40%,var(--info-wash),transparent 240px),var(--surface-lift)}.hero-schedule-kicker{color:var(--info-ink);font-size:10.5px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}.hero-schedule-dialog-head h2{margin-top:4px;font-size:21px;letter-spacing:-.025em}.hero-schedule-dialog-head p{margin-bottom:0}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:26px;margin-left:26px}.schedule-frequency{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:20px 26px}.schedule-frequency button{display:block;height:auto;min-height:68px;padding:11px 13px;text-align:left;white-space:normal}.schedule-frequency button b,.schedule-frequency button span{display:block}.schedule-frequency button span{margin-top:4px;color:var(--quiet);font-size:11.5px;font-weight:500}.schedule-frequency button[aria-pressed=true]{border-color:var(--info);background:var(--info-wash);color:var(--info-ink)}.schedule-frequency button[aria-pressed=true] span{color:var(--muted)}.schedule-days{margin-top:18px;padding:16px;border:1px solid var(--line-soft);border-radius:10px;background:var(--surface-lift)}.schedule-days-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}.schedule-days-head>b{font-size:12.5px}.schedule-days-head>div{display:flex;gap:4px;margin-left:auto}.schedule-days-head button{height:28px;padding:0 8px;border-color:transparent;background:none;color:var(--muted);font-size:11.5px}.schedule-day-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.schedule-day-grid button{width:100%;height:38px;padding:0;color:var(--muted)}.schedule-day-grid button[aria-pressed=true]{border-color:var(--accent);background:var(--accent-wash);color:var(--accent-ink)}.schedule-options{display:grid;grid-template-columns:minmax(0,2fr) minmax(140px,1fr);align-items:start;gap:22px;margin-top:20px;padding-top:18px;border-top:1px solid var(--line-soft)}.schedule-option-label{display:block;margin-bottom:3px;color:var(--muted);font-size:12px;font-weight:600}.schedule-placement-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px 14px}.hero-schedule-dialog>.check{margin-top:8px}.hero-schedule-dialog>.dialog-actions{margin-top:8px;padding:18px 26px;border-top:1px solid var(--line);background:var(--surface-lift)}@media (max-width: 820px){.hero-pin{grid-template-columns:30px minmax(0,1fr) auto}.hero-pin>button:last-child{grid-column:3}.hero-schedule{grid-template-columns:78px minmax(0,1fr);gap:12px}.hero-schedule-actions{grid-column:1 / -1;padding-top:10px;border-top:1px solid var(--line-soft)}.hero-schedule-actions button{flex:1 1 0}.hero-schedule-dialog-head{padding:22px 20px 18px}.hero-schedule-dialog>.fields,.hero-schedule-dialog>.schedule-days,.hero-schedule-dialog>.schedule-options,.hero-schedule-dialog>.check{margin-right:20px;margin-left:20px}.schedule-frequency{gap:6px;margin:16px 20px}.schedule-frequency button{min-width:0;min-height:62px;padding:9px}.schedule-frequency button span{display:none}.schedule-days-head{align-items:flex-start}.schedule-days-head>div{flex-wrap:wrap;justify-content:flex-end}.schedule-day-grid{gap:4px}.schedule-day-grid button{min-width:0;padding:0 3px;font-size:11px}.schedule-options,.schedule-placement-grid{grid-template-columns:1fr}.hero-schedule-dialog>.dialog-actions{padding:16px 20px max(16px,var(--safe-bottom))}}@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px){.page-head{margin-bottom:24px}.page-head h1{font-size:26px;line-height:1.2}.page-head p{max-width:72ch;font-size:14px}.card{padding:20px 22px;border-color:#636e7b6b;border-radius:12px;box-shadow:0 1px #ffffff06,0 14px 40px #00000024}.card+.card,.grid+.card,.card+.grid,.grid+.grid{margin-top:20px}.grid{gap:20px}.card-head{align-items:flex-start;margin-bottom:18px}.card-head h2{font-size:15.5px}.card-head p{margin-top:4px;font-size:13px}.card-foot{margin-top:18px;padding-top:16px}.tiles{gap:14px}.tile{min-height:112px;padding:16px 17px;border-radius:12px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:27px}.filters{gap:12px;padding:16px;border-radius:12px}.filters .field{flex:1 1 calc(50% - 6px);max-width:none}.filters .field.grow{flex-basis:100%}.list-row{min-height:64px;padding:14px 20px}.list-item{min-height:60px;padding:13px 0}.logview{height:min(66dvh,720px)}.logbar-actions{margin-left:0}}@media (max-width: 820px){.page-head-row{display:grid;grid-template-columns:minmax(0,1fr);gap:14px}.page-head-row .page-head-actions{width:100%;margin-left:0}.card{padding:17px 18px;border-radius:11px}.card-head{align-items:flex-start;flex-wrap:wrap}.card-head-actions{width:100%;margin-left:0}.card-foot>button,.card-foot>.btn{flex:1 1 auto}.tile{min-width:0;min-height:104px;padding:14px;background:linear-gradient(145deg,var(--surface-lift),var(--surface))}.tile b{font-size:23px}.tile span{overflow-wrap:anywhere}.filters .field,.filters .field.grow{flex:1 1 100%;max-width:none}.filters .filter-actions{width:100%;margin-left:0}.list-item,.list-row,.list-main{align-items:flex-start}.list-item,.list-row{flex-wrap:wrap}.list-item .list-actions,.list-row .list-actions{width:100%;justify-content:flex-start;padding-left:46px}.kv-row{align-items:flex-start}.dialog{width:100%;max-height:min(88dvh,720px);padding:22px;border-radius:16px}.scrim{align-items:end;padding:12px max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left))}.dialog-actions>button{flex:1 1 0}.bell-panel{position:fixed;top:calc(var(--top) + 8px);right:max(12px,var(--safe-right));left:max(12px,var(--safe-left));width:auto;max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 20px);border-radius:14px}.bell-list{max-height:calc(100dvh - var(--top) - var(--safe-bottom) - 120px)}.toasts{right:max(12px,var(--safe-right));bottom:max(12px,var(--safe-bottom));left:max(12px,var(--safe-left));max-width:none}}@media (pointer: coarse) and (max-width: 1400px){button,.btn{min-width:44px;height:44px;padding-right:15px;padding-left:15px}button[data-size=sm]{min-width:38px;height:38px;padding-right:11px;padding-left:11px}.topbar .rail-toggle,.topbar .bell-button,.topbar .topbar-status,.topbar .account-trigger{width:44px;height:44px;padding:0}.topbar .bell,.topbar .account-menu,.topbar .omni-input{height:44px}.rail a{min-height:46px;padding:10px 12px;border-radius:9px;font-size:14px}.rail-head{min-height:42px;padding:12px}input[type=text],input[type=password],input[type=search],input[type=number],input[type=date],input[type=time],input[type=datetime-local],input[type=url],select{height:44px;font-size:16px}select{background-position:calc(100% - 16px) 20px,calc(100% - 11px) 20px}textarea{min-height:104px;font-size:16px}.check{min-height:52px;padding-top:14px;padding-bottom:14px}.check .switch{width:40px;height:24px;flex-basis:40px}.check .switch:after{width:18px;height:18px}.check input:checked+.switch:after{transform:translate(16px)}.segments{max-width:100%;overflow-x:auto}.segments button{height:38px}.crumb,.crumbs a,.table-row-link{display:inline-flex;align-items:center;min-height:36px}}@media (pointer: fine) and (min-width: 821px) and (max-height: 800px){.page{padding-top:20px;padding-bottom:52px}.page-head{margin-bottom:16px}.card{padding-top:14px;padding-bottom:14px}.card-head{margin-bottom:11px}}@media (hover: none){tbody tr:hover td,.list-row:hover,.bell-item:hover,a.tile:hover{background:inherit}}.detail-row>td{padding:0 0 12px;background:var(--surface-sunken, transparent)}.logdrawer-raw h4{margin:0 0 6px;color:var(--quiet);font:700 10px/1 var(--sans);letter-spacing:.08em;text-transform:uppercase}.notification-body{max-width:70ch;margin:0;color:var(--text);font:13px/1.6 var(--sans);overflow-wrap:anywhere} diff --git a/admin-ui/dist/assets/index-KrrVPZvy.js b/admin-ui/dist/assets/index-KrrVPZvy.js new file mode 100644 index 0000000..2fab22f --- /dev/null +++ b/admin-ui/dist/assets/index-KrrVPZvy.js @@ -0,0 +1,11 @@ +import{r as x,a as gn,u as cs,L as re,b as ds,m as ts,N as Zs,O as bn,c as Ge,B as fn,R as yn,d as B,e as wn}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 Js={exports:{}},Ze={};/** + * @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 kn=x,Nn=Symbol.for("react.element"),Sn=Symbol.for("react.fragment"),Cn=Object.prototype.hasOwnProperty,Mn=kn.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,En={key:!0,ref:!0,__self:!0,__source:!0};function Ys(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)Cn.call(n,i)&&!En.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:Nn,type:s,key:o,ref:a,props:r,_owner:Mn.current}}Ze.Fragment=Sn;Ze.jsx=Ys;Ze.jsxs=Ys;Js.exports=Ze;var e=Js.exports,Qs,bs=gn;Qs=bs.createRoot,bs.hydrateRoot;const Xs={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",send:"M21 3 10.5 13.5M21 3l-6.8 18-3.7-7.5L3 10z",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=Xs[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 Je({name:s,tone:n}){return Xs[s]?e.jsx("span",{className:"glyph","data-tone":n,children:e.jsx(Y,{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:"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:"notifications",path:"/admin/notifications",label:"Notifications",title:"Notifications",intro:"Everything Memby sent: who it went to, over which channel, and whether it worked.",icon:"send"},{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:"Logs",title:"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 viewers have been looking for, and what was 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"}]}],An=Ce.flatMap(s=>s.items),Tn=Ce.flatMap(s=>s.items.filter(n=>!n.path.includes(":")).map(n=>({...n,group:s.label??""})));class fs extends Error{constructor(n,t){super(n),this.status=t,this.name="ApiError"}}const Rn=5*60*1e3;let en=Date.now();for(const s of["pointerdown","pointermove","keydown","wheel","scroll"])window.addEventListener(s,()=>{en=Date.now()},{passive:!0});const $n=()=>Date.now()-en({}));throw new fs(i.error??`Request failed (${t.status})`,t.status)}if(t.status!==204)return await t.json()}function Ee(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=>Ue(s),post:(s,n)=>Ue(s,{method:"POST",body:n===void 0?void 0:JSON.stringify(n)}),put:(s,n)=>Ue(s,{method:"PUT",body:n===void 0?void 0:JSON.stringify(n)}),del:s=>Ue(s,{method:"DELETE"})},Ln=3e4,sn=x.createContext(null);function Dn({children:s}){var h;const[n,t]=x.useState(),[i,r]=x.useState(!1),[o,a]=x.useState(""),[c,d]=x.useState(""),[l,u]=x.useState(!0),v=x.useRef(0),m=x.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()),u(!1))}},[]),p=x.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 m()},[m,(h=n==null?void 0:n.maintenance)==null?void 0:h.message]);x.useEffect(()=>{m();let g;const b=()=>{window.clearInterval(g),g=document.hidden?void 0:window.setInterval(()=>void m(),Ln)},N=()=>{b(),document.hidden||m()};return b(),document.addEventListener("visibilitychange",N),()=>{window.clearInterval(g),document.removeEventListener("visibilitychange",N)}},[m]);const f=x.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:m,setMaintenance:p}},[n,i,o,c,l,m,p]);return e.jsx(sn.Provider,{value:f,children:s})}function oe(){const s=x.useContext(sn);if(!s)throw new Error("useGateway used outside GatewayProvider");return s}function qn(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 Fn(){const s=cs(),{status:n}=oe(),[t,i]=x.useState(!1),[r,o]=x.useState(""),[a,c]=x.useState(0),d=x.useRef(null),l=x.useRef(null),u=x.useMemo(()=>{const m=r.trim().toLowerCase();return[...Tn,...((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:m?qn(f,m):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]);x.useEffect(()=>c(0),[r]),x.useEffect(()=>{const m=p=>{var f;(f=d.current)!=null&&f.contains(p.target)||i(!1)};return document.addEventListener("pointerdown",m),()=>document.removeEventListener("pointerdown",m)},[]),x.useEffect(()=>{const m=p=>{var h,g;if(p.key!=="S"||!p.shiftKey||p.ctrlKey||p.metaKey||p.altKey)return;const f=document.activeElement;f&&(f.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(f.tagName))||(p.preventDefault(),(h=l.current)==null||h.focus(),(g=l.current)==null||g.select())};return window.addEventListener("keydown",m),()=>window.removeEventListener("keydown",m)},[]);const v=m=>{var p;i(!1),o(""),(p=l.current)==null||p.blur(),s(m)};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","aria-label":"Search","aria-expanded":t,onFocus:()=>i(!0),onChange:m=>{o(m.target.value),i(!0)},onKeyDown:m=>{var p;if(m.key==="Escape")o(""),i(!1),(p=l.current)==null||p.blur();else if(m.key==="ArrowDown")m.preventDefault(),c(f=>Math.min(f+1,u.length-1));else if(m.key==="ArrowUp")m.preventDefault(),c(f=>Math.max(f-1,0));else if(m.key==="Enter"){const f=u[a];if(!f)return;m.preventDefault(),v(f.item.path)}}}),e.jsx("span",{className:"omni-key",children:"⇧S"})]}),t?e.jsx("div",{className:"omni-panel",role:"listbox",children:u.length===0?e.jsx("p",{className:"empty",children:"No pages, users or devices match that search."}):u.map((m,p)=>e.jsxs("a",{className:p===a?"omni-item on":"omni-item",href:m.item.path,role:"option","aria-selected":p===a,onPointerEnter:()=>c(p),onClick:f=>{f.preventDefault(),v(m.item.path)},children:[m.item.icon?e.jsx(Y,{name:m.item.icon}):null,e.jsxs("span",{children:[e.jsx("b",{children:m.item.label}),e.jsx("small",{children:m.item.intro})]}),e.jsx("span",{className:"omni-group",children:m.item.group})]},m.item.id))}):null]})}const k=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 Me(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 is(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 De(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(),He=s=>`${Math.round((s??0)*100)}%`;function ve(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 tn=15*60*1e3,Pn=3*60*60*1e3,Fe=s=>!!s&&Date.now()-new Date(s).getTime(){var b;try{const N=await F.get(`/admin/api/notifications?limit=${ws}`);t(N.events),r(N.unread),a(N.types),v.current=Math.max(v.current,((b=N.events[0])==null?void 0:b.id)??0),u("")}catch(N){u(N instanceof Error?N.message:String(N))}},[]),p=x.useCallback(b=>{v.current=Math.max(v.current,b.id),t(N=>N.some(j=>j.id===b.id)?N:[b,...N].sort((j,y)=>y.id-j.id).slice(0,ws)),b.readAt||r(N=>N+1),a(N=>N.some(j=>j.type===b.type)?N.map(j=>j.type===b.type?{...j,count:j.count+1}:j):[...N,{type:b.type,count:1}])},[]);x.useEffect(()=>{m()},[m]),x.useEffect(()=>{let b=null,N,j=!1;return(()=>{j||(b=new EventSource(`/admin/api/notifications/stream?after=${v.current}`),b.addEventListener("open",()=>{d(!0),window.clearInterval(N),N=void 0}),b.addEventListener("admin",w=>{try{p(JSON.parse(w.data))}catch{}}),b.addEventListener("error",()=>{d(!1),N===void 0&&(N=window.setInterval(()=>void m(),On))}))})(),()=>{j=!0,b==null||b.close(),window.clearInterval(N)}},[p,m]);const f=x.useCallback(async b=>{const N=b.filter(j=>j>0);if(N.length!==0){t(j=>j.map(y=>N.includes(y.id)&&!y.readAt?{...y,readAt:new Date().toISOString()}:y));try{const j=await F.post("/admin/api/notifications/read",{ids:N});r(j.unread)}catch{m()}}},[m]),h=x.useCallback(async()=>{r(0),t(b=>b.map(N=>N.readAt?N:{...N,readAt:new Date().toISOString()}));try{const b=await F.post("/admin/api/notifications/read",{all:!0});r(b.unread)}catch{m()}},[m]),g=x.useMemo(()=>({events:n,unread:i,types:o,connected:c,error:l,markRead:f,markAllRead:h,reload:m}),[n,i,o,c,l,f,h,m]);return e.jsx(an.Provider,{value:g,children:s})}function us(){const s=x.useContext(an);if(!s)throw new Error("useNotifications used outside NotificationProvider");return s}function rn(s){return s.severity==="error"?"bad":s.severity==="warning"?"warn":"info"}function ln(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 ks(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 Ns=99;function Un(){const{events:s,unread:n,connected:t,markRead:i,markAllRead:r}=us(),[o,a]=x.useState(!1),c=x.useRef(null);return x.useEffect(()=>{const d=u=>{var v;(v=c.current)!=null&&v.contains(u.target)||a(!1)},l=u=>{u.key==="Escape"&&a(!1)};return document.addEventListener("pointerdown",d),document.addEventListener("keydown",l),()=>{document.removeEventListener("pointerdown",d),document.removeEventListener("keydown",l)}},[]),x.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>Ns?`${Ns}+`: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(Je,{name:ln(d.type),tone:rn(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:ve(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=ds(),a=r??((c=An.find(d=>ts({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 $({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(Je,{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(Je,{name:n.icon,tone:n.tone}):null,e.jsx("b",{className:n.small?"small":void 0,children:n.value}),e.jsx("span",{children:n.label})]},n.label))})}function 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 X({children:s}){return e.jsx("p",{className:"empty",children:s})}function Q({columns:s,children:n}){return e.jsx("tr",{children:e.jsx("td",{colSpan:s,className:"muted",children:e.jsx("p",{className:"empty",children:n})})})}function ye({children:s,tone:n}){return e.jsx("p",{className:"note","data-tone":n,children:s})}function R({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 L({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 H({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 Pe({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 ms({data:s,labelOf:n,valueOf:t,toneOf:i,title:r}){if(s.length===0)return e.jsx(X,{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 Wn({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=x.useId(),d=x.useRef(null);return x.useEffect(()=>{var u;(u=d.current)==null||u.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(R,{onClick:a,variant:"quiet",children:"Cancel"}),e.jsx(R,{onClick:o,variant:i?"danger":"primary",busy:r,children:t})]})]})})}function as({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 on({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 Bn({open:s,onNavigate:n}){const{unread:t}=us(),i=ds(),[r,o]=x.useState(()=>{try{return JSON.parse(localStorage.getItem("memby-admin-nav")??"{}")}catch{return{}}}),a=d=>{try{localStorage.setItem("memby-admin-nav",JSON.stringify(d))}catch{}};x.useEffect(()=>{const d=Ce.find(l=>l.items.some(u=>ts({path:u.path,end:!0},i.pathname)));d&&o(l=>{const u={...l};return Ce.forEach(v=>{v.collapsible!==!1&&(u[v.id]=v.id!==d.id)}),a(u),u})},[i.pathname]);const c=(d,l=!1)=>{o(u=>{const v={...u},m=!(u[d]??l);return Ce.forEach(p=>{p.collapsible!==!1&&(v[p.id]=p.id===d?m:!0)}),a(v),v})};return e.jsx("nav",{className:"rail",id:"rail","data-open":s||void 0,"aria-label":"Console sections",children:Ce.map(d=>{const l=d.items.filter(p=>!p.hidden);if(l.length===0)return null;const u=l.some(p=>ts({path:p.path,end:!0},i.pathname)),v=d.collapsible!==!1,m=u||!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":m,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,m?l.map(p=>e.jsx(Zs,{to:p.path,end:p.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:[p.icon?e.jsx(Y,{name:p.icon}):null,p.label,p.badge==="notifications"&&t>0?e.jsx("span",{className:"rail-badge",children:t>99?"99+":t}):null]})},p.id)):null]},d.id)})})}function Vn(){var w,C,z;const{version:s,currentUser:n,online:t,loading:i,status:r,setMaintenance:o}=oe(),[a,c]=x.useState(!1),[d,l]=x.useState(!1),[u,v]=x.useState(!1),[m,p]=x.useState(!1),f=ds(),h=!!((w=r==null?void 0:r.maintenance)!=null&&w.enabled),g=!!((C=r==null?void 0:r.quietTime)!=null&&C.active),b=x.useRef(null),N=((z=Array.from(n.trim())[0])==null?void 0:z.toLocaleUpperCase("en-NZ"))||"A",j=r&&t&&!h&&!g?"ok":r||!i?"bad":"checking",y=async()=>{if(!(u||!r)){v(!0);try{await o(!h)}finally{v(!1)}}};return x.useEffect(()=>c(!1),[f.pathname]),x.useEffect(()=>{if(!d)return;const A=G=>{var se;(se=b.current)!=null&&se.contains(G.target)||l(!1)},I=G=>{G.key==="Escape"&&l(!1)};return document.addEventListener("mousedown",A),window.addEventListener("keydown",I),()=>{document.removeEventListener("mousedown",A),window.removeEventListener("keydown",I)}},[d]),x.useEffect(()=>{if(!a)return;const A=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=A,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(A=>!A),children:e.jsx(Y,{name:"menu"})}),e.jsx("div",{className:"topbar-spacer"}),e.jsxs("div",{className:"topbar-tools",children:[e.jsx(Fn,{}),e.jsxs("span",{className:"topbar-version",children:["gateway ",s||"unknown"]}),e.jsx("button",{type:"button",className:"topbar-status","data-tone":j,"aria-pressed":h,disabled:!r||!t||u||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():p(!0),children:e.jsx("span",{className:"dot","aria-hidden":"true"})}),e.jsx(Un,{}),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(A=>!A),children:[e.jsx("span",{className:"account-avatar","aria-hidden":"true",children:N}),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:N}),e.jsxs("span",{children:[e.jsx("small",{children:"Signed in as"}),e.jsx("b",{children:n})]})]}),e.jsxs(Zs,{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(Bn,{open:a,onNavigate:()=>c(!1)}),e.jsx("main",{className:"page",id:"main",children:e.jsx(bn,{})}),m?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:u,onConfirm:()=>{p(!1),y()},onCancel:()=>p(!1)}):null]})}const cn=x.createContext(null),zn=5e3;function Hn({children:s}){const[n,t]=x.useState([]),i=x.useRef(1),r=x.useCallback(d=>{t(l=>l.filter(u=>u.id!==d))},[]),o=x.useCallback((d,l="ok")=>{const u=i.current++;t(v=>[...v,{id:u,message:d,tone:l}]),window.setTimeout(()=>r(u),zn)},[r]),a=x.useCallback(async(d,l)=>{try{const u=await d();return l&&o(l,"ok"),u}catch(u){o(u instanceof Error?u.message:String(u),"bad");return}},[o]),c=x.useMemo(()=>({show:o,wrap:a}),[o,a]);return e.jsxs(cn.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=x.useContext(cn);if(!s)throw new Error("useToast used outside ToastProvider");return s}function J(s,n={}){const{pollMs:t,enabled:i=!0}=n,[r,o]=x.useState(),[a,c]=x.useState(""),[d,l]=x.useState(i),[u,v]=x.useState(!1),m=x.useRef(0),p=x.useRef(!1),f=x.useCallback(async()=>{if(!i)return;const h=++m.current;p.current&&v(!0);try{const g=await F.get(s);if(h!==m.current)return;o(g),c(""),p.current=!0}catch(g){if(h!==m.current)return;c(g instanceof Error?g.message:String(g))}finally{h===m.current&&(l(!1),v(!1))}},[s,i]);return x.useEffect(()=>(p.current=!1,l(!0),f(),()=>{m.current+=1}),[f]),x.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:u,reload:f,set:o}}function ee(){const[s,n]=x.useState(null),t=x.useRef(!0);x.useEffect(()=>()=>{t.current=!1},[]);const i=x.useCallback(async(r,o)=>{n(r);try{return await o(),!0}finally{t.current&&n(null)}},[]);return{busy:s,run:i}}function Kn(){var g,b,N,j,y,w;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(C=>Fe(C.lastSeen)).length,l=s.updatePolicy??{},u=!!l.minimumVersion&&l.minimumVersion===l.latestVersion,v=s.playbackPolicy,m=s.mdblist,p=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:k(s.library.total),icon:"library",tone:"data"},{label:"people signed in",value:k((s.requestUsers??[]).length),icon:"people",tone:"note"},{label:`devices · ${d} active now`,value:k(c.length),icon:"tv",tone:"info"},{label:`visits today · ${((g=r.data)==null?void 0:g.lastWeek.visits)??0} this time last week`,value:k((b=r.data)==null?void 0:b.today.visits),icon:"overview",tone:"data"},{label:`viewers today · ${((N=r.data)==null?void 0:N.lastWeek.viewers)??0} this time last week`,value:k((j=r.data)==null?void 0:j.today.viewers),icon:"people",tone:"note"},{label:"optional features on",value:`${a.filter(C=>C.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($,{title:"What televisions are being told",intro:"The answers the gateway is giving every set right now.",icon:"tv",tone:"info",children:e.jsx(as,{rows:[{label:"Availability",value:(y=s.maintenance)!=null&&y.enabled?e.jsx(M,{tone:"bad",children:"offline for maintenance"}):(w=s.quietTime)!=null&&w.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",k(o.revision)]})},{label:"App update prompt",value:l.enabled?e.jsxs(M,{tone:u?"warn":"ok",children:[u?"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($,{title:"Services",intro:"The services this gateway leans on, and whether they answered.",icon:"wrench",tone:"note",children:e.jsx(as,{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:m!=null&&m.enabled?e.jsxs(M,{tone:"ok",children:[k(m.cachedTitles)," titles stored"]}):e.jsx(M,{children:m!=null&&m.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:[k((p==null?void 0:p.candidates)??0)," ranked candidates"]})},{label:"Recommendation profiles",value:e.jsx("span",{className:"mono",children:k((p==null?void 0:p.profiles)??0)})}]})})]}),e.jsxs(he,{cols:"2",children:[e.jsx($,{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(Q,{columns:4,children:"No imports have run yet."}):f.map(C=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(C.startedAt)}),e.jsx("td",{children:C.kind}),e.jsx("td",{children:e.jsx(M,{tone:C.status==="success"?"ok":C.status==="running"?"warn":"bad",children:C.status})}),e.jsx("td",{className:"num",children:k(C.itemsUpserted)})]},C.id||C.startedAt))})]})})}),e.jsx($,{title:"Process",intro:"The container the gateway is served from.",icon:"chip",tone:"info",children:h?e.jsxs(e.Fragment,{children:[e.jsx(on,{tiles:[{label:"goroutines",value:k(h.goroutines)},{label:"heap in use",value:De(h.heapInuse)},{label:"reserved",value:De(h.sys)},{label:"collections",value:k(h.numGc)}]}),e.jsxs("p",{className:"hint",children:["Next collection at ",De(h.nextGc)," · memory limit"," ",h.memoryLimit>0&&h.memoryLimit`/admin/api/notifications${Ee({days:r,type:a,severity:d,unread:u,limit:f,offset:m*f})}`,[r,a,d,u,m]),{data:g,error:b,loading:N,reload:j}=J(h),y=async()=>{await t(),await j()};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(R,{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:k((g==null?void 0:g.total)??0),icon:"bell",tone:"info"},{label:"Unread",value:k(s),icon:"alert",tone:s>0?"warn":void 0},{label:"Kinds seen",value:k((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(L,{label:"Window",children:e.jsx(Pe,{value:r,options:Gn.map(w=>({value:w.value,label:w.label})),onChange:w=>{o(w),p(0)}})}),e.jsx(L,{label:"Kind",children:e.jsxs("select",{value:a,onChange:w=>{c(w.target.value),p(0)},children:[e.jsx("option",{value:"",children:"Everything"}),((g==null?void 0:g.types)??[]).map(w=>e.jsxs("option",{value:w.type,children:[ks(w.type)," (",w.count,")"]},w.type))]})}),e.jsx(L,{label:"Severity",children:e.jsxs("select",{value:d,onChange:w=>{l(w.target.value),p(0)},children:[e.jsx("option",{value:"",children:"Any"}),e.jsx("option",{value:"info",children:"Information"}),e.jsx("option",{value:"warning",children:"Warning"}),e.jsx("option",{value:"error",children:"Error"})]})}),e.jsx(L,{label:"Read state",children:e.jsxs("select",{value:u?"unread":"",onChange:w=>{v(w.target.value==="unread"),p(0)},children:[e.jsx("option",{value:"",children:"All"}),e.jsx("option",{value:"unread",children:"Unread only"})]})}),e.jsx("div",{className:"filter-actions",children:e.jsx(R,{variant:"quiet",size:"sm",icon:"refresh",onClick:()=>{j(),i()},children:"Refresh"})})]}),N?e.jsx(V,{}):e.jsx($,{title:"Events",icon:"bell",tone:"info",footer:((g==null?void 0:g.total)??0)>f?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:"sm",disabled:m===0,onClick:()=>p(m-1),children:"Newer"}),e.jsx(R,{size:"sm",disabled:(m+1)*f>=((g==null?void 0:g.total)??0),onClick:()=>p(m+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(Q,{columns:6,children:"Nothing has happened in this window."}):g==null?void 0:g.events.map(w=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(w.occurredAt),children:ve(w.occurredAt)}),e.jsx("td",{className:"nowrap",children:e.jsxs("span",{className:"row tight",children:[e.jsx(Je,{name:ln(w.type),tone:rn(w)}),ks(w.type)]})}),e.jsxs("td",{children:[e.jsx("b",{children:w.title}),w.summary?e.jsx("div",{className:"muted",children:w.summary}):null]}),e.jsx("td",{className:"muted nowrap",children:w.actor||"—"}),e.jsx("td",{className:"muted nowrap",children:w.target||"—"}),e.jsxs("td",{className:"nowrap",children:[w.readAt?null:e.jsx(M,{tone:"ok",children:"new"}),w.link?e.jsx(re,{className:"table-row-link",to:w.link,children:"Open"}):null]})]},w.id))})]})})})]})}function Ss(s){const n=s?new Date(s).getTime():0;return Number.isFinite(n)?n:0}function Cs(){return e.jsx("span",{className:"muted",title:"No Tracearr sessions matched to this person",children:"—"})}function Jn(){const{data:s,error:n,loading:t}=J("/admin/api/accounts",{pollMs:6e4}),i=(s==null?void 0:s.accounts)??[],r=i.flatMap(u=>u.devices??[]),o=i.filter(u=>{var v;return(v=u.recommendations)==null?void 0:v.completed}).length,a=i.filter(u=>{var v,m;return((v=u.recommendations)==null?void 0:v.prompted)&&!((m=u.recommendations)!=null&&m.completed)}).length,c=i.filter(u=>{var v;return(v=u.watchTime)==null?void 0:v.matched}),d=c.reduce((u,v)=>{var m;return u+(((m=v.watchTime)==null?void 0:m.weekMs)??0)},0),l=[...i].sort((u,v)=>Ss(v.lastSeen)-Ss(u.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:k(i.length),icon:"people",tone:"note"},{label:"signed-in devices",value:k(r.length),icon:"tv",tone:"info"},{label:"active in the last 15 mins",value:k(r.filter(u=>Fe(u.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"recommendation setups completed",value:k(o),icon:"check",tone:"ok"},{label:"setup prompts queued",value:k(a),icon:"sparkle",tone:"note"},...c.length?[{label:"watch time this week",value:Me(d),icon:"pulse",tone:"data"}]:[]]}),e.jsx($,{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",{children:"Short name"}),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(Q,{columns:7,children:"No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here."}):l.map(u=>{var g,b;const v=u.devices??[],m=v.filter(N=>Fe(N.lastSeen)).length,p=(g=u.recommendations)!=null&&g.completed?{label:"personalised",tone:"ok"}:(b=u.recommendations)!=null&&b.prompted?{label:"prompt queued",tone:"warn"}:{label:"not invited",tone:void 0},f=hs(u.lastSeen),h=u.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:u.initials||nn(u.username)}),e.jsx(re,{className:"table-row-link",to:`/admin/accounts/${encodeURIComponent(u.id)}`,children:u.username||"Unnamed user"})]})}),e.jsx("td",{children:u.shortName||e.jsx("span",{className:"muted",title:"Memby greets them by their account name",children:"account name"})}),e.jsxs("td",{className:"num",children:[k(v.length),m?e.jsxs("span",{className:"table-sub",children:[k(m)," active now"]}):null]}),e.jsx("td",{className:"num",children:h!=null&&h.matched?Me(h.weekMs):e.jsx(Cs,{})}),e.jsx("td",{className:"num muted",children:h!=null&&h.matched?Me(h.monthMs):e.jsx(Cs,{})}),e.jsx("td",{children:e.jsx(M,{tone:p.tone,children:p.label})}),e.jsx("td",{className:"nowrap muted",title:P(u.lastSeen),children:ve(u.lastSeen)})]},u.id)})})]})})})]})]})}function es(s){const n=String(s??"").replace("#","");return n.length!==8?`#${n}`:`#${n.slice(2)}${n.slice(0,2)}`}function Yn(){var ue;const{userId:s=""}=Ge(),n=cs(),{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}),[u,v]=x.useState(null),[m,p]=x.useState(null),[f,h]=x.useState(null),[g,b]=x.useState(null),[N,j]=x.useState(null),y=((a==null?void 0:a.accounts)??[]).find(T=>T.id===s),w=(a==null?void 0:a.catalogue)??[],C=(a==null?void 0:a.themes)??[];x.useEffect(()=>{var T;u===null&&y&&v({...((T=y.settings)==null?void 0:T.preferences)??{}})},[y,u]),x.useEffect(()=>{f===null&&y&&h({...y.notifications})},[y,f]),x.useEffect(()=>{if(m!==null||!y)return;const T=y.themes??[];p(T.length===0?C.map(D=>D.id):T)},[y,m,C]);const z=x.useMemo(()=>{const T=[];for(const D of w){let S=T.find(q=>q.name===D.area);S||T.push(S={name:D.area,definitions:[]}),S.definitions.push(D)}return T},[w]),A=(T,D,S,q)=>r(T,async()=>{const _=await t(D,S);b(null),_!==void 0&&(q==null||q()),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($,{children:e.jsx(X,{children:"This user is no longer signed in to Memby."})})]});const I=y.devices??[],G=I.filter(T=>Fe(T.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 · ${y.shortName?`greeted as ${y.shortName} · `:""}${k(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||nn(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($,{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(X,{children:"No devices are signed in to this user."}):e.jsx("div",{className:"list",children:I.map(T=>{const D=hs(T.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":D.tone,title:D.label})," ",e.jsx(re,{className:"table-row-link",to:`/admin/devices/${encodeURIComponent(T.id)}`,children:T.name||"Memby TV"})]}),e.jsxs("p",{children:[T.version?`Memby ${T.version}`:"Legacy Memby client"," · ",D.label," · last seen ",P(T.lastSeen)," · signed in ",P(T.signedInAt)]}),(T.versions??[]).length>0?e.jsx("div",{className:"chips",children:(T.versions??[]).map(S=>e.jsxs(ae,{tone:S.version===T.version?"ok":void 0,children:[S.version,S.version===T.version?" · now":""]},S.version))}):null]}),e.jsxs("div",{className:"list-actions",children:[e.jsx(R,{size:"sm",disabled:!T.id,onClick:()=>j({id:T.id,name:T.name}),children:"Rename"}),e.jsx(R,{size:"sm",variant:"danger",disabled:!T.id,onClick:()=>b({kind:"remove-device",deviceId:T.id,name:T.name}),children:"Sign out"})]})]},T.id||T.name)})})}),e.jsxs($,{title:"Recommendation setup",intro:"The prompt appears the next time this person opens Memby on any of their televisions.",icon:"sparkle",tone:"note",footer:ne.completed?e.jsx(R,{busy:i==="reset-rec",onClick:()=>b({kind:"reset-recommendations"}),children:"Clear stored choices"}):ne.prompted?e.jsx(R,{onClick:()=>b({kind:"cancel-prompt"}),children:"Cancel prompt"}):e.jsx(R,{variant:"primary",busy:i==="prompt",onClick:()=>void A("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(Qn,{prompt:ne})]})]}),(ue=y.watchTime)!=null&&ue.matched?e.jsx($,{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 server's own timezone.",icon:"pulse",tone:"data",actions:y.watchTime.tracearrUsername?e.jsx(ae,{children:y.watchTime.tracearrUsername}):null,children:e.jsx(le,{tiles:[{label:`this week · ${k(y.watchTime.weekSessions)} session${y.watchTime.weekSessions===1?"":"s"}`,value:Me(y.watchTime.weekMs),icon:"pulse",tone:"data"},{label:`this month · ${k(y.watchTime.monthSessions)} session${y.watchTime.monthSessions===1?"":"s"}`,value:Me(y.watchTime.monthMs),icon:"calendar",tone:"info"},{label:"since Tracearr started recording",value:Me(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($,{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(R,{variant:"primary",busy:i==="notifications",onClick:()=>void A("notifications",()=>F.put(`${o}/notifications`,f),"Notification settings saved.",()=>h(null)),children:"Save notifications"}),e.jsx(R,{onClick:()=>{h(null),l()},children:"Discard changes"})]}),children:f?e.jsxs("div",{className:"checks columns",children:[e.jsx(H,{label:"All notifications",hint:"The master switch. Turning this off hides every optional notification below.",checked:f.enabled,onChange:T=>h(D=>D&&{...D,enabled:T})}),e.jsx(H,{label:"My Shows return dates",hint:"Remind this person when a followed show is about to return.",checked:f.showReturnAlerts,disabled:!f.enabled,onChange:T=>h(D=>D&&{...D,showReturnAlerts:T})}),e.jsx(H,{label:"Sonarr television alerts",hint:"New episodes, additions and cancellation news supplied by Sonarr.",checked:f.sonarrAlerts,disabled:!f.enabled,onChange:T=>h(D=>D&&{...D,sonarrAlerts:T})}),e.jsx(H,{label:"Radarr film alerts",hint:"Notify this person when Radarr imports a new film.",checked:f.radarrAlerts,disabled:!f.enabled,onChange:T=>h(D=>D&&{...D,radarrAlerts:T})}),e.jsx(H,{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:T=>h(D=>D&&{...D,updateAlerts:T})}),e.jsx(H,{label:"Library activity",hint:"Show alerts after the Memby library catalogue is refreshed.",checked:f.libraryAlerts,disabled:!f.enabled,onChange:T=>h(D=>D&&{...D,libraryAlerts:T})}),e.jsx(H,{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:T=>h(D=>D&&{...D,watchTimeDigest:T})}),e.jsx(H,{label:"Service status",hint:"Memby deployment and Emby outage or recovery notices. Maintenance mode itself still applies.",checked:f.systemAlerts,disabled:!f.enabled,onChange:T=>h(D=>D&&{...D,systemAlerts:T})})]}):null}),e.jsx($,{title:"Settings",intro:"These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off.",icon:"sliders",tone:"ok",actions:se.saved?e.jsxs(M,{tone:se.source==="admin"?"warn":"ok",children:["r",k(se.revision)," · ",se.source||"device"," · ",P(se.updatedAt)]}):e.jsx(M,{children:"defaults · never synced"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:i==="push",onClick:()=>void A("push",()=>F.put(`${o}/preferences`,{preferences:u??{}}),"Pushed to their televisions.",()=>v(null)),children:"Push to their televisions"}),e.jsx(R,{onClick:()=>{v(null),l()},children:"Discard changes"}),e.jsx(R,{onClick:()=>b({kind:"reset-preferences"}),children:"Restore defaults"}),e.jsx(re,{className:"crumb",to:`/admin/accounts/${encodeURIComponent(s)}/settings`,children:"History and rollback →"})]}),children:z.map(T=>e.jsxs("div",{className:"group",children:[e.jsx("p",{className:"group-label",children:T.name}),T.definitions.map(D=>e.jsx(Xn,{definition:D,value:u==null?void 0:u[D.key],onChange:S=>v(q=>({...q??{},[D.key]:S}))},D.key))]},T.name))}),e.jsx($,{title:"Colour schemes",intro:"Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight.",icon:"sparkle",tone:"note",actions:(y.themes??[]).length===0?e.jsx(M,{children:"all schemes"}):e.jsxs(M,{tone:"note",children:[k((y.themes??[]).length)," of ",k(C.length)]}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:i==="themes",onClick:()=>(m??[]).length===0?b({kind:"no-themes"}):void A("themes",()=>F.put(`${o}/themes`,{themes:m??[]}),"Colour schemes saved.",()=>p(null)),children:"Save colour schemes"}),e.jsx(R,{onClick:()=>p(C.map(T=>T.id)),children:"Allow all"}),e.jsxs("span",{className:"hint",children:["Seasonal themes are not listed. They apply to every television in the house for their dates and nobody can decline one — the only switch is ",e.jsx("em",{children:"Seasonal themes"})," on the features page."]})]}),children:e.jsx("div",{className:"checks columns",children:C.map(T=>{var D,S,q;return e.jsxs("label",{className:"check",children:[e.jsx("input",{type:"checkbox",checked:(m??[]).includes(T.id),onChange:_=>p(ie=>_.target.checked?[...ie??[],T.id]:(ie??[]).filter(pe=>pe!==T.id))}),e.jsx("span",{className:"switch"}),e.jsx("span",{className:"swatch",style:{"--swatch-surface":es((D=T.palette)==null?void 0:D.surface),"--swatch-accent":es((S=T.palette)==null?void 0:S.accent),"--swatch-hairline":es((q=T.palette)==null?void 0:q.hairline)},children:e.jsx("i",{})}),e.jsxs("span",{className:"check-body",children:[e.jsx("b",{children:T.name}),e.jsx("p",{children:T.description})]})]},T.id)})})}),e.jsx($,{title:"Remove Memby access",intro:"Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched.",icon:"alert",tone:"bad",children:e.jsx(R,{variant:"danger",onClick:()=>b({kind:"remove-account"}),children:"Remove Memby access"})}),N?e.jsx(et,{initial:N.name,busy:i==="rename",onCancel:()=>j(null),onConfirm:T=>void A("rename",()=>F.put(`${o}/devices/${encodeURIComponent(N.id)}`,{deviceName:T}),"Device renamed.",()=>j(null))}):null,g?e.jsx(st,{pending:g,busy:i,username:y.username,onCancel:()=>b(null),onConfirm:()=>{switch(g.kind){case"remove-device":return void A("remove-device",()=>F.del(`${o}/devices/${encodeURIComponent(g.deviceId)}`),"Device signed out.");case"remove-account":return void A("remove-account",()=>F.del(`${o}/sessions`),"Memby access removed.",()=>n("/admin/accounts"));case"reset-recommendations":case"cancel-prompt":return void A("reset-rec",()=>F.del(`${o}/recommendations`),"Recommendation choices cleared.");case"reset-preferences":return void A("reset-prefs",()=>F.del(`${o}/preferences`),"Defaults restored.",()=>v(null));case"no-themes":return void A("themes",()=>F.put(`${o}/themes`,{themes:[]}),"Colour schemes saved.",()=>p(null))}}}):null]})}function Qn({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," · ",k(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(X,{children:"No recommendation selections have been saved."}):e.jsx("div",{className:"chips",children:i})}function Xn({definition:s,value:n,onChange:t}){if(s.kind==="toggle")return e.jsx(H,{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(L,{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(H,{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(L,{label:s.name,hint:s.description,children:e.jsx("input",{type:"text",value:String(n??""),maxLength:s.maxLength,placeholder:s.placeholder,onChange:r=>t(s.uppercase?r.target.value.toLocaleUpperCase("en-NZ"):r.target.value)})});const i=Array.isArray(n)?n:[];return e.jsx(L,{label:s.name,hint:s.description,children:e.jsx("textarea",{spellCheck:!1,placeholder:"One row id per line",value:i.join(` +`),onChange:r=>t(r.target.value.split(` +`).map(o=>o.trim()).filter(Boolean))})})}function et({initial:s,busy:n,onConfirm:t,onCancel:i}){const[r,o]=x.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(L,{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(R,{variant:"quiet",onClick:i,children:"Cancel"}),e.jsx(R,{variant:"primary",busy:n,disabled:!r.trim(),onClick:()=>t(r.trim()),children:"Rename"})]})]})})}function st({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 nt(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 tt(){const{userId:s=""}=Ge(),{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,u]=x.useState(new Set),[v,m]=x.useState(null),p=(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=j=>u(y=>{const w=new Set(y);return w.has(j)?w.delete(j):w.add(j),w}),N=j=>i("restore",async()=>{await n(()=>F.post(`${r}/preferences/revisions/${j}/restore`),`Restored r${j}.`),m(null),await d()});return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Settings history",intro:`Every change to ${p}'s synced settings, and which of their televisions has taken it.`,crumbs:e.jsxs(re,{to:`/admin/accounts/${encodeURIComponent(s)}`,children:["← ",p]})}),e.jsx(U,{message:a}),c?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx($,{title:"Where each device has got to",intro:"A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway.",icon:"tv",tone:"info",actions:o!=null&&o.saved?e.jsxs(M,{tone:o.currentSource==="admin"?"warn":"ok",children:["now on r",k(o.currentRevision)," · ",o.currentSource||"device"]}):e.jsx(M,{children:"defaults · never synced"}),children:f.length===0?e.jsx(X,{children:"No television has been signed in to this account."}):e.jsx("div",{className:"list",children:f.map(j=>{const y=j.never?void 0:j.behind?"bad":"ok";return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[e.jsx("span",{className:"dot-state","data-tone":y})," ",j.name||"Memby TV"," ",j.signedOut?e.jsx(M,{children:"signed out"}):null]}),e.jsxs("p",{children:[j.never?"Has not fetched these settings yet":`Holding r${k(j.revision)} · taken ${P(j.ackedAt)}`,j.clientVersion?` · Memby ${j.clientVersion}`:"",j.signedOut?"":` · last seen ${P(j.lastSeen)}`]})]}),e.jsx("div",{className:"list-actions",children:j.never?e.jsx(M,{children:"never taken one"}):j.behind?e.jsxs(M,{tone:"bad",children:[k(j.behind)," behind"]}):e.jsx(M,{tone:"ok",children:"up to date"})})]},j.deviceId||j.name)})})}),e.jsx($,{title:"Change history",intro:"Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{className:"num",children:"Rev"}),e.jsx("th",{children:"Changed by"}),e.jsx("th",{children:"What changed"}),e.jsx("th",{className:"num",children:"Taken by"}),e.jsx("th",{})]})}),e.jsx("tbody",{children:h.length===0?e.jsx(Q,{columns:6,children:"Nothing has been changed on this account yet."}):h.flatMap(j=>{const y=l.has(j.revision),w=j.acks??[],C=j.changes??[],z=[e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",children:P(j.createdAt)}),e.jsxs("td",{className:"num nowrap",children:["r",k(j.revision)," ",j.current?e.jsx(M,{tone:"ok",children:"current"}):null]}),e.jsxs("td",{className:"nowrap",children:[e.jsx(M,{tone:j.source==="admin"?"warn":"ok",children:j.author}),j.restoredFrom?e.jsxs("span",{className:"muted",children:[" restored r",k(j.restoredFrom)]}):null]}),e.jsx("td",{className:"muted",children:j.initial?e.jsx("span",{className:"muted",children:"First recorded settings"}):C.length===0?e.jsx("span",{className:"muted",children:"No visible change"}):e.jsx("div",{className:"chips",children:C.map((A,I)=>e.jsxs(ae,{children:[A.name,": ",A.before," → ",A.after]},`${A.name}:${I}`))})}),e.jsx("td",{className:"num",children:w.length===0?e.jsx("span",{className:"muted",children:"—"}):e.jsx("span",{title:w.map(A=>A.deviceName||A.deviceId).join(", "),children:k(w.length)})}),e.jsx("td",{className:"num nowrap",children:e.jsxs("span",{className:"list-actions",children:[e.jsx(R,{size:"sm",onClick:()=>b(j.revision),children:y?"Hide":"Show"}),j.current?null:e.jsx(R,{size:"sm",onClick:()=>m(j.revision),children:"Restore"})]})})]},j.revision)];return y&&z.push(e.jsx("tr",{children:e.jsx("td",{colSpan:6,className:"muted",children:e.jsx("div",{className:"chips",children:g.map(A=>{var I;return e.jsxs(ae,{children:[A.name,":"," ",nt(A,(I=j.preferences)==null?void 0:I[A.key])]},A.key)})})})},`${j.revision}:detail`)),z})})]})})})]}),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 N(v),onCancel:()=>m(null)}):null]})}function it({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 at(){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:k(i.length),icon:"tv",tone:"info"},{label:"active in the last quarter hour",value:k(i.filter(a=>Fe(a.lastSeen)).length),icon:"pulse",tone:"ok"},{label:"reporting their capabilities",value:k(r.length),icon:"sliders",tone:"ok"},{label:"app builds in service",value:k(o.size),icon:"download",tone:"note"}]}),e.jsx($,{title:"Devices",intro:"Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Device"}),e.jsx("th",{children:"Person"}),e.jsx("th",{children:"App"}),e.jsx("th",{children:"Builds seen"}),e.jsx("th",{children:"Status"}),e.jsx("th",{children:"Last seen"})]})}),e.jsx("tbody",{children:i.length===0?e.jsx(Q,{columns:6,children:"No devices have signed in yet."}):i.map(a=>{const c=(a.capabilities??[]).includes("server_features_v1"),d=hs(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(it,{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 Ms={user:"",q:"",ip:"",outcome:"",from:"",to:""},rt=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function lt(){var j,y,w,C,z,A;const[s,n]=x.useState("log"),[t,i]=x.useState(7),[r,o]=x.useState(Ms),[a,c]=x.useState(0),d=100,l=x.useMemo(()=>Ee({...r,days:r.from?void 0:t||void 0,limit:d,offset:a*d}),[r,t,a]),u=J(`/admin/api/logins${l}`,{enabled:s==="log"}),v=J(`/admin/api/logins/devices${l}`,{enabled:s==="devices"}),m=((j=u.data)==null?void 0:j.users)??((y=v.data)==null?void 0:y.users)??[],p=((w=u.data)==null?void 0:w.totals)??((C=v.data)==null?void 0:C.totals),f=((z=u.data)==null?void 0:z.retentionDays)??((A=v.data)==null?void 0:A.retentionDays)??90,h=s==="log"?u.loading:v.loading,g=s==="log"?u.error:v.error,b=I=>{o(G=>({...G,...I})),c(0)},N=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(Pe,{value:s,options:[{value:"log",label:"Log"},{value:"devices",label:"By device"}],onChange:n})}),e.jsx(U,{message:g}),p?e.jsx(le,{tiles:[{label:"Successful sign-ins",value:k(p.logins),icon:"key",tone:"ok"},{label:"Refused",value:k(p.failures),icon:"shield",tone:p.failures>0?"warn":void 0},{label:"Televisions",value:k(p.devices),icon:"tv",tone:"info"},{label:"People",value:k(p.users),icon:"people",tone:"note"},{label:"Addresses",value:k(p.addresses),icon:"globe",tone:"data"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(L,{label:"Window",children:e.jsx(Pe,{value:r.from?-1:t,options:rt.map(I=>({value:I.value,label:I.label})),onChange:I=>{i(I),b({from:"",to:""})}})}),e.jsx(L,{label:"Person",children:e.jsxs("select",{value:r.user,onChange:I=>b({user:I.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),m.map(I=>e.jsx("option",{value:I.id,children:I.username||I.id},I.id))]})}),e.jsx(L,{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(L,{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(L,{label:"From",children:e.jsx("input",{type:"date",value:r.from,onChange:I=>b({from:I.target.value})})}),e.jsx(L,{label:"To",children:e.jsx("input",{type:"date",value:r.to,onChange:I=>b({to:I.target.value})})}),e.jsx(L,{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:N?e.jsx(R,{variant:"quiet",size:"sm",onClick:()=>{o(Ms),c(0)},children:"Clear"}):null})]}),h?e.jsx(V,{}):s==="log"?e.jsx(ot,{data:u.data,page:a,limit:d,onPage:c}):e.jsx(ct,{data:v.data})]})}function ot({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($,{title:"Attempts per day",intro:"Grouped in the server's own timezone, so an evening sign-in stays on the day it happened.",icon:"chart",tone:"info",children:e.jsx(ms,{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($,{title:"Where from",icon:"globe",tone:"data",children:s.addresses.length===0?e.jsx(X,{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:[k(a.logins)," in",a.failures>0?` · ${k(a.failures)} refused`:""]})]}),a.failures>0&&a.logins===0?e.jsx(M,{tone:"bad",children:"only refused"}):null]},a.ipAddress))})})]}),e.jsx($,{title:"Attempts",intro:"Uncollapsed and newest first: this is what to read when somebody says a television will not sign in.",icon:"key",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:s.total===0?"nothing matches":`${k(o)}–${k(o+r-1)} of ${k(s.total)}`}),footer:s.total>t?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:"sm",disabled:n===0,onClick:()=>i(n-1),children:"Newer"}),e.jsx(R,{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(Q,{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 ct({data:s}){return s?e.jsx($,{title:"Televisions",intro:"Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it.",icon:"tv",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Television"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"num",children:"Today"}),e.jsx("th",{className:"num",children:"Sign-ins"}),e.jsx("th",{className:"num",children:"Refused"}),e.jsx("th",{className:"num",children:"Addresses"}),e.jsx("th",{className:"nowrap",children:"Last address"}),e.jsx("th",{className:"nowrap",children:"Last sign-in"}),e.jsx("th",{children:"Build"})]})}),e.jsx("tbody",{children:s.devices.length===0?e.jsx(Q,{columns:9,children:"No television has connected in this window."}):s.devices.map(n=>e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx(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?k(n.loginsToday):"—"}),e.jsx("td",{className:"num",children:k(n.logins)}),e.jsx("td",{className:"num",children:n.failures>0?e.jsx("span",{className:"mono",children:k(n.failures)}):"—"}),e.jsx("td",{className:"num",children:k(n.distinctIps)}),e.jsx("td",{className:"mono nowrap",children:n.lastIp||"—"}),e.jsx("td",{className:"nowrap muted",children:n.lastLogin?P(n.lastLogin):"—"}),e.jsx("td",{className:"mono",children:n.clientVersion||"—"})]},n.deviceId))})]})})}):null}const dt=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:0,label:"All"}];function ht(){const{deviceId:s=""}=Ge(),[n,t]=x.useState(7),i=x.useMemo(()=>`/admin/api/logins/devices/${encodeURIComponent(s)}${Ee({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(Pe,{value:n,options:dt.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:k((c==null?void 0:c.loginsToday)??0),icon:"clock",tone:"ok"},{label:"Sign-ins in total",value:k((c==null?void 0:c.logins)??0),icon:"key",tone:"info"},{label:"Refused",value:k((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:k((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($,{title:"Connections per day",icon:"chart",tone:"info",children:e.jsx(ms,{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($,{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($,{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(X,{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($,{title:"Addresses",icon:"globe",tone:"data",children:r.addresses.length===0?e.jsx(X,{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," · ",k(l.logins),l.failures>0?` (+${k(l.failures)} refused)`:""]},l.ipAddress))})}),e.jsx($,{title:"Every attempt",icon:"key",tone:"ok",actions:e.jsxs("span",{className:"filter-summary",children:[k(r.total)," in this window"]}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"When"}),e.jsx("th",{children:"Person"}),e.jsx("th",{className:"nowrap",children:"Address"}),e.jsx("th",{children:"Build"}),e.jsx("th",{children:"Method"}),e.jsx("th",{children:"Outcome"})]})}),e.jsx("tbody",{children:r.events.length===0?e.jsx(Q,{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 ut(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(!1),l=(s==null?void 0:s.library.byType)??{},u=!!(s!=null&&s.syncRunning),v=m=>a(m,async()=>{await r(()=>F.post("/admin/api/sync",{kind:m}),m==="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:k(s.library.total),icon:"library",tone:"data"},...Object.keys(l).sort().map(m=>({label:m,value:k(l[m]),icon:"list"})),{label:"last import",value:P(s.library.lastSynced),small:!0,icon:"clock"}]}),e.jsx($,{title:"Import the catalogue",intro:"Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live.",icon:"library",tone:"data",footer:e.jsx("span",{className:"hint",children:u?"Import running…":`An incremental import runs automatically every ${s.syncEvery}.`}),children:e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",icon:"sync",disabled:u,busy:o==="incremental",onClick:()=>void v("incremental"),children:"Sync new items"}),e.jsx(R,{icon:"database",disabled:u,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 mt={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 pt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(!1),[l,u]=x.useState(""),[v,m]=x.useState(!1),[p,f]=x.useState([]),[h,g]=x.useState(!1),b=s==null?void 0:s.mdblist;x.useEffect(()=>{h||!b||(d(b.enabled),f(b.sources??[]))},[b,h]);const N=()=>a("save",async()=>{await r(()=>F.post("/admin/api/mdblist-settings",{enabled:c,apiKey:l.trim(),clearApiKey:v,sources:p}),"Ratings settings saved."),u(""),m(!1),g(!1),await i()}),j=(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:k(j),icon:"database",tone:"data"},{label:"due to be re-checked",value:k(b.staleTitles),icon:"sync",tone:"warn"},{label:"sources shown",value:k((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($,{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 · ",p.length," sources"]}):e.jsx(M,{children:b.apiKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(H,{label:"Show external ratings on televisions",hint:"Off leaves the stored ratings in place.",checked:c,onChange:y=>{d(y),g(!0)}}),e.jsx(L,{label:"API key",hint:"Leave blank to keep the key that is already saved.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:l,placeholder:b.apiKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:y=>u(y.target.value)})}),e.jsx(H,{label:"Remove the saved key",checked:v,onChange:m})]}),e.jsx($,{title:"Sources shown on televisions",intro:"A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched.",icon:"list",tone:"data",children:(b.availableSources??[]).length===0?e.jsx(X,{children:"No rating sources are available."}):e.jsx("div",{className:"checks columns",children:(b.availableSources??[]).map(y=>e.jsx(H,{label:mt[y]??y,checked:p.includes(y),onChange:w=>{g(!0),f(C=>w?[...C,y]:C.filter(z=>z!==y))}},y))})})]}),e.jsx($,{children:e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",busy:o==="save",onClick:()=>void N(),children:"Save ratings settings"}),e.jsx("span",{className:"hint",children:j?"Ratings are fetched as televisions browse, never on the request path.":"No ratings stored yet. They are saved as televisions browse the library."})]})})]})]})}function xt(){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=x.useMemo(()=>new Map(((s==null?void 0:s.requestUsage)??[]).map(m=>[m.userId,m])),[s==null?void 0:s.requestUsage]),u=m=>a(`access-${m}`,async()=>{const p=d.includes(m)?d.filter(f=>f!==m):[...d,m];await r(()=>F.post("/admin/api/request-policy",{allowedUserIds:p}),p.includes(m)?"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($,{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(X,{children:"Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The button stays hidden on every television until one of them is."}):null}),e.jsx($,{title:"Request access and activity",intro:"One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state.",icon:"people",tone:"note",children:c.length===0?e.jsx(X,{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(m=>{const p=l.get(m.id),f=d.includes(m.id);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("b",{children:m.username})}),e.jsx("td",{className:"muted nowrap",children:P(m.lastSeen)}),e.jsx("td",{className:"num",children:(p==null?void 0:p.requests)??0}),e.jsx("td",{className:"muted nowrap",children:p!=null&&p.lastRequest?P(p.lastRequest):"—"}),e.jsx("td",{children:e.jsx(R,{size:"sm",variant:f?"quiet":"primary",busy:o===`access-${m.id}`,onClick:()=>void u(m.id),children:f?"Remove access":"Give access"})})]},m.id)})})]})})})]})]})}function jt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(!1),l=s==null?void 0:s.forYou,u=!!(s!=null&&s.forYouRunning),v=(m,p,f)=>a(p,async()=>{await r(()=>F.post("/admin/api/for-you",{action:m}),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:k((l==null?void 0:l.tracearrSessions)??0),icon:"play",tone:"info"},{label:"user profiles",value:k((l==null?void 0:l.profiles)??0),icon:"people",tone:"note"},{label:"ranked candidates",value:k((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($,{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:u?"For You maintenance running…":"Prepared pools normally refresh in the background."}),children:e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",icon:"download",disabled:u,busy:o==="import",onClick:()=>void v("incremental-import","import","Import started."),children:"Import recent sessions"}),e.jsx(R,{icon:"database",disabled:u,onClick:()=>d(!0),children:"Full Tracearr backfill"}),e.jsx(R,{icon:"sync",disabled:u,busy:o==="rebuild",onClick:()=>void v("rebuild-all","rebuild","Rebuild started."),children:"Rebuild all pools"})]})}),e.jsx($,{title:"Reading a person's scores",intro:"The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order.",icon:"search",tone:"info",actions:e.jsx(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 vt=[["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 gt(s){return s?vt.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 Es=s=>`${s>=0?"+":""}${s.toFixed(3)}`;function bt(){const{status:s}=oe(),{wrap:n}=te(),{busy:t,run:i}=ee(),[r,o]=x.useState(""),[a,c]=x.useState("default"),[d,l]=x.useState("0"),[u,v]=x.useState(""),[m,p]=x.useState(null),[f,h]=x.useState("Choose a person to inspect their recommendations."),[g,b]=x.useState(""),N=(s==null?void 0:s.requestUsers)??[],j=()=>i("run",async()=>{if(!r){b("Choose a person to pressure-test.");return}b(""),h("Running the permission check and the scorer…");const A=new URLSearchParams({userId:r,context:a,minutes:d||"0",limit:"100"});u&&A.set("at",new Date(u).toISOString());const I=await n(()=>F.get(`/admin/api/recommendations?${A.toString()}`));I?(p(I),h(`Scored at ${new Date().toLocaleTimeString()}.`)):h("Pressure test failed.")}),y=gt((m==null?void 0:m.profile)??null).slice(0,24),w=(m==null?void 0:m.actions)??[],C=(m==null?void 0:m.items)??[],z=(m==null?void 0:m.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($,{title:"Run a pressure test",intro:"Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose.",icon:"search",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:t==="run",onClick:()=>void j(),children:"Run pressure test"}),e.jsx("span",{className:"hint",children:f})]}),children:e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Person",children:e.jsxs("select",{value:r,onChange:A=>o(A.target.value),children:[e.jsx("option",{value:"",children:"Choose a person…"}),N.map(A=>e.jsx("option",{value:A.id,children:A.username},A.id))]})}),e.jsx(L,{label:"Context",children:e.jsxs("select",{value:a,onChange:A=>c(A.target.value),children:[e.jsx("option",{value:"default",children:"Default"}),e.jsx("option",{value:"bedtime",children:"One episode before bed"}),e.jsx("option",{value:"hidden",children:"Hidden library"}),e.jsx("option",{value:"new-releases",children:"Recent new releases"})]})}),e.jsx(L,{label:"Available minutes",children:e.jsx("input",{type:"number",min:0,max:360,value:d,onChange:A=>l(A.target.value)})}),e.jsx(L,{label:"Evaluate at",children:e.jsx("input",{type:"datetime-local",value:u,onChange:A=>v(A.target.value)})})]})}),m?e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"prepared pool",value:k(m.poolCandidates),icon:"database",tone:"data"},{label:"permission eligible",value:k(m.permissionEligible),icon:"shield",tone:"ok"},{label:"ranked result",value:k(C.length),icon:"sparkle",tone:"note"},{label:"source events",value:k(z.sourceEvents??0),icon:"pulse",tone:"info"},{label:"algorithm",value:z.algorithmVersion||"—",small:!0,icon:"chip"},{label:"pool built",value:P(z.poolBuiltAt),small:!0,icon:"clock"}]}),e.jsxs($,{title:"Profile evidence",intro:"The strongest learned affinities, and every explicit action this person has taken.",icon:"sparkle",tone:"note",children:[y.length===0?e.jsx(X,{children:"No repeated affinity evidence yet; cold-start priors apply."}):e.jsx("div",{className:"chips",children:y.map(A=>e.jsxs(ae,{tone:A.weight<0?"bad":void 0,children:[A.dimension,": ",A.name," ",Es(A.weight)," · n=",k(A.evidence)]},`${A.dimension}:${A.name}`))}),w.length===0?e.jsx(X,{children:"No explicit recommendation actions."}):e.jsx("div",{className:"chips",children:w.map((A,I)=>e.jsxs(ae,{tone:"ok",children:[A.action,": ",A.title||A.itemId]},`${A.action}:${I}`))})]}),C.length===0?e.jsx($,{children:e.jsx(X,{children:"No candidates survived this context, the explicit exclusions and the permission filter."})}):e.jsx(he,{children:C.map((A,I)=>{const G=A.explanation??{},se=Object.entries(G.components??{}).sort((T,D)=>Math.abs(D[1])-Math.abs(T[1])),ne=A.exposure??{},ue=[A.type,A.year,A.runtimeMinutes?`${A.runtimeMinutes} min`:null,...A.genres??[]].filter(Boolean).join(" · ");return e.jsxs($,{title:`#${I+1} · ${A.title}`,intro:ue,actions:e.jsx(ae,{tone:"ok",children:Number(G.total??0).toFixed(3)}),children:[e.jsxs("p",{className:"hint",children:[A.preparedReason||"No legacy prepared explanation",A.compatibilityLabel?` · ${A.compatibilityLabel}`:""]}),e.jsxs("div",{className:"chips",children:[(G.reasonCodes??[]).map(T=>e.jsx(ae,{tone:"ok",children:T},T)),se.map(([T,D])=>e.jsxs(ae,{tone:D<0?"bad":void 0,children:[T,"=",Es(D)]},T))]}),e.jsxs("details",{children:[e.jsx("summary",{className:"muted",children:"Pool, row and exposure detail"}),e.jsxs("p",{className:"hint",children:["Base rank ",k(A.baseRank??0)," · base ",Number(A.baseScore??0).toFixed(3)," · affinity ",Number(A.affinityScore??0).toFixed(3)," · compatibility"," ",Number(A.compatibilityScore??0).toFixed(3)," · impressions"," ",k(ne.impressions??0)," · focuses ",k(ne.focuses??0)," · selects"," ",k(ne.selects??0)]}),e.jsx("div",{className:"chips",children:(A.eligibleRows??[]).map(T=>e.jsx(ae,{tone:"ok",children:T},T))}),A.preparedEvidenceTitle?e.jsxs("p",{className:"hint",children:["Prepared evidence: ",A.preparedEvidenceTitle]}):null]})]},`${I}:${A.title}`)})})]}):null]})}const ft=4,ke=[{id:"home",label:"Home",type:"films and television shows"},{id:"movies",label:"Movies",type:"films"},{id:"tv_shows",label:"TV Shows",type:"television shows"}],Te=()=>({pinnedItems:[],primeSubtitle:""}),rs=[{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 As(s){const n=s.getTimezoneOffset()*6e4;return new Date(s.getTime()-n).toISOString().slice(0,16)}function Ts(s){return!!(s&&Number.isFinite(new Date(s).getTime())&&new Date(s).getFullYear()>=2e3)}function yt(s){return s.frequency??"once"}function wt(s){if(s.frequency==="daily")return`Every day · ${s.startTime}–${s.endTime}`;if(s.frequency==="weekly"){const n=rs.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 Rs(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 kt(){var ue,T,D;const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=te(),{busy:a,run:c}=ee(),[d,l]=x.useState("home"),[u,v]=x.useState({home:Te(),movies:Te(),tv_shows:Te()}),[m,p]=x.useState(!1),[f,h]=x.useState(""),[g,b]=x.useState(null),[N,j]=x.useState([]),[y,w]=x.useState(null),C=s==null?void 0:s.heroPolicy;x.useEffect(()=>{var S,q,_;m||!C||(v({home:((S=C.placements)==null?void 0:S.home)??{pinnedItems:C.pinnedItems??[],primeSubtitle:C.primeSubtitle??""},movies:((q=C.placements)==null?void 0:q.movies)??Te(),tv_shows:((_=C.placements)==null?void 0:_.tv_shows)??Te()}),j(C.schedules??[]))},[C,m]);const z=()=>c("search",async()=>{const S=f.trim();if(!S)return;const q=await r(()=>F.get(`/admin/api/hero/search?q=${encodeURIComponent(S)}`));q&&b(q.items??[])}),A=u[d],I=A.pinnedItems??[],G=S=>{v(q=>({...q,[d]:{...q[d],...S}})),p(!0)},se=S=>{if(!I.some(q=>q.id===S.id)){if(I.length>=ft){o("Remove a pinned title before adding another.","bad");return}G({pinnedItems:[...I,S]})}},ne=()=>c("save",async()=>{await r(()=>F.post("/admin/api/hero-policy",{placements:Object.fromEntries(Object.entries(u).map(([S,q])=>[S,{pinnedItemIds:(q.pinnedItems??[]).map(_=>_.id),primeSubtitle:q.primeSubtitle.trim()}])),schedules:N}),"Hero saved."),p(!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:ke.map(S=>{var _e;const q=u[S.id],_=N.filter(me=>me.enabled&&(me.placements??["home"]).includes(S.id)).sort((me,Qe)=>Qe.priority-me.priority)[0],ie=(q.pinnedItems??[]).length?"Manual":_?"Schedule ready":"Automatic",pe=(_e=C==null?void 0:C.items)==null?void 0:_e.find(me=>me.id===(_==null?void 0:_.itemId)),Oe=(q.pinnedItems??[]).map(me=>me.name).join(", ")||(pe==null?void 0:pe.name)||(_==null?void 0:_.itemId)||"Resolved for each viewer";return e.jsx($,{title:S.label,intro:`${ie} · ${Oe}`,tone:S.id===d?"info":void 0,children:e.jsxs(R,{size:"sm",variant:"quiet",onClick:()=>l(S.id),children:["Manage ",S.label]})},S.id)})}),e.jsx("div",{className:"tabs",role:"tablist","aria-label":"Hero placement",children:ke.map(S=>e.jsx(R,{variant:d===S.id?"primary":"quiet",onClick:()=>l(S.id),children:S.label},S.id))}),e.jsxs($,{title:`${(ue=ke.find(S=>S.id===d))==null?void 0:ue.label} hero`,intro:`Pinned ${(T=ke.find(S=>S.id===d))==null?void 0:T.type} lead this section only. Empty places use this placement’s automatic selection.`,icon:"star",tone:"note",footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:a==="save",onClick:()=>void ne(),children:"Save hero"}),e.jsx(R,{onClick:()=>{G({pinnedItems:[]})},children:"Clear pins"}),m?e.jsx("span",{className:"hint",children:"Unsaved changes."}):null]}),children:[I.length===0?e.jsx(X,{children:"No titles are pinned. The hero is entirely release-aware and automatic."}):e.jsx("div",{className:"hero-pins",children:I.map((S,q)=>e.jsxs("div",{className:"hero-pin",children:[e.jsx("span",{className:"hero-pin-order",children:q+1}),e.jsxs("span",{children:[e.jsx("b",{children:S.name}),e.jsxs("small",{children:[S.type,S.year?` · ${S.year}`:""]})]}),e.jsx(R,{size:"sm",icon:"clock",onClick:()=>w(Rs(S,d)),children:"Schedule"}),e.jsx(R,{variant:"quiet",size:"sm",icon:"close",title:`Remove ${S.name}`,onClick:()=>G({pinnedItems:I.filter(_=>_.id!==S.id)})})]},S.id))}),e.jsx(L,{label:"Prime-card subtitle",hint:"Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason.",children:e.jsx("input",{type:"text",maxLength:160,value:A.primeSubtitle,placeholder:"Leave blank for the automatic reason",onChange:S=>{G({primeSubtitle:S.target.value})}})})]}),e.jsx($,{title:"Hero schedule",intro:"The gateway applies these rules in server time. Manual pins win first; otherwise the highest-priority active schedule wins, followed by Memby’s automatic hero.",icon:"clock",tone:"info",actions:e.jsx(M,{tone:"info",children:(C==null?void 0:C.timeZone)||"server local time"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{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:N.length===0?e.jsx(X,{children:"No scheduled heroes yet. Use Schedule beside a pinned or searched title."}):e.jsx("div",{className:"hero-schedule-list",children:[...N].sort((S,q)=>Number(q.enabled)-Number(S.enabled)||q.priority-S.priority).map(S=>{const q=[...(C==null?void 0:C.items)??[],...I,...g??[]].find(_=>_.id===S.itemId);return e.jsxs("article",{className:"hero-schedule","data-enabled":S.enabled||void 0,children:[e.jsxs("div",{className:"hero-schedule-time",children:[e.jsx("b",{children:S.frequency==="weekly"?"Weekly":S.frequency==="daily"?"Daily":"Once"}),e.jsx("span",{children:S.frequency?S.startTime:S.startAt?new Date(S.startAt).toLocaleDateString():"—"})]}),e.jsxs("div",{className:"hero-schedule-main",children:[e.jsxs("div",{className:"hero-schedule-title",children:[e.jsx("h3",{children:(q==null?void 0:q.name)??S.itemId}),e.jsx(M,{tone:S.enabled?"ok":void 0,children:S.enabled?"enabled":"paused"})]}),e.jsx("p",{children:wt(S)}),e.jsxs("div",{className:"chips",children:[(S.placements??["home"]).map(_=>{var ie;return e.jsx("span",{className:"chip",children:(ie=ke.find(pe=>pe.id===_))==null?void 0:ie.label},_)}),S.priority!==0?e.jsxs("span",{className:"chip",children:["Priority ",S.priority]}):null]})]}),e.jsxs("div",{className:"hero-schedule-actions",children:[e.jsx(R,{size:"sm",onClick:()=>w({...S}),children:"Edit"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>{j(_=>_.map(ie=>ie.id===S.id?{...ie,enabled:!ie.enabled}:ie)),p(!0)},children:S.enabled?"Pause":"Enable"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>{j(_=>_.filter(ie=>ie.id!==S.id)),p(!0)},children:"Remove"})]})]},S.id)})})}),e.jsxs($,{title:"Find a title",intro:`Search the imported Emby catalogue. Add a result to the selected ${(D=ke.find(S=>S.id===d))==null?void 0:D.label} placement; switch tabs to show it in more than one section.`,icon:"search",tone:"info",children:[e.jsxs("div",{className:"field-row",children:[e.jsx(L,{label:"Title",grow:!0,children:e.jsx("input",{type:"search",value:f,placeholder:"Search films and television shows",onChange:S=>h(S.target.value),onKeyDown:S=>{S.key==="Enter"&&z()}})}),e.jsx(R,{busy:a==="search",icon:"search",onClick:()=>void z(),children:"Search"})]}),g===null?null:g.length===0?e.jsx(X,{children:"No playable films or series matched that search."}):e.jsx(he,{children:g.map(S=>e.jsx($,{title:S.name,intro:`${S.type||"Title"} · ${S.year||"Year unknown"}`,children:e.jsxs("div",{className:"row",children:[e.jsx(R,{size:"sm",icon:"plus",disabled:I.some(q=>q.id===S.id)||d==="movies"&&S.type!=="Movie"||d==="tv_shows"&&S.type!=="Series",onClick:()=>se(S),children:I.some(q=>q.id===S.id)?"Pinned":"Add to hero"}),e.jsx(R,{size:"sm",icon:"clock",onClick:()=>w(Rs(S,d)),children:"Schedule"})]})},S.id))})]})]}),y?e.jsx(Nt,{schedule:y,item:[...(C==null?void 0:C.items)??[],...I,...g??[]].find(S=>S.id===y.itemId),timeZone:(C==null?void 0:C.timeZone)||"server local time",isNew:!N.some(S=>S.id===y.id),onCancel:()=>w(null),onSave:S=>{j(q=>q.some(_=>_.id===S.id)?q.map(_=>_.id===S.id?S:_):[...q,S]),w(null),p(!0)}}):null]})}function Nt({schedule:s,item:n,timeZone:t,isNew:i,onSave:r,onCancel:o}){const[a,c]=x.useState({...s,weekdays:[...s.weekdays??[]]}),d=yt(a),l=h=>{const g=new Date,b=new Date(g.getTime()+2*60*60*1e3);c(N=>{var j;return h==="once"?{...N,frequency:void 0,startAt:Ts(N.startAt)?N.startAt:g.toISOString(),endAt:Ts(N.endAt)?N.endAt:b.toISOString()}:{...N,frequency:h,startTime:N.startTime||"18:00",endTime:N.endTime||"22:00",weekdays:h==="weekly"?(j=N.weekdays)!=null&&j.length?N.weekdays:[1,2,3,4,5]:[]}})},u=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",m=!!(a.startAt&&a.endAt&&new Date(a.endAt)>new Date(a.startAt)),p=!!(a.startTime&&a.endTime&&a.startTime!==a.endTime&&(d!=="weekly"||(a.weekdays??[]).length>0)),f=d==="once"?m:p;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(L,{label:"Starts",children:e.jsx("input",{type:"datetime-local",value:a.startAt?As(new Date(a.startAt)):"",onChange:h=>c(g=>({...g,startAt:h.target.value?new Date(h.target.value).toISOString():void 0}))})}),e.jsx(L,{label:"Ends",children:e.jsx("input",{type:"datetime-local",value:a.endAt?As(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(L,{label:"Starts each time",children:e.jsx("input",{type:"time",value:a.startTime??"",onChange:h=>c(g=>({...g,startTime:h.target.value}))})}),e.jsx(L,{label:"Ends each time",hint:"An earlier end time continues into the following day.",children:e.jsx("input",{type:"time",value: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:rs.map(g=>g.value)})),children:"Every day"})]})]}),e.jsx("div",{className:"schedule-day-grid",children:rs.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(N=>N!==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:ke.map(h=>e.jsx(H,{label:h.label,checked:u.includes(h.id),disabled:!v(h.id),onChange:g=>c(b=>{const N=b.placements??["home"],j=g?[...N,h.id]:N.filter(y=>y!==h.id);return{...b,placements:j.length?[...new Set(j)]:N}})},h.id))})]}),e.jsx(L,{label:"Priority",hint:"Higher rules win when schedules overlap.",children:e.jsx("input",{type:"number",min:-1e3,max:1e3,step:10,value:a.priority,onChange:h=>c(g=>({...g,priority:Number(h.target.value)}))})})]}),e.jsx(H,{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(R,{variant:"quiet",onClick:o,children:"Cancel"}),e.jsx(R,{variant:"primary",disabled:!f,onClick:()=>r(a),children:i?"Add rule":"Save rule"})]})]})})}function St(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(null),l=s==null?void 0:s.features,u=(l==null?void 0:l.features)??[],v=(s==null?void 0:s.clients)??[],m=(l==null?void 0:l.revision)??0,p=(b,N,j,y)=>a(N,async()=>{await r(()=>F.post("/admin/api/features",{action:b,expectedRevision:m,overrides:y??{}}),j),d(null),await i()}),f=v.filter(b=>(b.capabilities??[]).includes("server_features_v1")).length,h=!!(l!=null&&l.safeMode),g=(b,N)=>({...Object.fromEntries(u.filter(j=>j.source==="override").map(j=>[j.key,j.enabled])),[b]:N});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($,{title:"Control plane",intro:"Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional.",icon:"sliders",tone:"ok",actions:e.jsx(R,{variant:h?void 0:"danger",busy:o==="safe",onClick:()=>h?void p("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(on,{tiles:[{label:"features active",value:`${u.filter(b=>b.enabled).length} / ${u.length}`},{label:"explicit overrides",value:k(u.filter(b=>b.source==="override").length)},{label:"televisions reporting the control plane",value:`${f} / ${v.length}`},{label:"published revision",value:`r${k(m)}`}]})}),e.jsx(he,{cols:"2",children:u.length===0?e.jsx($,{title:"Nothing registered",icon:"sliders",children:e.jsx(X,{children:"No server features are registered."})}):u.map(b=>e.jsxs($,{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(H,{label:b.enabled?"On":"Off",hint:"Changing this applies the feature policy to every compatible television.",checked:b.enabled,disabled:o===b.key,onChange:N=>d({action:"feature",key:b.key,enabled:N,title:`${N?"Turn on":"Turn off"} ${b.name}?`,body:`${N?"Enable":"Disable"} this feature for every compatible television. ${b.recovery}`,label:N?"Turn on":"Turn off"})}),e.jsxs("div",{className:"chips",children:[e.jsx(ae,{children:b.key}),e.jsxs(ae,{children:["protocol ",k(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($,{children:e.jsxs("div",{className:"row",children:[e.jsx(R,{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(R,{onClick:()=>d({action:"reset",title:"Clear every override?",body:"All features return to their safe software defaults.",label:"Clear overrides"}),children:"Clear all overrides"}),e.jsx("span",{className:"spacer"}),h?e.jsx(M,{tone:"warn",children:"safe mode · optional features off"}):e.jsxs(M,{tone:"ok",children:["live · revision r",k(m)]})]})})]}),c?e.jsx(xe,{title:c.title,body:c.body,confirmLabel:c.label,destructive:c.action!=="rollback",busy:o===c.action,onConfirm:()=>void p(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 Ct(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r,show:o}=te(),{busy:a,run:c}=ee(),[d,l]=x.useState(!0),[u,v]=x.useState("6.5"),[m,p]=x.useState(!1);x.useEffect(()=>{var h,g;m||!s||(l(((h=s.playbackPolicy)==null?void 0:h.prerollEnabled)!==!1),v(String((((g=s.playbackPolicy)==null?void 0:g.prerollDurationMs)??6500)/1e3)))},[s,m]);const f=()=>c("save",async()=>{const h=Number(u);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."),p(!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($,{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 · ",u,"s"]}):e.jsx(M,{children:"off"}),footer:e.jsx(R,{variant:"primary",busy:a==="save",onClick:()=>void f(),children:"Save playback policy"}),children:[e.jsx(H,{label:"Show the preroll before a title starts",checked:d,onChange:h=>{l(h),p(!0)}}),e.jsx("div",{className:"fields",children:e.jsx(L,{label:"Duration",hint:"Between 1 and 30 seconds. The stream is already playing behind it.",children:e.jsx("input",{type:"number",min:1,max:30,step:.5,value:u,onChange:h=>{v(h.target.value),p(!0)}})})})]})]})}function Mt(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(null),[l,u]=x.useState(null),[v,m]=x.useState(!1),p=s==null?void 0:s.subtitles,f=p==null?void 0:p.stored;x.useEffect(()=>{c||!p||d({bazarr:p.bazarrEnabled,openSubtitles:p.openSubtitlesEnabled,key:"",clearKey:!1,username:p.openSubtitlesUsername??"",password:"",clearLogin:!1})},[p,c]);const h=j=>d(y=>y&&{...y,...j}),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()=>{u(null);const j=await r(()=>F.post("/admin/api/subtitle-test"));u((j==null?void 0:j.results)??[])}),N=()=>a("clear",async()=>{await r(()=>F.post("/admin/api/subtitle-settings",{action:"clear-stored"}),"Stored subtitles deleted."),m(!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||!p||!c?e.jsx(V,{}):e.jsxs(e.Fragment,{children:[e.jsx(le,{tiles:[{label:"offered on televisions",value:p.available?"yes":"no",small:!0,icon:"captions",tone:p.available?"ok":void 0},{label:"providers on",value:k((p.bazarrEnabled&&p.bazarrConfigured?1:0)+(p.openSubtitlesEnabled?1:0)),icon:"list",tone:"note"},{label:"subtitles held",value:k((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($,{title:"Bazarr",intro:"Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it.",icon:"wrench",tone:"data",actions:p.bazarrConfigured?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:p.bazarrConfigured?`Configured at ${p.bazarrUrl}`:"Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr."}),children:e.jsx(H,{label:"Offer Bazarr in the player",hint:"Off leaves every subtitle it has already written in place.",checked:c.bazarr,disabled:!p.bazarrConfigured,onChange:j=>h({bazarr:j})})}),e.jsxs($,{title:"OpenSubtitles",intro:"OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.",icon:"captions",tone:"note",actions:p.openSubtitlesEnabled?e.jsx(M,{tone:p.openSubtitlesAccount?"ok":"warn",children:p.openSubtitlesAccount?"on · signed in":"on · anonymous"}):e.jsx(M,{children:p.openSubtitlesKeyConfigured?"off · key saved":"off · no key"}),children:[e.jsx(H,{label:"Offer OpenSubtitles in the player",hint:"Needs an API key. It cannot be switched on without one.",checked:c.openSubtitles,onChange:j=>h({openSubtitles:j})}),e.jsx(L,{label:"API key",hint:"From your consumer at opensubtitles.com. Leave blank to keep the saved key.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:c.key,placeholder:p.openSubtitlesKeyConfigured?"Saved key (leave blank to keep)":"Paste an API key",onChange:j=>h({key:j.target.value})})}),e.jsx(H,{label:"Remove the saved key",checked:c.clearKey,onChange:j=>h({clearKey:j})}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Account username",hint:"Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance.",children:e.jsx("input",{type:"text",autoComplete:"off",value:c.username,placeholder:"Not signed in",onChange:j=>h({username:j.target.value})})}),e.jsx(L,{label:"Account password",hint:"Leave blank to keep the saved one.",children:e.jsx("input",{type:"password",autoComplete:"new-password",value:c.password,onChange:j=>h({password:j.target.value})})})]}),e.jsx(H,{label:"Sign out and forget the account",checked:c.clearLogin,onChange:j=>h({clearLogin:j})})]})]}),e.jsxs($,{children:[e.jsxs("div",{className:"row",children:[e.jsx(R,{variant:"primary",busy:o==="save",onClick:()=>void g(),children:"Save subtitle settings"}),e.jsx(R,{busy:o==="test",icon:"pulse",onClick:()=>void b(),children:"Test the providers"}),e.jsx("span",{className:"hint",children:p.featureEnabled?"A change applies to the next title opened; no app release is required.":"Downloading subtitles is switched off on the Features page, so nothing here is offered."})]}),l===null?null:l.length===0?e.jsx(X,{children:"No provider is switched on, so there was nothing to ask."}):e.jsx("div",{className:"list",children:l.map(j=>e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsx("b",{children:j.provider}),e.jsx("p",{children:j.message})]}),e.jsx("div",{className:"list-actions",children:e.jsx(M,{tone:j.ok?"ok":"bad",children:j.ok?"reachable":"not reachable"})})]},j.provider))})]}),e.jsx($,{title:"Subtitles Memby is holding",intro:"Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it.",icon:"database",tone:"data",actions:f!=null&&f.count?e.jsxs(M,{tone:"data",children:[k(f.count)," files · ",De(f.bytes)]}):e.jsx(M,{children:"nothing held"}),footer:e.jsx(R,{variant:"danger",disabled:!(f!=null&&f.count),onClick:()=>m(!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 N(),onCancel:()=>m(!1)}):null]})}function Et(){const{status:s,error:n,loading:t,reload:i}=oe(),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(null),[l,u]=x.useState(!1),v=s==null?void 0:s.updatePolicy;x.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 m=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."),u(!1),d(null),await i())}),p=!!(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($,{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:p?"warn":"ok",children:[f?"sign-out · ":p?"required · ":"optional · ",v.latestVersion]}):e.jsx(M,{children:"off"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:o==="save",onClick:()=>c.required?u(!0):void m(!0),children:"Save policy"}),e.jsx(R,{busy:o==="off",onClick:()=>void m(!1),children:"Turn prompts off"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Latest version",children:e.jsx("input",{type:"text",value:c.version,placeholder:"0.2.63",onChange:g=>h({version:g.target.value})})}),e.jsx(L,{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(L,{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(L,{label:"Sign out builds below",hint:"The destructive compatibility floor. Leave blank to keep every supported viewer signed in.",children:e.jsx("input",{type:"text",value:c.retireBelow,placeholder:"0.2.44",onChange:g=>h({retireBelow:g.target.value})})}),e.jsx(H,{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(H,{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 m(!0),onCancel:()=>u(!1)}):null]})}const At=2e4,Tt=3e3,Rt=[{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 $t(s){const n=[...Rt];for(const t of[s.defaultIntervalSeconds,s.intervalSeconds])t>0&&!n.some(i=>i.value===t)&&n.push({value:t,label:is(t).replace(/^every /,"Every ")});return n.sort((t,i)=>t.value-i.value)}function $s(s){return s==="failed"?"bad":s==="running"?"info":s==="skipped"?"warn":"ok"}function It(){const{wrap:s}=te(),{busy:n,run:t}=ee(),[i,r]=x.useState(!1),{data:o,error:a,loading:c,reload:d}=J("/admin/api/tasks?limit=60",{pollMs:i?Tt:At}),l=(o==null?void 0:o.tasks)??[],u=l.some(y=>y.running);u!==i&&r(u);const v=y=>t(y.id,async()=>{await s(()=>F.post(`/admin/api/tasks/${encodeURIComponent(y.id)}/run`),`${y.name} started.`),await d()}),m=(y,w)=>t(`${y.id}:enabled`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{enabled:w}),w?`${y.name} switched on.`:`${y.name} switched off.`),await d()}),p=(y,w)=>t(`${y.id}:interval`,async()=>{await s(()=>F.put(`/admin/api/tasks/${encodeURIComponent(y.id)}`,{intervalSeconds:w}),`${y.name} now runs ${is(w)}.`),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 w;return((w=y.lastRun)==null?void 0:w.status)==="failed"}).length,g=l.filter(y=>y.defaultIntervalSeconds>0&&y.intervalSeconds!==y.defaultIntervalSeconds).length,b=l.filter(y=>!y.enabled).length,N=(o==null?void 0:o.groups)??[],j=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:k(l.length),icon:"clock",tone:"info"},{label:"Running now",value:k(l.filter(y=>y.running).length),icon:"pulse",tone:u?"ok":void 0},{label:"Last run failed",value:k(h),icon:"alert",tone:h>0?"bad":void 0},{label:"Switched off",value:k(b),icon:"power",tone:b>0?"warn":void 0},{label:"Retimed",value:k(g),icon:"clock",tone:g>0?"note":void 0}]}),h>0?e.jsx(ye,{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,[...N,...j.length>0?[""]:[]].map(y=>{const w=l.filter(C=>C.group===y);return w.length===0?null:e.jsx($,{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:w.map(C=>{var z;return e.jsxs("div",{className:"list-item",children:[e.jsxs("div",{className:"list-body",children:[e.jsxs("b",{children:[C.name," ",C.running?e.jsx(M,{tone:"info",children:"running"}):null,C.enabled?null:e.jsx(M,{tone:"warn",children:"off"}),C.defaultIntervalSeconds>0&&C.intervalSeconds!==C.defaultIntervalSeconds?e.jsx(M,{tone:"note",children:"retimed"}):null]}),e.jsx("p",{children:C.description}),e.jsxs("p",{className:"quiet",children:[is(C.intervalSeconds),C.enabled&&C.nextRun?` · next ${ve(C.nextRun).replace(" ago","")}`:"",C.lastRun?e.jsxs(e.Fragment,{children:[" · last ",e.jsx("span",{title:P(C.lastRun.startedAt),children:ve(C.lastRun.startedAt)}),` in ${Ne(C.lastRun.durationMs)}`,C.lastRun.detail?` — ${C.lastRun.detail}`:""]}):" · never run"]}),(z=C.lastRun)!=null&&z.error?e.jsx("p",{className:"mono",style:void 0,children:e.jsx(M,{tone:"bad",children:C.lastRun.error})}):null]}),e.jsxs("div",{className:"list-actions",children:[C.lastRun?e.jsx(M,{tone:$s(C.lastRun.status),children:C.lastRun.status}):e.jsx(M,{children:"never run"}),e.jsx("select",{"aria-label":`How often ${C.name} runs`,value:C.intervalSeconds,disabled:n===`${C.id}:interval`||C.running,onChange:A=>void p(C,Number(A.target.value)),children:$t(C).map(A=>e.jsxs("option",{value:A.value,children:[A.label,A.value===C.defaultIntervalSeconds?" (default)":""]},A.value))}),C.defaultIntervalSeconds>0&&C.intervalSeconds!==C.defaultIntervalSeconds?e.jsx(R,{size:"sm",icon:"refresh",busy:n===`${C.id}:interval`,onClick:()=>void f(C),children:"Default"}):null,e.jsx(H,{label:"",checked:C.enabled,disabled:n===`${C.id}:enabled`,onChange:A=>void m(C,A)}),e.jsx(R,{size:"sm",icon:"play",busy:n===C.id,disabled:C.running,onClick:()=>void v(C),children:"Run now"})]})]},C.id)})})},y||"other")}),e.jsx($,{title:"Recent runs",intro:"Every task together and in order, which is what shows two jobs interfering with each other.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{className:"nowrap",children:"Started"}),e.jsx("th",{children:"Task"}),e.jsx("th",{children:"Trigger"}),e.jsx("th",{children:"Result"}),e.jsx("th",{className:"num",children:"Took"}),e.jsx("th",{children:"Detail"})]})}),e.jsx("tbody",{children:((o==null?void 0:o.runs.length)??0)===0?e.jsx(Q,{columns:6,children:"No task has run yet."}):o==null?void 0:o.runs.map(y=>{var w;return e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(y.startedAt),children:ve(y.startedAt)}),e.jsx("td",{children:((w=l.find(C=>C.id===y.taskId))==null?void 0:w.name)??y.taskId}),e.jsx("td",{className:"muted",children:y.trigger}),e.jsx("td",{children:e.jsx(M,{tone:$s(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 Lt={id:"",name:"Discord",url:"",enabled:!0,events:[]};function Dt(){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]=x.useState(null),[u,v]=x.useState(null),m=(r==null?void 0:r.catalogue)??[],p=(r==null?void 0:r.integrations)??[],f=N=>l({id:N.id,name:N.name,url:"",enabled:N.enabled,events:N.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=N=>i("remove",async()=>{await s(()=>F.del(`/admin/api/integrations/${encodeURIComponent(N.id)}`),`${N.name} removed.`),v(null),await c()}),b=N=>i(`test:${N.id}`,async()=>{const j=await s(()=>F.post(`/admin/api/integrations/${encodeURIComponent(N.id)}/test`));j&&n(j.message,j.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(R,{variant:"primary",icon:"plus",onClick:()=>l(Lt),children:"Add a webhook"})}),e.jsx(U,{message:o}),e.jsx(qt,{}),e.jsx(Ft,{}),e.jsx(Pt,{}),((r==null?void 0:r.dropped)??0)>0?e.jsxs(ye,{tone:"warn",children:[k((r==null?void 0:r.dropped)??0)," events could not be queued for delivery. The queue is deliberately lossy — a slow endpoint must never hold up a television signing in — but a number growing here means a destination is not keeping up."]}):null,a?e.jsx(V,{}):p.length===0&&!d?e.jsx($,{title:"Nothing configured",icon:"plug",tone:"note",children:e.jsx(X,{children:"No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here."})}):p.map(N=>e.jsx(Ot,{integration:N,catalogue:m,busy:t,onEdit:()=>f(N),onTest:()=>void b(N),onRemove:()=>v(N)},N.id)),d?e.jsx(_t,{draft:d,catalogue:m,busy:t==="save",onChange:l,onSave:()=>void h(),onCancel:()=>l(null)}):null,u?e.jsx(xe,{title:`Remove ${u.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(u),onCancel:()=>v(null)}):null]})}function qt(){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($,{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(H,{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(H,{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 Ft(){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]=x.useState(0),[l,u]=x.useState(!1);x.useEffect(()=>{i&&(d(i.qualityProfileId),u(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()}),m=i==null?void 0:i.profiles.find(p=>p.id===c);return e.jsxs($,{title:"Sonarr TV requests",intro:"The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx(R,{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(L,{label:"Request quality profile",hint:"Memby stores this Sonarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:c,onChange:p=>d(Number(p.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(p=>e.jsxs("option",{value:p.id,children:[p.name,p.recommended?" — recommended (720p)":""]},p.id))]})})}),e.jsx(H,{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:u}),m?e.jsxs(ye,{tone:"info",children:["Requested series will use ",e.jsx("b",{children:m.name})," (profile ID ",m.id,"), be monitored using Memby’s existing all-episodes strategy, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Pt(){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]=x.useState(0),[l,u]=x.useState(!1);x.useEffect(()=>{i&&(d(i.qualityProfileId),u(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()}),m=i==null?void 0:i.profiles.find(p=>p.id===c);return e.jsxs($,{title:"Radarr movie requests",intro:"The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice.",icon:"tv",tone:i!=null&&i.configured?"ok":"warn",actions:i!=null&&i.configured?e.jsx(M,{tone:"ok",children:"configured"}):e.jsx(M,{tone:"warn",children:"needs attention"}),footer:e.jsx(R,{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(L,{label:"Request quality profile",hint:"Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.",children:e.jsxs("select",{value:c,onChange:p=>d(Number(p.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(p=>e.jsxs("option",{value:p.id,children:[p.name,p.recommended?" — recommended (720p)":""]},p.id))]})})}),e.jsx(H,{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:u}),m?e.jsxs(ye,{tone:"info",children:["Requested films will use ",e.jsx("b",{children:m.name})," (profile ID ",m.id,"), remain monitored, and ",l?"start an immediate search.":"not start an immediate search."]}):null]})]})}function Ot({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($,{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(R,{size:"sm",icon:"pulse",busy:t===`test:${s.id}`,onClick:r,children:"Test"}),e.jsx(R,{size:"sm",onClick:i,children:"Edit"}),e.jsx(R,{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 u;return((u=n.find(v=>v.type===l))==null?void 0:u.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:[k(a.deliveries)," attempts, ",k(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:ve(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(Q,{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(d=>d.group))],c=(d,l)=>i({...s,events:l?[...s.events,d]:s.events.filter(u=>u!==d)});return e.jsxs($,{title:s.id?`Edit ${s.name}`:"New Discord webhook",icon:"plug",tone:"info",footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"primary",busy:t,onClick:r,children:s.id?"Save":"Add"}),e.jsx(R,{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(L,{label:"Name",hint:"What this destination is called in the console.",children:e.jsx("input",{type:"text",value:s.name,onChange:d=>i({...s,name:d.target.value})})}),e.jsx(L,{label:"Webhook address",hint:s.id?"Leave blank to keep the address already saved — it is a credential and is never sent back to this page.":"Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.",children:e.jsx("input",{type:"url",value:s.url,placeholder:s.id?"unchanged":"https://discord.com/api/webhooks/…",onChange:d=>i({...s,url:d.target.value})})})]}),e.jsx(H,{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(H,{label:l.label,hint:l.description,checked:s.events.includes(l.type),onChange:u=>c(l.type,u)},l.type))]},d))]})}function Ut(){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]=x.useState(""),[l,u]=x.useState(!1),[v,m]=x.useState(!1),[p,f]=x.useState(!1),[h,g]=x.useState("23:00"),[b,N]=x.useState("07:00"),[j,y]=x.useState(""),[w,C]=x.useState(!1),z=!!((G=s==null?void 0:s.maintenance)!=null&&G.enabled);x.useEffect(()=>{var T;!v&&s&&d(((T=s.maintenance)==null?void 0:T.message)??"")},[s,v]),x.useEffect(()=>{w||!(s!=null&&s.quietTime)||(f(s.quietTime.enabled),g(s.quietTime.startTime),N(s.quietTime.endTime),y(s.quietTime.message))},[s,w]);const A=T=>a(T?"on":"off",async()=>{await r(()=>F.post("/admin/api/maintenance",{enabled:T,message:c}),T?"Memby is offline for every television.":"Memby is back online."),u(!1),m(!1),await i()}),I=()=>a("quiet",async()=>{await r(()=>F.post("/admin/api/quiet-time",{enabled:p,startTime:h,endTime:b,message:j}),p?"Quiet time saved.":"Quiet time turned off."),C(!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($,{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:z?"bad":"warn",actions:z?e.jsx(M,{tone:"bad",children:"offline"}):e.jsx(M,{tone:"ok",children:"online"}),footer:e.jsxs(e.Fragment,{children:[e.jsx(R,{variant:"danger",disabled:z,onClick:()=>u(!0),children:"Go offline"}),e.jsx(R,{disabled:!z,busy:o==="off",onClick:()=>void A(!1),children:"Bring back online"})]}),children:e.jsx(L,{label:"Message shown on the television",hint:"Say what is happening and when it will be back. It is the only thing the viewer is told.",children:e.jsx("input",{type:"text",value:c,placeholder:"Back shortly — upgrading the server",onChange:T=>{d(T.target.value),m(!0)}})})}),t?null:e.jsxs($,{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 server 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"}):p?e.jsx(M,{tone:"ok",children:"scheduled"}):e.jsx(M,{children:"off"}),footer:e.jsx(R,{variant:"primary",busy:o==="quiet",onClick:()=>void I(),children:"Save quiet time"}),children:[e.jsx(H,{label:"Pause server activity during quiet time",checked:p,onChange:T=>{f(T),C(!0)}}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Starts",hint:"Uses the server's 24-hour clock.",children:e.jsx("input",{type:"time",value:h,onChange:T=>{g(T.target.value),C(!0)}})}),e.jsx(L,{label:"Ends",hint:"May be on the following day, for example 23:00 to 07:00.",children:e.jsx("input",{type:"time",value:b,onChange:T=>{N(T.target.value),C(!0)}})})]}),e.jsx(L,{label:"Message shown on the television",hint:"Shown when a television contacts Memby during quiet time.",children:e.jsx("input",{type:"text",value:j,placeholder:"Quiet time — try again after 7 am",onChange:T=>{y(T.target.value),C(!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 A(!0),onCancel:()=>u(!1)}):null]})}const Wt=-1;function Re(s){return s===0?"":s<0?"off":String(s)}function $e(s,n){const t=s.trim().toLowerCase();if(t==="")return 0;if(n&&(t==="off"||t==="none"||t==="0"))return Wt;const i=Number.parseInt(t,10);return Number.isFinite(i)?i:0}function Ie(s,n){return s<=0?"off":`${s} ${n}${s===1?"":"s"}`}function ss(s){return{timezone:s.timezone??"",logLevel:s.logLevel??"",sessionIdleDays:Re(s.sessionIdleDays),sonarrAlertMinutes:Re(s.sonarrAlertMinutes),radarrAlertMinutes:Re(s.radarrAlertMinutes),embyHealthSeconds:Re(s.embyHealthSeconds),librarySyncMinutes:Re(s.librarySyncMinutes)}}function Bt(){const{data:s,error:n,loading:t,reload:i}=J("/admin/api/gateway-settings"),{wrap:r}=te(),{busy:o,run:a}=ee(),[c,d]=x.useState(null);x.useEffect(()=>{!c&&s&&d(ss(s.settings))},[s,c]);const l=(h,g)=>d(b=>b&&{...b,[h]:g}),u=()=>a("save",async()=>{if(!c)return;const h={timezone:c.timezone.trim(),logLevel:c.logLevel.trim(),sessionIdleDays:$e(c.sessionIdleDays,!1),sonarrAlertMinutes:$e(c.sonarrAlertMinutes,!0),radarrAlertMinutes:$e(c.radarrAlertMinutes,!0),embyHealthSeconds:$e(c.embyHealthSeconds,!0),librarySyncMinutes:$e(c.librarySyncMinutes,!0)},g=await r(()=>F.post("/admin/api/gateway-settings",h),"Gateway settings saved.");g&&d(ss(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(ss(h.settings)),await i()}),m=s==null?void 0:s.deployed,p=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||!m||!p?e.jsx(V,{rows:2}):e.jsxs(e.Fragment,{children:[e.jsx($,{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(as,{rows:[{label:"Server timezone",value:p.timezone||"not set"},{label:"Log level",value:p.logLevel},{label:"Sign-in expiry",value:Ie(p.sessionIdleDays,"day")},{label:"Emby health probe",value:Ie(p.embyHealthSeconds,"second")},{label:"Catalogue sweep",value:Ie(p.librarySyncMinutes,"minute")},{label:"Episode alert window",value:Ie(p.sonarrAlertMinutes,"minute")},{label:"Film alert window",value:Ie(p.radarrAlertMinutes,"minute")}]})}),e.jsxs($,{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(R,{variant:"primary",busy:o==="save",onClick:()=>void u(),children:"Save settings"}),e.jsx(R,{busy:o==="clear",onClick:()=>void v(),children:"Use deployed values"})]}),children:[e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Server timezone",hint:`Deployed: ${m.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:m.timezone,onChange:h=>l("timezone",h.target.value)})}),e.jsx(L,{label:"Log level",hint:`Deployed: ${m.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 (",m.logLevel,")"]}),f.map(h=>e.jsx("option",{value:h,children:h},h))]})})]}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Sign a television out after (days)",hint:`Deployed: ${m.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(m.sessionIdleDays),onChange:h=>l("sessionIdleDays",h.target.value)})}),e.jsx(L,{label:"Emby health probe (seconds)",hint:`Deployed: ${m.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(m.embyHealthSeconds),onChange:h=>l("embyHealthSeconds",h.target.value)})})]}),e.jsx("div",{className:"fields",children:e.jsx(L,{label:"Catalogue sweep (minutes)",hint:`Deployed: ${m.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(m.librarySyncMinutes),onChange:h=>l("librarySyncMinutes",h.target.value)})})}),e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Episode alert window (minutes)",hint:`Deployed: ${m.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(m.sonarrAlertMinutes),onChange:h=>l("sonarrAlertMinutes",h.target.value)})}),e.jsx(L,{label:"Film alert window (minutes)",hint:`Deployed: ${m.radarrAlertMinutes||"off"}. The same, for a film Radarr has just imported.`,children:e.jsx("input",{type:"text",inputMode:"numeric",value:c.radarrAlertMinutes,placeholder:String(m.radarrAlertMinutes),onChange:h=>l("radarrAlertMinutes",h.target.value)})})]}),e.jsxs(ye,{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(ye,{children:["Last changed by ",s.settings.updatedBy,s.settings.updatedAt?` on ${new Date(s.settings.updatedAt).toLocaleString("en-NZ")}`:"","."]}):null]})]})]})}const Vt={done:"ok",pending:"warn",failed:"bad"};function zt(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 Ht(){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($,{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:k((s==null?void 0:s.counts.pending)??0),icon:"clock",tone:"note"},{label:"Given up on",value:k((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(Q,{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:zt(o)}),e.jsx("td",{className:"muted",children:o.reason}),e.jsx("td",{children:e.jsx(M,{tone:Vt[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 Kt(){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(Ht,{}),t?e.jsx(V,{rows:1}):e.jsx($,{title:"Synchronisation history",intro:"A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs. 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(Q,{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:k(r.itemsSeen)}),e.jsx("td",{className:"num",children:k(r.itemsUpserted)}),e.jsx("td",{className:"num",children:k(r.itemsRemoved)}),e.jsx("td",{className:"muted",children:r.error||""})]},r.id||r.startedAt))})]})})})]})}const Gt={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"},dn=s=>Gt[s]??"quiet",Zt={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"]},Jt=[[/^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 Yt(s,n){const t=n.toLowerCase();for(const[r,o]of Jt)if(r.test(t))return o;const i=Zt[s];return i||(s?["gateway","Gateway",Ye(s.replace(/[-_]/g," "))]:["gateway","Gateway","Server"])}const Qt={h:36e5,m:6e4,s:1e3,ms:1,us:.001,µs:.001,ns:1e-6};function ls(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]?Qt[r[2]]:void 0;o!==void 0&&(t+=Number(r[1])*o,i=!0)}return i?t:null}function ps(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 Xt=s=>s>=3e3?"bad":s>=1e3?"warn":null,ei={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 si(s){const n=ei[s];return n||(s>=500?"Server Error":s>=400?"Client Error":s>=300?"Redirected":s>=200?"OK":"Response")}const ni=s=>s>=500?"bad":s>=400?"warn":s>=300?"quiet":"ok",ti={"/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"},ii=s=>/\d/.test(s)||s.length>24;function Is(s){const n=ti[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=>!ii(r));return i.length===0?s:Ye(i.join(" ").replace(/[-_.]/g," ").replace(/\s+/g," ").trim())}const Ye=s=>s&&s.charAt(0).toUpperCase()+s.slice(1),We=s=>Ye(s.replace(/_/g," ")),de=s=>s==null?"":String(s),hn=new Set(["","unknown","none","null","","0"]),be=s=>!hn.has(de(s).toLowerCase()),ai=["title","series","name","query","item_title","file"],ri={directplay:"ok",direct:"ok",directstream:"ok",transcode:"warn",transcoding:"warn"};function li(s,n,t){if(t!==null)return{label:`${t} ${si(t)}`,short:String(t),tone:ni(t)};if(be(s.error))return{label:"Failed",short:"Failed",tone:n==="WARN"?"warn":"bad"};const i=de(s.play_method).toLowerCase().replace(/[\s_-]/g,"");if(i&&!hn.has(i)){const r=Ye(de(s.play_method).replace(/([a-z])([A-Z])/g,"$1 $2"));return{label:r,short:r,tone:ri[i]??"info"}}if(be(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 oi(s){const n=[];be(s.user)&&n.push(de(s.user)),be(s.device)&&n.push(de(s.device));const t=ls(s.marker_ms);t!==null&&t>0&&n.push(`Start ${Ls(t)}`);const i=ls(s.position);return i!==null&&i>0&&n.push(`At ${Ls(i)}`),be(s.watched)&&n.push(`${de(s.watched)} watched`),be(s.reason)&&n.push(de(s.reason)),n.slice(0,3).join(" · ")}function Ls(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 un=[{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"]}],ci=new Set(un.flatMap(s=>s.keys)),di=s=>ci.has(s),hi=new Intl.DateTimeFormat("en-NZ",{hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}),ui=new Intl.DateTimeFormat("en-NZ",{weekday:"short",day:"numeric",month:"short"}),Ds=new WeakMap;function qe(s){const n=Ds.get(s);if(n)return n;const t=mi(s);return Ds.set(s,t),t}function mi(s){const n=s.attributes??{},t=s.message??"",[i,r,o]=Yt(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,u=t==="request"&&!!a,v=ai.find(A=>be(n[A])),m=v?de(n[v]):"";let p,f;u?(p=c||"HTTP",f=Is(a)):a&&c?(p=c,f=m?`${We(t)} · ${m}`:`${We(t)} — ${Is(a)}`):(p="",f=m?`${We(t)} · ${m}`:We(t));const h=ls(n.duration??n.duration_ms??n.negotiation_duration),g=be(n.error)?de(n.error):"",b=oi(n),N=new Date(s.occurredAt),j=g||(u?"":b),y=g?"error":j?"context":"",w=!g&&u?b:"",C=Object.entries(n);return{serviceKey:i,service:r,component:o,action:p,summary:f,context:b,detail:g,secondary:j,secondaryTone:y,trail:w,result:li(n,s.level,l),durationMs:h,method:c,status:l,eventKey:t,level:s.level,time:`${hi.format(N)}.${String(N.getMilliseconds()).padStart(3,"0")}`,day:ui.format(N),dayKey:N.toDateString(),tall:!!j,haystack:[t,r,o,f,b,g,...C.flat().map(de)].join(" ").toLowerCase(),fields:C,attributes:n}}const qs={TRACE:5,DEBUG:10,INFO:20,WARN:30,ERROR:40},ns={level:"INFO",service:"",component:"",event:"",method:"",status:"",slower:0,text:""};function pi(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 xi(s,n,t){if((qs[s.level]??0)<(qs[n.level]??20))return!1;const i=qe(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||!pi(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 vi(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: >${ps(s.slower)}`}),s.text&&t.push({key:"text",label:`Search: ${s.text}`}),t}const Be=2e4,gi=5e3,Fs=7,bi=16,fi=15,yi=2,wi=1,os=Fs+bi+Fs+wi,Ps=os+yi+fi,Os=26,ki=31,_s=10,Ni=[{value:"TRACE",label:"Everything"},{value:"DEBUG",label:"Debug+"},{value:"INFO",label:"Info+"},{value:"WARN",label:"Warnings+"},{value:"ERROR",label:"Errors only"}],Si=[{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)"}],Ci=[{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"}],Us=s=>s.replace(/_/g," ");function Ve({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 Mi=x.memo(function({event:n,view:t,top:i,height:r,selected:o,onInspect:a,onFilter:c}){const d=t.durationMs!==null?Xt(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(Ve,{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(Ve,{className:"logrow-service","data-tone":dn(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(Ve,{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.summary,t.trail,t.secondary].filter(Boolean).join(" — "),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.trail?e.jsx("span",{className:"logrow-trail",children:t.trail}):null]}),t.secondary?e.jsx("span",{className:"logrow-second","data-tone":t.secondaryTone,children:t.secondaryTone==="error"?`↳ ${t.secondary}`:t.secondary}):null]}),e.jsx("span",{className:"logrow-result",children:t.result?e.jsx(Ve,{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?ps(t.durationMs):""})]})});function Ei({event:s,view:n,onClose:t}){const[i,r]=x.useState(!1),o=n.fields.filter(([d])=>!di(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=un.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":dn(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(R,{size:"sm",variant:"quiet",onClick:a,icon:"download",children:i?"Copied":"Copy JSON"}),e.jsx(R,{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:ps(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,u])=>e.jsxs(x.Fragment,{children:[e.jsx("dt",{children:Us(l)}),e.jsx("dd",{children:String(u)})]},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(x.Fragment,{children:[e.jsx("dt",{children:Us(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 Ai(){var js;const[s,n]=x.useState([]),[t,i]=x.useState(0),[r,o]=x.useState(!1),[a,c]=x.useState(0),[d,l]=x.useState(ns),[u,v]=x.useState(""),[m,p]=x.useState({top:0,height:600}),[f,h]=x.useState(!0),[g,b]=x.useState(null),N=x.useDeferredValue(d.text.trim().toLowerCase()),j=x.useRef(0),y=x.useRef(!1),w=x.useRef(0),C=x.useRef(null),z=x.useRef(!0),A=x.useRef(void 0),I=x.useRef([]),G=x.useRef(r);x.useEffect(()=>{G.current=r},[r]);const se=x.useCallback(E=>{n(O=>{const K=O.concat(E);return K.length>Be?K.slice(K.length-Be):K})},[]),ne=x.useCallback(async()=>{if(y.current||document.hidden)return;y.current=!0;const E=w.current,O=[];let K=0;try{let ce=0,ge;do ge=await F.get(`/admin/api/events?after=${j.current}&limit=1000`),j.current=ge.next||j.current,K+=ge.dropped||0,O.push(...ge.events??[]),ce+=1;while(ge.hasMore&&ce<20);v("")}catch(ce){v(ce instanceof Error?ce.message:String(ce))}finally{O.length>0&&E===w.current&&(G.current?(I.current=I.current.concat(O),I.current.length>Be&&(I.current=I.current.slice(I.current.length-Be)),c(I.current.length)):se(O)),K>0&&E===w.current&&i(ce=>ce+K),y.current=!1}},[se]);x.useEffect(()=>{let E;const O=()=>{window.clearInterval(E),E=document.hidden?void 0:window.setInterval(()=>void ne(),gi)},K=()=>{O(),document.hidden||ne()};return ne(),O(),document.addEventListener("visibilitychange",K),()=>{window.clearInterval(E),document.removeEventListener("visibilitychange",K)}},[ne]);const ue=x.useCallback(()=>{G.current=!0,o(!0)},[]),T=x.useCallback(()=>{G.current=!1;const E=I.current;I.current=[],c(0),o(!1),E.length&&se(E)},[se]),D=x.useCallback(E=>{l(O=>({...O,...E}))},[]),S=x.useMemo(()=>s.filter(E=>xi(E,d,N)),[s,d,N]),q=x.useMemo(()=>{const E=new Float64Array(S.length+1),O=new Uint8Array(S.length),K=new Uint8Array(S.length);let ce=0,ge="";for(let Se=0;Se{let E=0,O=S.length;for(;E>1;(q.tops[K]??0)+(q.heights[K]??0)<=_?E=K+1:O=K}return Math.max(0,E-_s)},[q,_,S.length]),pe=x.useMemo(()=>{const E=_+m.height;let O=ie;for(;O{const E=[];for(let O=ie;Os.find(E=>E.sequence===g),[s,g]),Qe=x.useCallback(()=>{const E=C.current;E&&(z.current=!0,E.scrollTop=E.scrollHeight,h(!0),p({top:E.scrollTop,height:E.clientHeight}))},[]);x.useLayoutEffect(()=>{const E=C.current;!E||!z.current||(E.scrollTop=E.scrollHeight,p({top:E.scrollTop,height:E.clientHeight}))},[_e,q.total]),x.useEffect(()=>()=>window.cancelAnimationFrame(A.current??0),[]);const jn=()=>{const E=C.current;if(!E)return;const O=E.scrollHeight-E.scrollTop-E.clientHeight{p({top:E.scrollTop,height:E.clientHeight})})},vn=()=>{const E=new Blob([JSON.stringify(S,null,2)],{type:"application/json"}),O=document.createElement("a");O.href=URL.createObjectURL(E),O.download=`memby-events-${new Date().toISOString().replace(/[:.]/g,"-")}.json`,O.click(),window.setTimeout(()=>URL.revokeObjectURL(O.href),1e3)},Ae=x.useMemo(()=>ji(s),[s]),xs=vi(d,Ae.services);return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Server logs",intro:"Structured gateway events as they happen."}),e.jsx(U,{message:u}),e.jsxs($,{children:[e.jsxs("div",{className:"logbar",children:[e.jsxs("div",{className:"logbar-filters",children:[e.jsxs("select",{"aria-label":"Service",value:d.service,onChange:E=>D({service:E.target.value,component:""}),children:[e.jsx("option",{value:"",children:"All services"}),Ae.services.map(E=>e.jsx("option",{value:E.key,children:E.label},E.key))]}),e.jsxs("select",{"aria-label":"Component",value:d.component,onChange:E=>D({component:E.target.value}),children:[e.jsx("option",{value:"",children:"All components"}),Ae.components.map(E=>e.jsx("option",{value:E,children:E},E))]}),e.jsx("select",{"aria-label":"Level",value:d.level,onChange:E=>D({level:E.target.value}),children:Ni.map(E=>e.jsx("option",{value:E.value,children:E.label},E.value))}),e.jsxs("select",{"aria-label":"Event",value:d.event,onChange:E=>D({event:E.target.value}),children:[e.jsx("option",{value:"",children:"All events"}),Ae.events.map(E=>e.jsx("option",{value:E,children:E},E))]}),e.jsx("select",{"aria-label":"Result",value:d.status,onChange:E=>D({status:E.target.value}),children:Si.map(E=>e.jsx("option",{value:E.value,children:E.label},E.value))}),e.jsxs("select",{"aria-label":"Method",value:d.method,onChange:E=>D({method:E.target.value}),children:[e.jsx("option",{value:"",children:"Any method"}),Ae.methods.map(E=>e.jsx("option",{value:E,children:E},E))]}),e.jsx("select",{"aria-label":"Duration",value:String(d.slower),onChange:E=>D({slower:Number(E.target.value)}),children:Ci.map(E=>e.jsx("option",{value:String(E.value),children:E.label},E.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:E=>D({text:E.target.value})})]})]}),e.jsxs("div",{className:"logbar-actions",children:[e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>r?T():ue(),icon:r?"play":"clock",children:r?a?`Resume (${k(a)})`:"Resume":"Pause"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:()=>{w.current+=1,I.current=[],c(0),n([]),i(0),b(null)},children:"Clear view"}),e.jsx(R,{size:"sm",variant:"quiet",onClick:vn,icon:"download",title:"Export the rows matching the current filters, as delivered by the gateway",children:"Export JSON"})]})]}),xs.length?e.jsxs("div",{className:"logchips",children:[xs.map(E=>e.jsxs("button",{type:"button",className:"logchip",onClick:()=>D({[E.key]:ns[E.key]}),children:[E.label,e.jsx(Y,{name:"close"})]},E.key)),e.jsx("button",{type:"button",className:"logchip logchip-clear",onClick:()=>l(ns),children:"Clear all"})]}):null,e.jsxs("div",{className:"logshell",children:[e.jsxs("div",{className:"logview",ref:C,onScroll:jn,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"})]}),S.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:`${q.total}px`},children:Oe.map(({event:E,view:O,index:K})=>e.jsxs(x.Fragment,{children:[q.divider[K]?e.jsx("div",{className:"logday",style:{transform:`translateY(${(q.tops[K]??0)-Os}px)`},children:e.jsx("span",{children:O.day})}):null,e.jsx(Mi,{event:E,view:O,top:q.tops[K]??0,height:q.heights[K]??os,selected:E.sequence===g,onInspect:b,onFilter:D})]},E.sequence))})]}),!f&&S.length>0?e.jsxs("button",{type:"button",className:"logtail",onClick:Qe,children:[e.jsx(Y,{name:"caret"}),"Jump to latest"]}):null]}),e.jsxs("p",{className:"hint",children:[k(s.length)," retained · ",k(S.length)," matching",S.length?` · ${k(Oe.length)} rows mounted`:"",t?` · ${k(t)} overwritten before delivery`:"",r?` · paused${a?`, ${k(a)} held`:""}`:""]}),me?e.jsx(Ei,{event:me,view:qe(me),onClose:()=>b(null)}):s.length?e.jsx(ye,{children:"Select a row to see the full record — request, context, diagnostics and raw event."}):null]})]})}const Ws=["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 we(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function Ti(){const[s,n]=x.useState(30),[t,i]=x.useState(""),r=cs(),o=x.useMemo(()=>`/admin/api/journeys${Ee({days:s,userId:t})}`,[s,t]),{data:a,error:c,loading:d}=J(o),l=a==null?void 0:a.stats,u=(a==null?void 0:a.users)??[],v=(a==null?void 0:a.actions)??[],m=(a==null?void 0:a.paths)??[],p=m[0],f=x.useMemo(()=>{const h=new Map(((a==null?void 0:a.features)??[]).map(b=>[b.feature,b])),g=new Map(Ws.map((b,N)=>[b,N]));return[...new Set([...Ws,...h.keys()])].map(b=>({name:b,stat:h.get(b)})).sort((b,N)=>{var y,w;const j=(((y=N.stat)==null?void 0:y.uses)??0)-(((w=b.stat)==null?void 0:w.uses)??0);return j||(g.get(b.name)??Number.MAX_SAFE_INTEGER)-(g.get(N.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($,{title:"Journey health",intro:"Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored.",icon:"people",tone:"info",actions:e.jsxs(e.Fragment,{children:[e.jsx(L,{label:"Window",children:e.jsxs("select",{value:s,onChange: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(L,{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"}),u.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:k(l==null?void 0:l.journeys),icon:"list",tone:"data"},{label:"viewers",value:k(l==null?void 0:l.viewers),icon:"people",tone:"info"},{label:"completion",value:He(l==null?void 0:l.completionRate),icon:"check",tone:"ok"},{label:"abandoned",value:k(l==null?void 0:l.abandoned),icon:"alert",tone:"note"},{label:"active now",value:k(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:He(l==null?void 0:l.completionRate)})]}),e.jsx(Wn,{value:(l==null?void 0:l.completed)??0,total:(l==null?void 0:l.journeys)??0}),e.jsxs("p",{children:[k(l==null?void 0:l.completed)," completed · ",k(l==null?void 0:l.abandoned)," abandoned ·"," ",k(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:p?`${we(p.from)} → ${we(p.to)}`:"Not enough data"}),e.jsx("p",{children:p?`${k(p.count)} times in this window`:"Journeys will appear here as viewers move through Memby."})]})]})]})}),e.jsxs(he,{cols:"2",children:[e.jsx($,{title:"What people do",intro:"Actions show total use and how many separate visits included them.",icon:"chart",tone:"info",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Action"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{className:"num",children:"Visits"})]})}),e.jsx("tbody",{children:v.length===0?e.jsx(Q,{columns:3,children:"No significant actions in this window."}):v.map(h=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("b",{children:we(h.action)}),e.jsx("span",{className:"table-sub",children:we(h.category)})]}),e.jsx("td",{className:"num",children:k(h.events)}),e.jsx("td",{className:"num",children:k(h.journeys)})]},`${h.category}:${h.action}`))})]})})}),e.jsx($,{title:"Where people go",intro:"The most common steps between screens, including where quiet visits ended.",icon:"list",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Route"}),e.jsx("th",{className:"num",children:"Times"})]})}),e.jsx("tbody",{children:m.length===0?e.jsx(Q,{columns:2,children:"No repeated paths in this window."}):m.map((h,g)=>e.jsxs("tr",{children:[e.jsxs("td",{children:[we(h.from)," ",e.jsx("span",{className:"route-arrow",children:"→"})," ",we(h.to)]}),e.jsx("td",{className:"num",children:k(h.count)})]},`${h.from}:${h.to}:${g}`))})]})})})]}),e.jsx($,{title:"Feature use",intro:"Rare and unused features are shown against Memby's major feature catalogue.",icon:"pulse",tone:"data",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Feature"}),e.jsx("th",{className:"num",children:"Uses"}),e.jsx("th",{children:"Last used"}),e.jsx("th",{children:"Status"})]})}),e.jsx("tbody",{children:f.map(({name:h,stat:g})=>{const b=(g==null?void 0:g.uses)??0;return e.jsxs("tr",{children:[e.jsx("td",{children:we(h)}),e.jsx("td",{className:"num",children:k(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($,{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},mn=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)),Ri=s=>{const n=s.find(t=>t.category==="playback"&&t.action==="request");return n!=null&&n.source?fe(n.source):mn(s[0])},Bs=s=>s.itemName?`${fe(s.itemType)} · ${s.itemName}`:s.source&&s.target?`${fe(s.source)} → ${fe(s.target)}`:mn(s),$i=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 Ii(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 Li(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 Di(){var d,l;const{userId:s=""}=Ge(),n=x.useMemo(()=>`/admin/api/journeys${Ee({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(u=>u.userId===s))==null?void 0:l.username)||s,a=x.useMemo(()=>{const u=new Map;for(const v of(t==null?void 0:t.events)??[])u.set(v.journeyId,[...u.get(v.journeyId)??[],v]);return[...u.values()].map(v=>v.sort((m,p)=>m.sequence-p.sequence)).sort((v,m)=>{var p,f;return(((p=m[0])==null?void 0:p.occurredAt)??"").localeCompare(((f=v[0])==null?void 0:f.occurredAt)??"")})},[t==null?void 0:t.events]),c=a.flatMap(u=>Li(u).map((v,m)=>{var p;return{events:v,key:`${(p=u[0])==null?void 0:p.journeyId}:${m}`}}));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($,{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((u,v)=>{const m=u.events,p=m[0],f=[...m].reverse().find(g=>g.itemName||g.action==="select"||g.category==="playback"&&g.action==="request"),h=Ii(m);return e.jsxs("article",{className:"visit",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("b",{children:P(p==null?void 0:p.occurredAt)}),e.jsxs("span",{children:["Journey ",v+1," · ",m.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:Ri(m)})]}),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?Bs(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:m.map(g=>e.jsxs("li",{children:[e.jsx("span",{className:"timeline-dot","data-action":g.action}),e.jsxs("div",{children:[e.jsx("b",{children:$i(g)}),e.jsx("span",{children:Bs(g)})]}),e.jsx("time",{children:P(g.occurredAt)})]},`${g.journeyId}:${g.sequence}`))})]},u.key)})})})]})}function Vs(s){const n=String(s||"—").replaceAll("_"," ");return n==="favorites"?"Favourites":n==="abandoned"?"Abandoned / interrupted":n}function qi(){const[s,n]=x.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($,{title:"Launcher rows",intro:"Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth.",icon:"chart",tone:"info",actions:e.jsx(L,{label:"Window",children:e.jsxs("select",{value:s,onChange: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(Q,{columns:8,children:"No events in this window."}):o.map(a=>e.jsxs("tr",{children:[e.jsx("td",{children:Vs(a.rowId)}),e.jsx("td",{className:"muted",children:Vs(a.rowKind)}),e.jsx("td",{className:"num",children:Ne(a.dwellMs)}),e.jsx("td",{className:"num",children:k(a.impressions)}),e.jsx("td",{className:"num",children:k(a.focuses)}),e.jsx("td",{className:"num",children:k(a.selects)}),e.jsx("td",{className:"num",children:He(a.selectRate)}),e.jsx("td",{className:"num",children:k(a.viewers)})]},`${a.rowId}:${a.rowKind}`))})]})})})]})}function Fi(){const[s,n]=x.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 viewers have been looking for, and what was searched just now."}),e.jsx(U,{message:i}),e.jsx(le,{tiles:[{label:"searches",value:k((c==null?void 0:c.searches)??0),icon:"search",tone:"info"},{label:"distinct queries",value:k((c==null?void 0:c.queries)??0),icon:"list",tone:"data"},{label:"viewers searching",value:k((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($,{title:"What the house looks for",intro:"Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too.",icon:"search",tone:"info",actions:e.jsx(L,{label:"Window",children:e.jsxs("select",{value:s,onChange:d=>n(Number(d.target.value)),children:[e.jsx("option",{value:1,children:"24 hours"}),e.jsx("option",{value:7,children:"7 days"}),e.jsx("option",{value:30,children:"30 days"})]})}),children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Query"}),e.jsx("th",{className:"num",children:"Searches"}),e.jsx("th",{className:"num",children:"Viewers"}),e.jsx("th",{children:"Last searched"})]})}),e.jsx("tbody",{children:o.length===0?e.jsx(Q,{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:k(d.searches)}),e.jsx("td",{className:"num",children:k(d.viewers)}),e.jsx("td",{className:"muted nowrap",children:P(d.lastAt)})]},d.query))})]})})}),e.jsx($,{title:"As it happened",intro:"The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),e.jsx("th",{children:"Viewer"}),e.jsx("th",{children:"Query"})]})}),e.jsx("tbody",{children:a.length===0?e.jsx(Q,{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 zs(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 Pi(){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:zs((s==null?void 0:s.today.visits)??0,(s==null?void 0:s.lastWeek.visits)??0),value:k(s==null?void 0:s.today.visits),icon:"overview",tone:"data"},{label:zs((s==null?void 0:s.today.viewers)??0,(s==null?void 0:s.lastWeek.viewers)??0),value:k(s==null?void 0:s.today.viewers),icon:"people",tone:"note"},{label:"busiest time today",value:(s==null?void 0:s.busiestHour)||"—",small:!0,icon:"clock",tone:"info"}]}),e.jsx($,{title:"Visits by day",intro:"One visit is a signed-in home-screen opening. Viewers are distinct signed-in 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(Q,{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:k(o.visits)}),e.jsx("td",{className:"num",children:k(o.viewers)})]},o.label))})]})})}),e.jsx($,{title:"Today by hour",intro:"Local New Zealand time. Use this to see when viewers are 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(Q,{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:k(o.visits)}),e.jsx("td",{className:"num",children:k(o.viewers)})]},o.label))})]})})})]})]})}const Oi=s=>s.mediaType==="episode"?`${s.seriesTitle} S${String(s.seasonNumber).padStart(2,"0")}E${String(s.episodeNumber).padStart(2,"0")}`:s.title;function _i(){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($,{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:Oi(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(R,{size:"sm",variant:"quiet",busy:n===`${o.id}-acknowledged`,onClick:()=>void i(o,"acknowledged"),children:"Acknowledge"})," ",e.jsx(R,{size:"sm",variant:"quiet",busy:n===`${o.id}-resolved`,onClick:()=>void i(o,"resolved"),children:"Resolve"})," ",e.jsx(R,{size:"sm",variant:"quiet",busy:n===`${o.id}-dismissed`,onClick:()=>void i(o,"dismissed"),children:"Dismiss"})]})]},o.id))})]})}):e.jsx(X,{children:"No media problems have been reported."})})]})}const Hs={user:"",kind:"",channel:"",status:"",source:"",q:"",from:"",to:""},Ui=[{value:1,label:"Today"},{value:7,label:"7 days"},{value:30,label:"30 days"},{value:90,label:"90 days"}],Le=100,pn={sent:"ok",delivered:"ok",failed:"bad",pending:"warn",skipped:"idle"},Wi={"in-app":"note",broadcast:"info",webhook:"data"},Ke={"in-app":"In-app",broadcast:"Broadcast",webhook:"Webhook"};function je(s){if(!s)return"—";const n=s.replace(/[-_.:]+/g," ").trim();return n.charAt(0).toUpperCase()+n.slice(1)}function ze(s,n){return(s??[]).map(t=>e.jsxs("option",{value:t.value,children:[n(t.value)," (",t.count,")"]},t.value))}function Bi(){var b,N,j,y;const[s,n]=x.useState(7),[t,i]=x.useState(Hs),[r,o]=x.useState(0),[a,c]=x.useState(null),d=x.useMemo(()=>Ee({...t,days:t.from?void 0:s,limit:Le,offset:r*Le}),[t,s,r]),l=J(`/admin/api/notification-log${d}`),u=l.data,v=w=>{i(C=>({...C,...w})),o(0),c(null)},m=Object.values(t).some(w=>w!==""),p=u==null?void 0:u.totals,f=(u==null?void 0:u.retentionDays)??90,h=(u==null?void 0:u.entries.length)??0,g=!u||u.total===0?0:r*Le+1;return e.jsxs(e.Fragment,{children:[e.jsx(W,{title:"Notifications",intro:"Everything Memby sent — a viewer's own news, the bar every television draws, and each outbound webhook — with what became of it. Every feature reports through one notification service, so this is the whole trail rather than whichever half a feature remembered to log."}),e.jsx(U,{message:l.error}),p?e.jsx(le,{tiles:[{label:"Sent",value:k(p.sent),icon:"send",tone:"ok"},{label:"Confirmed",value:k(p.delivered),icon:"check",tone:"ok"},{label:"Failed",value:k(p.failed),icon:"alert",tone:p.failed>0?"bad":void 0},{label:"Skipped",value:k(p.skipped),icon:"filter",tone:"idle"},{label:"People reached",value:k(p.users),icon:"people",tone:"note"},{label:"History kept",value:`${f} days`,small:!0,icon:"clock"}]}):null,e.jsxs("div",{className:"filters",children:[e.jsx(L,{label:"Window",children:e.jsx(Pe,{value:t.from?-1:s,options:Ui.map(w=>({value:w.value,label:w.label})),onChange:w=>{n(w),v({from:"",to:""})}})}),e.jsx(L,{label:"Person",children:e.jsxs("select",{value:t.user,onChange:w=>v({user:w.target.value}),children:[e.jsx("option",{value:"",children:"Anyone"}),((u==null?void 0:u.users)??[]).map(w=>e.jsx("option",{value:w.id,children:w.username||w.id},w.id))]})}),e.jsx(L,{label:"Type",children:e.jsxs("select",{value:t.kind,onChange:w=>v({kind:w.target.value}),children:[e.jsx("option",{value:"",children:"Any type"}),ze((b=u==null?void 0:u.facets)==null?void 0:b.kinds,je)]})}),e.jsx(L,{label:"Channel",children:e.jsxs("select",{value:t.channel,onChange:w=>v({channel:w.target.value}),children:[e.jsx("option",{value:"",children:"Any channel"}),ze((N=u==null?void 0:u.facets)==null?void 0:N.channels,w=>Ke[w]??je(w))]})}),e.jsx(L,{label:"Status",children:e.jsxs("select",{value:t.status,onChange:w=>v({status:w.target.value}),children:[e.jsx("option",{value:"",children:"Any status"}),ze((j=u==null?void 0:u.facets)==null?void 0:j.statuses,je)]})}),e.jsx(L,{label:"Source",children:e.jsxs("select",{value:t.source,onChange:w=>v({source:w.target.value}),children:[e.jsx("option",{value:"",children:"Any service"}),ze((y=u==null?void 0:u.facets)==null?void 0:y.sources,je)]})}),e.jsx(L,{label:"From",children:e.jsx("input",{type:"date",value:t.from,onChange:w=>v({from:w.target.value})})}),e.jsx(L,{label:"To",children:e.jsx("input",{type:"date",value:t.to,onChange:w=>v({to:w.target.value})})}),e.jsx(L,{label:"Search",grow:!0,children:e.jsx("input",{type:"search",value:t.q,placeholder:"Title, message, error or person",onChange:w=>v({q:w.target.value})})}),e.jsx("div",{className:"filter-actions",children:m?e.jsx(R,{variant:"quiet",size:"sm",onClick:()=>{i(Hs),o(0),c(null)},children:"Clear"}):null})]}),l.loading&&!u?e.jsx(V,{}):null,u&&u.days.length>1?e.jsx($,{title:"Notifications per day",intro:"Failures and deliberate skips are counted beside the deliveries, because a quiet week and a week nothing was allowed to send look identical otherwise.",icon:"chart",tone:"info",children:e.jsx(ms,{data:u.days,labelOf:w=>w.day,valueOf:w=>w.sent+w.delivered+w.failed+w.skipped,toneOf:w=>w.failed>0?"bad":void 0,title:w=>`${w.day}: ${w.sent+w.delivered} sent, ${w.failed} failed, ${w.skipped} skipped`})}):null,u?e.jsx($,{title:"History",intro:"Newest first. A row says who, what and whether it worked on its own; open one for the whole message and the delivery response.",icon:"send",tone:"ok",actions:e.jsx("span",{className:"filter-summary",children:u.total===0?"nothing matches":`${k(g)}–${k(g+h-1)} of ${k(u.total)}`}),footer:u.total>Le?e.jsxs(e.Fragment,{children:[e.jsx(R,{size:"sm",disabled:r===0,onClick:()=>o(r-1),children:"Newer"}),e.jsx(R,{size:"sm",disabled:(r+1)*Le>=u.total,onClick:()=>o(r+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:"Recipient"}),e.jsx("th",{children:"Type"}),e.jsx("th",{children:"Title"}),e.jsx("th",{children:"Channel"}),e.jsx("th",{children:"Source"}),e.jsx("th",{children:"Status"}),e.jsx("th",{"aria-label":"Details"})]})}),e.jsx("tbody",{children:u.entries.length===0?e.jsx(Q,{columns:8,children:"No notifications match these filters."}):u.entries.map(w=>e.jsxs(x.Fragment,{children:[e.jsx(Vi,{entry:w,open:a===w.id,onToggle:()=>c(a===w.id?null:w.id)}),a===w.id?e.jsx(zi,{entry:w}):null]},w.id))})]})})}):null]})}function Vi({entry:s,open:n,onToggle:t}){return e.jsxs("tr",{"data-selected":n||void 0,children:[e.jsx("td",{className:"nowrap muted",children:P(s.occurredAt)}),e.jsx("td",{children:s.userId?e.jsx(re,{className:"table-row-link",to:`/admin/accounts/${encodeURIComponent(s.userId)}`,children:s.username||s.userId}):s.target?e.jsx("span",{className:"muted",children:s.target}):e.jsx("span",{className:"quiet",children:"Everyone"})}),e.jsx("td",{className:"mono",children:s.kind||"—"}),e.jsx("td",{children:s.title||e.jsx("span",{className:"quiet",children:"—"})}),e.jsx("td",{className:"nowrap",children:e.jsx(M,{tone:Wi[s.channel],children:Ke[s.channel]??s.channel})}),e.jsx("td",{className:"muted",children:je(s.source)}),e.jsx("td",{className:"nowrap",children:e.jsx(M,{tone:pn[s.status]??"idle",children:s.status})}),e.jsx("td",{className:"nowrap",children:e.jsx(R,{size:"sm",variant:"quiet",onClick:t,icon:n?"close":"list",children:n?"Close":"Details"})})]})}function zi({entry:s}){const n=Object.entries(s.metadata??{}).filter(([,t])=>t!=null&&String(t)!=="");return e.jsx("tr",{className:"detail-row",children:e.jsx("td",{colSpan:8,children:e.jsxs("section",{className:"logdrawer","aria-label":`Notification ${s.id}`,children:[e.jsxs("header",{className:"logdrawer-head",children:[e.jsxs("div",{children:[e.jsxs("p",{className:"logdrawer-place",children:[e.jsx("span",{children:Ke[s.channel]??s.channel}),e.jsx("span",{className:"logrow-sep","aria-hidden":"true",children:"›"}),je(s.source)]}),e.jsx("b",{children:s.title||je(s.kind)}),s.detail?e.jsx("p",{className:"logdrawer-error",children:s.detail}):null]}),e.jsx("div",{className:"logdrawer-actions",children:e.jsx(M,{tone:pn[s.status]??"idle",children:s.status})})]}),e.jsxs("div",{className:"logdrawer-grid",children:[e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Delivery"}),e.jsxs("dl",{children:[e.jsx("dt",{children:"Sent"}),e.jsx("dd",{children:P(s.occurredAt)}),s.eventAt?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Event"}),e.jsx("dd",{children:P(s.eventAt)})]}):null,e.jsx("dt",{children:"Channel"}),e.jsx("dd",{children:Ke[s.channel]??s.channel}),e.jsx("dt",{children:"Type"}),e.jsx("dd",{children:s.kind||"—"}),e.jsx("dt",{children:"Service"}),e.jsx("dd",{children:je(s.source)}),e.jsx("dt",{children:"Took"}),e.jsxs("dd",{children:[s.durationMs,"ms"]})]})]}),e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Recipient"}),e.jsxs("dl",{children:[e.jsx("dt",{children:"Person"}),e.jsx("dd",{children:s.userId?s.username||s.userId:"the whole household"}),s.target?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Destination"}),e.jsx("dd",{children:s.target})]}):null,s.itemId?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Title id"}),e.jsx("dd",{children:s.itemId})]}):null,s.sourceKey?e.jsxs(e.Fragment,{children:[e.jsx("dt",{children:"Source key"}),e.jsx("dd",{children:s.sourceKey})]}):null]})]}),n.length?e.jsxs("div",{className:"logdrawer-section",children:[e.jsx("h4",{children:"Context"}),e.jsx("dl",{children:n.map(([t,i])=>e.jsxs(x.Fragment,{children:[e.jsx("dt",{children:je(t)}),e.jsx("dd",{children:String(i)})]},t))})]}):null]}),s.body?e.jsxs("div",{className:"logdrawer-raw",children:[e.jsx("h4",{children:"Message"}),e.jsx("p",{className:"notification-body",children:s.body})]}):null]})})})}const Hi=s=>s==="detected"?"ok":s==="failed"?"bad":s==="no_match"?"warn":"info",Ki=s=>s==="no_match"?"no match":s,Ks=s=>({"live-playback":"Live playback","tracearr-next":"Next episode","tracearr-binge-prefetch":"Binge look-ahead","multi-user-demand":"Multiple viewers"}[s]??s)||"Unknown",Gs=(s,n)=>s>0&&n>0?`S${String(s).padStart(2,"0")}E${String(n).padStart(2,"0")}`:"Episode";function Gi(){var h,g,b,N;const s=J("/admin/api/credits?limit=150",{pollMs:15e3}),{wrap:n}=te(),{busy:t,run:i}=ee(),[r,o]=x.useState(),[a,c]=x.useState(!1);x.useEffect(()=>{!a&&s.data&&o(s.data.settings)},[s.data,a]);const d=j=>{o(y=>y&&{...y,...j}),c(!0)},l=()=>{r&&i("save",async()=>{const j=await n(()=>F.put("/admin/api/credits",r),"Credits scanning settings saved.");j&&(s.set(j),o(j.settings),c(!1))})},u=((h=s.data)==null?void 0:h.history)??[],v=((g=s.data)==null?void 0:g.pending)??[],m=u.filter(j=>j.outcome==="detected").length,p=u.filter(j=>j.outcome==="no_match").length,f=u.filter(j=>j.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(ye,{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:k((N=s.data)==null?void 0:N.queueDepth),icon:"clock",tone:v.length?"info":void 0},{label:"Detected in this history",value:k(m),icon:"check",tone:"ok"},{label:"No match",value:k(p),icon:"search",tone:p?"warn":void 0},{label:"Failed",value:k(f),icon:"alert",tone:f?"bad":void 0}]}),e.jsxs(he,{cols:"wide",children:[e.jsx($,{title:"Candidate controls",intro:"The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead.",icon:"sliders",tone:"note",footer:e.jsx(R,{variant:"primary",icon:"check",busy:t==="save",disabled:!a,onClick:l,children:"Save settings"}),children:e.jsxs("div",{className:"fields",children:[e.jsx(L,{label:"Candidate limit",hint:"Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.",children:e.jsx("input",{type:"number",min:1,max:100,value:r.candidateLimit,onChange:j=>d({candidateLimit:Number(j.target.value)})})}),e.jsx(L,{label:"Ordinary look-ahead",hint:"Episodes prepared ahead of a normally paced viewer.",children:e.jsx("input",{type:"number",min:1,max:10,value:r.prefetchEpisodes,onChange:j=>d({prefetchEpisodes:Number(j.target.value)})})}),e.jsx(L,{label:"Maximum look-ahead",hint:"Upper bound for fast binge viewing; must not be below the ordinary look-ahead.",children:e.jsx("input",{type:"number",min:r.prefetchEpisodes,max:20,value:r.maxPrefetch,onChange:j=>d({maxPrefetch:Number(j.target.value)})})}),e.jsx(L,{label:"Retry delay (hours)",hint:"After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.",children:e.jsx("input",{type:"number",min:0,max:720,value:r.retryHours,onChange:j=>d({retryHours:Number(j.target.value)})})})]})}),e.jsxs($,{title:"How selection works",icon:"sparkle",tone:"data",children:[e.jsx("p",{className:"muted",children:"Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching."}),e.jsx("p",{className:"muted",children:"A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it."})]})]}),e.jsx($,{title:"Waiting candidates",intro:"The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as 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(Q,{columns:6,children:"No episodes are waiting to be scanned."}):v.map(j=>e.jsxs("tr",{children:[e.jsx("td",{children:Gs(j.season,j.episode)}),e.jsx("td",{children:e.jsx(M,{tone:"info",children:Ks(j.reason)})}),e.jsx("td",{className:"num",children:k(j.priority)}),e.jsx("td",{className:"num",children:k(j.userCount)}),e.jsx("td",{className:"nowrap muted",title:P(j.lastViewed),children:ve(j.lastViewed)}),e.jsx("td",{className:"mono muted",children:j.itemId})]},j.itemId))})]})})}),e.jsx($,{title:"Scan history",intro:"Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately.",icon:"history",tone:"note",children:e.jsx(Z,{children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"Finished"}),e.jsx("th",{children:"Programme"}),e.jsx("th",{children:"Selected because"}),e.jsx("th",{children:"Result"}),e.jsx("th",{children:"Marker"}),e.jsx("th",{children:"Evidence"}),e.jsx("th",{className:"num",children:"Took"})]})}),e.jsx("tbody",{children:u.length===0?e.jsx(Q,{columns:7,children:"No credits scans have completed yet."}):u.map(j=>e.jsxs("tr",{children:[e.jsx("td",{className:"nowrap muted",title:P(j.finishedAt),children:ve(j.finishedAt)}),e.jsxs("td",{children:[e.jsx("b",{children:j.seriesName||j.itemName||j.itemId}),e.jsxs("span",{className:"table-sub",children:[Gs(j.season,j.episode),j.itemName&&j.seriesName?` · ${j.itemName}`:""]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:"info",children:Ks(j.reason)}),e.jsxs("span",{className:"table-sub",children:["priority ",j.priority]})]}),e.jsxs("td",{children:[e.jsx(M,{tone:Hi(j.outcome),children:Ki(j.outcome)}),j.error?e.jsx("span",{className:"table-sub",children:j.error}):null]}),e.jsx("td",{className:"nowrap",children:j.markerMs>0?Ne(j.markerMs):"—"}),e.jsxs("td",{className:"muted",children:[j.method||"visual",j.confidence>0?` · ${He(j.confidence)}`:"",j.frames>0?` · ${j.frames} frames`:""]}),e.jsx("td",{className:"num muted",children:Ne(j.durationMs)})]},j.id))})]})})})]})]})}function Zi(){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 Ji(){return e.jsx(fn,{children:e.jsx(Dn,{children:e.jsx(_n,{children:e.jsx(Hn,{children:e.jsx(yn,{children:e.jsxs(B,{path:"/admin",element:e.jsx(Vn,{}),children:[e.jsx(B,{index:!0,element:e.jsx(Kn,{})}),e.jsx(B,{path:"activity",element:e.jsx(Zn,{})}),e.jsx(B,{path:"accounts",element:e.jsx(Jn,{})}),e.jsx(B,{path:"accounts/:userId",element:e.jsx(Yn,{})}),e.jsx(B,{path:"accounts/:userId/settings",element:e.jsx(tt,{})}),e.jsx(B,{path:"clients",element:e.jsx(at,{})}),e.jsx(B,{path:"logins",element:e.jsx(lt,{})}),e.jsx(B,{path:"devices/:deviceId",element:e.jsx(ht,{})}),e.jsx(B,{path:"library",element:e.jsx(ut,{})}),e.jsx(B,{path:"ratings",element:e.jsx(pt,{})}),e.jsx(B,{path:"requests",element:e.jsx(xt,{})}),e.jsx(B,{path:"recommendations",element:e.jsx(jt,{})}),e.jsx(B,{path:"inspector",element:e.jsx(bt,{})}),e.jsx(B,{path:"hero",element:e.jsx(kt,{})}),e.jsx(B,{path:"features",element:e.jsx(St,{})}),e.jsx(B,{path:"playback",element:e.jsx(Ct,{})}),e.jsx(B,{path:"subtitles",element:e.jsx(Mt,{})}),e.jsx(B,{path:"credits",element:e.jsx(Gi,{})}),e.jsx(B,{path:"updates",element:e.jsx(Et,{})}),e.jsx(B,{path:"tasks",element:e.jsx(It,{})}),e.jsx(B,{path:"integrations",element:e.jsx(Dt,{})}),e.jsx(B,{path:"maintenance",element:e.jsx(Ut,{})}),e.jsx(B,{path:"settings",element:e.jsx(Bt,{})}),e.jsx(B,{path:"imports",element:e.jsx(Kt,{})}),e.jsx(B,{path:"logs",element:e.jsx(Ai,{})}),e.jsx(B,{path:"journeys",element:e.jsx(Ti,{})}),e.jsx(B,{path:"journeys/:userId",element:e.jsx(Di,{})}),e.jsx(B,{path:"views",element:e.jsx(Pi,{})}),e.jsx(B,{path:"engagement",element:e.jsx(qi,{})}),e.jsx(B,{path:"searches",element:e.jsx(Fi,{})}),e.jsx(B,{path:"media-reports",element:e.jsx(_i,{})}),e.jsx(B,{path:"notifications",element:e.jsx(Bi,{})}),e.jsx(B,{path:"overview",element:e.jsx(wn,{to:"/admin",replace:!0})}),e.jsx(B,{path:"*",element:e.jsx(Zi,{})})]})})})})})})}const xn=document.getElementById("root");if(!xn)throw new Error("the console has no root element to render into");Qs(xn).render(e.jsx(x.StrictMode,{children:e.jsx(Ji,{})})); diff --git a/admin-ui/dist/index.html b/admin-ui/dist/index.html index 31a3bbd..fa60a12 100644 --- a/admin-ui/dist/index.html +++ b/admin-ui/dist/index.html @@ -13,9 +13,9 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" /> - + - +
diff --git a/admin-ui/src/App.tsx b/admin-ui/src/App.tsx index b13062a..90d2e8f 100644 --- a/admin-ui/src/App.tsx +++ b/admin-ui/src/App.tsx @@ -35,6 +35,7 @@ import { EngagementPage } from './pages/Engagement'; import { SearchesPage } from './pages/Searches'; import { ViewsPage } from './pages/Views'; import { MediaReportsPage } from './pages/MediaReports'; +import { NotificationsPage } from './pages/Notifications'; import { CreditsPage } from './pages/Credits'; /* The console's routing table. @@ -97,6 +98,7 @@ export function App() { } /> } /> } /> + } /> {/* The old console redirected /admin/ to /admin/overview. Anything that still links there lands on the overview rather than on a 404. */} diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 063880a..53474e3 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -601,3 +601,82 @@ export interface IngestResponse { counts: { pending: number; done: number; failed: number }; recent: IngestJob[]; } + +/* The outbound notification history — /admin/api/notification-log. + * + * Distinct from the administrative activity feed above: that is the operator's own bell, + * this is the record of what Memby sent to viewers and to external services, whichever + * feature produced it. One request carries the page, its totals, its daily shape and the + * filter options, because they all describe the same filtered window and two requests + * could disagree with each other while a filter was being typed. */ + +export type NotificationChannel = 'in-app' | 'broadcast' | 'webhook'; + +export type NotificationStatus = 'sent' | 'delivered' | 'failed' | 'pending' | 'skipped'; + +export interface NotificationLogEntry { + id: number; + occurredAt: string; + channel: NotificationChannel | string; + kind: string; + source: string; + userId?: string; + username?: string; + title: string; + body?: string; + itemId?: string; + /** A destination's name — an integration, never its address. */ + target?: string; + sourceKey?: string; + status: NotificationStatus | string; + /** The failure, or the reason a notification was deliberately not delivered. */ + detail?: string; + durationMs: number; + eventAt?: string; + metadata?: Record; +} + +export interface NotificationTotals { + total: number; + sent: number; + delivered: number; + failed: number; + pending: number; + skipped: number; + users: number; +} + +export interface NotificationDay { + day: string; + sent: number; + failed: number; + skipped: number; + delivered: number; +} + +export interface NotificationFacet { + value: string; + count: number; +} + +/* Built from what has actually been sent rather than from a list of constants, so the + filters can neither offer a type that matches nothing nor miss one a feature added + after this page was written. */ +export interface NotificationFacets { + kinds: NotificationFacet[]; + channels: NotificationFacet[]; + statuses: NotificationFacet[]; + sources: NotificationFacet[]; +} + +export interface NotificationLogResponse { + entries: NotificationLogEntry[]; + total: number; + limit: number; + offset: number; + totals: NotificationTotals; + days: NotificationDay[]; + facets: NotificationFacets; + users: KnownUser[]; + retentionDays: number; +} diff --git a/admin-ui/src/components/Icon.tsx b/admin-ui/src/components/Icon.tsx index 7df42a0..7fcf2d8 100644 --- a/admin-ui/src/components/Icon.tsx +++ b/admin-ui/src/components/Icon.tsx @@ -33,6 +33,7 @@ export const icons = { play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14', list: 'M4 7h16M4 12h16M4 17h10', inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5', + send: 'M21 3 10.5 13.5M21 3l-6.8 18-3.7-7.5L3 10z', 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', diff --git a/admin-ui/src/components/OmniSearch.tsx b/admin-ui/src/components/OmniSearch.tsx index e5a546c..65814cf 100644 --- a/admin-ui/src/components/OmniSearch.tsx +++ b/admin-ui/src/components/OmniSearch.tsx @@ -117,8 +117,8 @@ export function OmniSearch() { ref={input} type="search" value={query} - placeholder="Search pages, users and devices…" - aria-label="Search pages, users and devices" + placeholder="Search" + aria-label="Search" aria-expanded={open} onFocus={() => setOpen(true)} onChange={(event) => { diff --git a/admin-ui/src/lib/logmodel.ts b/admin-ui/src/lib/logmodel.ts index a39379d..81387cf 100644 --- a/admin-ui/src/lib/logmodel.ts +++ b/admin-ui/src/lib/logmodel.ts @@ -32,6 +32,15 @@ export interface Shaped { context: string; /** The one line that explains a failure, printed under the row rather than hidden. */ detail: string; + /* What the row actually prints under its summary, and what it prints at the end of the + * summary line. They are separate fields rather than a rule the table re-derives, + * because `tall` is the row's height and a height that disagrees with what is drawn is + * text sliced through the middle — which is exactly what happened while the renderer + * printed a context line the height rule had already decided against. */ + secondary: string; + secondaryTone: 'error' | 'context' | ''; + /** Identity on a request row: the same fact, kept on the one line density depends on. */ + trail: string; result: { label: string; short: string; tone: LogTone } | null; durationMs: number | null; method: string; @@ -433,6 +442,15 @@ function derive(event: LogEvent): Shaped { const context = contextFor(attributes); const occurred = new Date(event.occurredAt); + // One decision, read twice. A failure explains itself under the summary; so does an + // application event carrying a person or a position. Ordinary request traffic — which is + // most of a log — keeps its identity at the end of its own line instead, because density + // is the whole reason this page is worth watching and a second line on every request + // would halve what an operator can see at once. + const secondary = detail || (isRequest ? '' : context); + const secondaryTone: Shaped['secondaryTone'] = detail ? 'error' : secondary ? 'context' : ''; + const trail = !detail && isRequest ? context : ''; + const fields = Object.entries(attributes); const shaped: Shaped = { serviceKey, @@ -442,6 +460,9 @@ function derive(event: LogEvent): Shaped { summary, context, detail, + secondary, + secondaryTone, + trail, result: resultFor(attributes, event.level, status), durationMs, method, @@ -451,10 +472,8 @@ function derive(event: LogEvent): Shaped { time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`, day: dayFormat.format(occurred), dayKey: occurred.toDateString(), - // An error explains itself on a second line; so does an application event carrying a - // person or a position. Ordinary request traffic — which is most of a log — stays on - // one, because density is the whole reason this page is worth watching. - tall: Boolean(detail) || (Boolean(context) && !isRequest), + // The height follows what is printed, never a second guess at it. + tall: Boolean(secondary), haystack: [ message, service, component, summary, context, detail, ...fields.flat().map(text), diff --git a/admin-ui/src/nav.ts b/admin-ui/src/nav.ts index c7ef95d..75eed6c 100644 --- a/admin-ui/src/nav.ts +++ b/admin-ui/src/nav.ts @@ -64,7 +64,7 @@ export const nav: NavGroup[] = [ id: 'accounts', path: '/admin/accounts', label: 'Users', - title: 'Memby users', + title: 'Users', intro: 'Who uses Memby, and the devices they are signed in on.', icon: 'people', }, @@ -76,6 +76,17 @@ export const nav: NavGroup[] = [ intro: 'Who can ask for something the library does not have.', icon: 'inbox', }, + { + /* The record of what Memby sent, which is a different question from the activity + feed above it: that is the operator's own bell, this is every outbound + notification to a viewer or an external service, whichever feature produced it. */ + id: 'notifications', + path: '/admin/notifications', + label: 'Notifications', + title: 'Notifications', + intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.', + icon: 'send', + }, { id: 'media-reports', path: '/admin/media-reports', @@ -95,8 +106,8 @@ export const nav: NavGroup[] = [ { id: 'logs', path: '/admin/logs', - label: 'Server logs', - title: 'Server logs', + label: 'Logs', + title: 'Logs', intro: 'Structured gateway events as they happen.', icon: 'list', }, @@ -309,7 +320,7 @@ export const nav: NavGroup[] = [ path: '/admin/searches', label: 'Searches', title: 'Searches', - intro: 'What the household has been looking for, and what it searched just now.', + intro: 'What viewers have been looking for, and what was searched just now.', icon: 'search', }, { diff --git a/admin-ui/src/pages/Account.tsx b/admin-ui/src/pages/Account.tsx index 2176d42..2def472 100644 --- a/admin-ui/src/pages/Account.tsx +++ b/admin-ui/src/pages/Account.tsx @@ -40,6 +40,12 @@ interface PreferenceDefinition { numbers?: number[]; unit?: string; maxLength?: number; + /* Whether a text value is folded to capitals, and what an empty field means. Both come + from the catalogue rather than from this page: initials read as capitals and a person's + name does not, and a console that decided that for itself would drift from the server + the first time a text setting was added. */ + uppercase?: boolean; + placeholder?: string; adminOnly?: boolean; } @@ -101,6 +107,7 @@ interface AccountDetail { id: string; username: string; initials: string; + shortName: string; lastSeen: string; devices: AccountDevice[] | null; themes: string[] | null; @@ -229,7 +236,9 @@ export function AccountPage() { <> ← All users} actions={ <> @@ -361,7 +370,7 @@ export function AccountPage() { {account.watchTime?.matched ? ( onChange(event.target.value.toLocaleUpperCase('en-NZ'))} + placeholder={definition.placeholder} + onChange={(event) => + onChange( + definition.uppercase + ? event.target.value.toLocaleUpperCase('en-NZ') + : event.target.value, + ) + } /> ); diff --git a/admin-ui/src/pages/Accounts.tsx b/admin-ui/src/pages/Accounts.tsx index 0d11d0b..16a6724 100644 --- a/admin-ui/src/pages/Accounts.tsx +++ b/admin-ui/src/pages/Accounts.tsx @@ -37,6 +37,7 @@ interface Account { id: string; username: string; initials: string; + shortName: string; lastSeen: string; devices: KnownClient[] | null; recommendations?: { prompted?: boolean; completed?: boolean }; @@ -100,7 +101,7 @@ export function AccountsPage() { { label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' }, { label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' }, { - label: 'active in the last quarter hour', + label: 'active in the last 15 mins', value: num(devices.filter((device) => recent(device.lastSeen)).length), icon: 'pulse', tone: 'ok', @@ -110,7 +111,7 @@ export function AccountsPage() { ...(tracked.length ? [ { - label: 'watched by the household this week', + label: 'watch time this week', value: watchTime(weekMs), icon: 'pulse' as const, tone: 'data' as const, @@ -126,6 +127,7 @@ export function AccountsPage() { Person + Short name Devices This week This month @@ -135,7 +137,7 @@ export function AccountsPage() { {rows.length === 0 ? ( - + No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here. ) : ( @@ -163,6 +165,16 @@ export function AccountsPage() { + {/* Blank is the ordinary state and not a gap: the launcher greets + somebody by their account name unless an operator has given + Memby a friendlier one, and saying so beats a bare dash. */} + + {account.shortName || ( + + account name + + )} + {num(list.length)} {/* Only where there is something to say. A sub-line under every diff --git a/admin-ui/src/pages/Credits.tsx b/admin-ui/src/pages/Credits.tsx index a7e3dc2..8ff1492 100644 --- a/admin-ui/src/pages/Credits.tsx +++ b/admin-ui/src/pages/Credits.tsx @@ -122,7 +122,7 @@ export function CreditsPage() { diff --git a/admin-ui/src/pages/Logins.tsx b/admin-ui/src/pages/Logins.tsx index f83e7a8..950cf0d 100644 --- a/admin-ui/src/pages/Logins.tsx +++ b/admin-ui/src/pages/Logins.tsx @@ -239,7 +239,7 @@ function LogView({ diff --git a/admin-ui/src/pages/Logs.tsx b/admin-ui/src/pages/Logs.tsx index 0611bda..bd6ce78 100644 --- a/admin-ui/src/pages/Logs.tsx +++ b/admin-ui/src/pages/Logs.tsx @@ -47,6 +47,13 @@ import type { LogEvent, LogResponse } from '../api/types'; * because density is the reason this page is worth watching; a failure or a real * application event earns a second. That means the virtual window is driven by a prefix * sum of row heights rather than by multiplication, computed once per filter change. + * Which height a row gets and what it prints are one decision, made once, in + * `lib/logmodel`'s `secondary` — the table renders that field and nothing else under + * the summary. They were two, and they disagreed: every authenticated request line + * carries the viewer and the television, so a context line was drawn on rows the height + * rule had already ruled out, centred inside a box too short for it and sliced top and + * bottom. A request's identity now sits at the end of its own line instead, which keeps + * both the density and the fact. * - **Pause holds the view, not the connection.** Draining continues while paused and the * arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer * and resuming is a flush rather than a stampede — the previous behaviour let the ring @@ -56,8 +63,20 @@ import type { LogEvent, LogResponse } from '../api/types'; const RETAIN = 20_000; const POLL_MS = 5_000; -const ROW_COMPACT = 30; -const ROW_TALL = 48; +/* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is + * absolutely positioned at a height this file decides, so anything the stylesheet draws + * that these figures do not account for is text clipped by a rule nobody can see from the + * CSS. Every first-line cell in `.logrow` is given exactly ROW_LINE, the secondary line + * exactly ROW_SECOND, and the padding and hairline below are the same on both heights — + * which is what makes the columns line up whether an event printed one line or two. + * Changing any figure here means changing its twin in `styles.css`. */ +const ROW_PAD = 7; +const ROW_LINE = 16; +const ROW_SECOND = 15; +const ROW_SECOND_GAP = 2; +const ROW_RULE = 1; +const ROW_COMPACT = ROW_PAD + ROW_LINE + ROW_PAD + ROW_RULE; +const ROW_TALL = ROW_COMPACT + ROW_SECOND_GAP + ROW_SECOND; const DAY_HEIGHT = 26; const HEADER_HEIGHT = 31; const OVERSCAN = 10; @@ -175,7 +194,7 @@ const LogRow = memo(function LogRow({ diff --git a/admin-ui/src/pages/Maintenance.tsx b/admin-ui/src/pages/Maintenance.tsx index 30661a6..33670de 100644 --- a/admin-ui/src/pages/Maintenance.tsx +++ b/admin-ui/src/pages/Maintenance.tsx @@ -107,7 +107,7 @@ export function MaintenancePage() { {!loading ? ( active now : quietEnabled ? scheduled : off} @@ -123,7 +123,7 @@ export function MaintenancePage() { onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }} />
- + { setQuietStart(event.target.value); setQuietTouched(true); }} /> diff --git a/admin-ui/src/pages/Notifications.tsx b/admin-ui/src/pages/Notifications.tsx new file mode 100644 index 0000000..3db0736 --- /dev/null +++ b/admin-ui/src/pages/Notifications.tsx @@ -0,0 +1,532 @@ +import { Fragment, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { query } from '../api/client'; +import { useQuery } from '../lib/hooks'; +import { num, when } from '../lib/format'; +import type { Tone } from '../lib/format'; +import { + Banner, + Bars, + Button, + Card, + EmptyRow, + Field, + Loading, + PageHead, + Segments, + TableWrap, + Tag, + Tiles, +} from '../components/ui'; +import type { + NotificationFacet, + NotificationLogEntry, + NotificationLogResponse, +} from '../api/types'; + +/* Everything Memby sent. + * + * The page exists because that question used to be unanswerable without reading three + * subsystems' log lines: each feature both decided to notify somebody and performed the + * delivery itself, so there was no one place that knew a summary had gone out, or that a + * viewer's own preferences had quietly refused it. Every producer now goes through + * internal/notify, and this is the console's window on the trail that leaves behind. + * + * Its shape follows the sign-in history's, deliberately, because an operator arrives at + * both with a *question* rather than a browsing intention — "did the weekly summary go + * out", "why did nobody hear about that import". So the filters sit above the table and + * are always visible, each control maps to one server-side filter, and the table stays + * readable on its own: the drawer is for the full body and the delivery error, never for + * working out who a row was about. */ + +interface Filters { + user: string; + kind: string; + channel: string; + status: string; + source: string; + q: string; + from: string; + to: string; +} + +const EMPTY: Filters = { + user: '', + kind: '', + channel: '', + status: '', + source: '', + q: '', + from: '', + to: '', +}; + +const WINDOWS = [ + { value: 1, label: 'Today' }, + { value: 7, label: '7 days' }, + { value: 30, label: '30 days' }, + { value: 90, label: '90 days' }, +] as const; + +const LIMIT = 100; + +/* One tone per status, and they mean what they mean everywhere else in the console: green + is the verdict, amber is look at this, red is wrong. Skipped is deliberately *not* red — + a notification a viewer's own preferences declined is Memby working correctly, and + colouring it as a fault would send an operator to fix something nobody broke. */ +const STATUS_TONE: Record = { + sent: 'ok', + delivered: 'ok', + failed: 'bad', + pending: 'warn', + skipped: 'idle', +}; + +/* The channel is the one column that says what *kind* of thing happened, so it carries a + tone of its own from the console's neutral half: a person, the household, somebody + else's service. None of the three is a judgement. */ +const CHANNEL_TONE: Record = { + 'in-app': 'note', + broadcast: 'info', + webhook: 'data', +}; + +const CHANNEL_LABEL: Record = { + 'in-app': 'In-app', + broadcast: 'Broadcast', + webhook: 'Webhook', +}; + +/** readable turns a stored slug into something an operator reads: "watch-time-week" + * becomes "Watch time week". The slug is still the filter value — this is display only, + * so a kind added tomorrow needs nothing here. */ +function readable(slug: string): string { + if (!slug) return '—'; + const spaced = slug.replace(/[-_.:]+/g, ' ').trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** options builds a dropdown from a facet list, so it can never offer a value that matches + * nothing. The count rides in the label because "failed (0)" and a missing option are + * different answers to "has anything failed". */ +function options(facets: NotificationFacet[] | undefined, label: (value: string) => string) { + return (facets ?? []).map((facet) => ( + + )); +} + +export function NotificationsPage() { + const [days, setDays] = useState(7); + const [filters, setFilters] = useState(EMPTY); + const [page, setPage] = useState(0); + const [openId, setOpenId] = useState(null); + + // An explicit `from` wins over the window, the rule the sign-in history follows: an + // operator who typed a date meant it. + const params = useMemo( + () => + query({ + ...filters, + days: filters.from ? undefined : days, + limit: LIMIT, + offset: page * LIMIT, + }), + [filters, days, page], + ); + + const log = useQuery(`/admin/api/notification-log${params}`); + const data = log.data; + + const update = (patch: Partial) => { + setFilters((current) => ({ ...current, ...patch })); + setPage(0); + // The open row belongs to the page that was on screen. Leaving it open across a filter + // change would show a record the table underneath no longer contains. + setOpenId(null); + }; + + const active = Object.values(filters).some((value) => value !== ''); + const totals = data?.totals; + const retention = data?.retentionDays ?? 90; + const shown = data?.entries.length ?? 0; + const from = !data || data.total === 0 ? 0 : page * LIMIT + 1; + + return ( + <> + + + + + {totals ? ( + 0 ? 'bad' : undefined, + }, + { + /* Skipped is on the tile row because it is the number that answers the + complaint this page is usually opened for: somebody was not told, and + Memby meant not to tell them. */ + label: 'Skipped', + value: num(totals.skipped), + icon: 'filter', + tone: 'idle', + }, + { label: 'People reached', value: num(totals.users), icon: 'people', tone: 'note' }, + { label: 'History kept', value: `${retention} days`, small: true, icon: 'clock' }, + ]} + /> + ) : null} + +
+ + ({ value: entry.value as number, label: entry.label }))} + onChange={(next) => { + setDays(next); + update({ from: '', to: '' }); + }} + /> + + + + + + + + + + + + + + + + + + update({ from: event.target.value })} + /> + + + update({ to: event.target.value })} + /> + + + update({ q: event.target.value })} + /> + +
+ {active ? ( + + ) : null} +
+
+ + {log.loading && !data ? : null} + + {data && data.days.length > 1 ? ( + + row.day} + valueOf={(row: (typeof data.days)[number]) => + row.sent + row.delivered + row.failed + row.skipped + } + toneOf={(row: (typeof data.days)[number]) => (row.failed > 0 ? 'bad' : undefined)} + title={(row: (typeof data.days)[number]) => + `${row.day}: ${row.sent + row.delivered} sent, ${row.failed} failed, ${row.skipped} skipped` + } + /> + + ) : null} + + {data ? ( + + {data.total === 0 + ? 'nothing matches' + : `${num(from)}–${num(from + shown - 1)} of ${num(data.total)}`} + + } + footer={ + data.total > LIMIT ? ( + <> + + + + ) : undefined + } + > + + + + + + + + + + + + + + + {data.entries.length === 0 ? ( + No notifications match these filters. + ) : ( + data.entries.map((entry) => ( + + setOpenId(openId === entry.id ? null : entry.id)} + /> + {openId === entry.id ? : null} + + )) + )} + +
WhenRecipientTypeTitleChannelSourceStatus +
+
+
+ ) : null} + + ); +} + +function Row({ + entry, + open, + onToggle, +}: { + entry: NotificationLogEntry; + open: boolean; + onToggle: () => void; +}) { + return ( + + {when(entry.occurredAt)} + + {entry.userId ? ( + + {entry.username || entry.userId} + + ) : entry.target ? ( + /* A webhook's row names the destination it went to. Its address is never sent to + the console — the URL is the credential — so this is the only thing that can + identify which channel a delivery landed in. */ + {entry.target} + ) : ( + /* No recipient is a real answer rather than a missing one: a service alert is + the whole household being told something. */ + Everyone + )} + + {entry.kind || '—'} + {entry.title || } + + + {CHANNEL_LABEL[entry.channel] ?? entry.channel} + + + {readable(entry.source)} + + {entry.status} + + + + + + ); +} + +/* The drawer is the whole record: the message as it was written, the delivery response, + and the context the producer attached. It is a row inside the table rather than a panel + beside it, so the record stays under the row it belongs to while an operator reads down + a filtered list. */ +function DetailRow({ entry }: { entry: NotificationLogEntry }) { + const metadata = Object.entries(entry.metadata ?? {}).filter( + ([, value]) => value !== null && value !== undefined && String(value) !== '', + ); + + return ( + + +
+
+
+

+ {CHANNEL_LABEL[entry.channel] ?? entry.channel} + + {readable(entry.source)} +

+ {entry.title || readable(entry.kind)} + {/* The failure and the reason share a line, because they are the same answer + to "why did this not arrive" — one from the provider, one from Memby. */} + {entry.detail ?

{entry.detail}

: null} +
+
+ {entry.status} +
+
+ +
+
+

Delivery

+
+
Sent
+
{when(entry.occurredAt)}
+ {entry.eventAt ? ( + <> +
Event
+
{when(entry.eventAt)}
+ + ) : null} +
Channel
+
{CHANNEL_LABEL[entry.channel] ?? entry.channel}
+
Type
+
{entry.kind || '—'}
+
Service
+
{readable(entry.source)}
+
Took
+
{entry.durationMs}ms
+
+
+ +
+

Recipient

+
+
Person
+
+ {entry.userId ? entry.username || entry.userId : 'the whole household'} +
+ {entry.target ? ( + <> +
Destination
+
{entry.target}
+ + ) : null} + {entry.itemId ? ( + <> +
Title id
+
{entry.itemId}
+ + ) : null} + {entry.sourceKey ? ( + <> + {/* The idempotency key is what explains a skip as a repeat rather than + as an unexplained gap, so it is printed rather than hidden. */} +
Source key
+
{entry.sourceKey}
+ + ) : null} +
+
+ + {metadata.length ? ( +
+

Context

+
+ {metadata.map(([key, value]) => ( + +
{readable(key)}
+
{String(value)}
+
+ ))} +
+
+ ) : null} +
+ + {entry.body ? ( +
+

Message

+

{entry.body}

+
+ ) : null} +
+ + + ); +} diff --git a/admin-ui/src/pages/Searches.tsx b/admin-ui/src/pages/Searches.tsx index 1265c1c..612104d 100644 --- a/admin-ui/src/pages/Searches.tsx +++ b/admin-ui/src/pages/Searches.tsx @@ -51,7 +51,7 @@ export function SearchesPage() { <> diff --git a/admin-ui/src/pages/Settings.tsx b/admin-ui/src/pages/Settings.tsx index e481ca3..dd0b7e5 100644 --- a/admin-ui/src/pages/Settings.tsx +++ b/admin-ui/src/pages/Settings.tsx @@ -147,7 +147,7 @@ export function SettingsPage() { >
- + {daily.length === 0 ? No home-screen visits yet. : daily.map((row) => )}
DayVisitsViewers
{row.label}{num(row.visits)}{num(row.viewers)}
- + {hourly.length === 0 ? No home-screen visits yet today. : hourly.map((row) => )}
HourVisitsViewers
{row.label}{num(row.visits)}{num(row.viewers)}
diff --git a/admin-ui/src/styles.css b/admin-ui/src/styles.css index 969a944..dfc3e7e 100644 --- a/admin-ui/src/styles.css +++ b/admin-ui/src/styles.css @@ -1881,12 +1881,19 @@ select { font: 12px/1.5 var(--sans); contain: layout paint style; } +/* Every track but the event is a fixed width, and that is the whole of why the columns + line up. A row is its own grid container — there are twenty thousand of them and no + shared table — so a `minmax()` track sized from its own content gave each row a slightly + different Service and Result column, and the Result column moved as the widest verdict + on screen changed. Fixed tracks cannot move; content inside them truncates instead. + + Vertical rhythm is set here too: cells start at the top of the row rather than being + centred in it, so the Time, Level, Service and Result of a two-line event sit on the + same line as its summary instead of dropping half a line to the middle of the pair. */ .loghead, .logrow { display: grid; - grid-template-columns: - 92px 52px minmax(150px, 190px) minmax(240px, 1fr) - minmax(96px, 150px) 68px; + grid-template-columns: 92px 52px 190px minmax(240px, 1fr) 150px 68px; gap: 12px; padding: 0 12px; } @@ -1942,15 +1949,26 @@ select { background: var(--line-soft); } +/* The figures below are the twin of the row geometry in `pages/Logs.tsx`: 7px of padding, + a 16px first line, a 2px gap and a 15px second line, over a 1px hairline. The row's + height is set inline from those constants, so a cell drawn taller than its share here is + text sliced through the middle rather than a row that grows. Every first-line cell is + therefore given `line-height: 16px` explicitly, whatever its font size. */ .logrow { position: absolute; top: 0; right: 0; left: 0; - align-items: center; + align-items: start; + padding-top: 7px; + padding-bottom: 7px; border-bottom: 1px solid rgba(255, 255, 255, .03); contain: strict; } +.logrow > *, +.logrow-line { + line-height: 16px; +} .logrow:hover { background: rgba(255, 255, 255, .035); } @@ -1971,7 +1989,7 @@ select { .logrow-time { color: var(--quiet); - font: 11px/1 var(--mono); + font: 11px/16px var(--mono); font-variant-numeric: tabular-nums; } @@ -2003,7 +2021,7 @@ select { .logrow-level { color: var(--quiet); - font: 700 10px/1 var(--sans); + font: 700 10px/16px var(--sans); letter-spacing: .06em; } .logrow-level[data-level="ERROR"] { color: var(--danger-ink); } @@ -2019,16 +2037,18 @@ select { on every other page and must not start meaning "playback" on this one. */ .logrow-place { display: flex; - align-items: baseline; + align-items: center; gap: 5px; min-width: 0; + height: 16px; + overflow: hidden; } .logrow-service { flex: 0 0 auto; max-width: 96px; overflow: hidden; color: var(--quiet); - font: 700 10px/1.4 var(--sans); + font: 700 10px/16px var(--sans); letter-spacing: .07em; text-overflow: ellipsis; text-transform: uppercase; @@ -2047,6 +2067,7 @@ select { flex: 1 1 auto; color: var(--quiet); font-size: 11.5px; + line-height: 16px; } /* The summary is the row. It is a button because selecting the row is what opens the @@ -2055,9 +2076,9 @@ select { display: flex; flex-direction: column; gap: 2px; - justify-content: center; + align-items: stretch; + justify-content: flex-start; width: 100%; - height: 100%; min-width: 0; padding: 0; overflow: hidden; @@ -2079,11 +2100,12 @@ select { align-items: baseline; gap: 8px; min-width: 0; + height: 16px; } .logrow-action { flex: 0 0 auto; color: var(--muted); - font: 600 10.5px/1.4 var(--mono); + font: 600 10.5px/16px var(--mono); letter-spacing: .04em; } .logrow-action[data-method="POST"], @@ -2098,15 +2120,13 @@ select { text-overflow: ellipsis; white-space: nowrap; } -/* The reason a failure needs no drawer. */ -.logrow-error { - overflow: hidden; - color: var(--danger-ink); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} -.logrow-context { +/* Identity on a request row, kept on the summary's own line. It gives way before the path + does — knowing which route was called matters more than which television called it, and + the drawer holds both either way. */ +.logrow-trail { + flex: 0 1 auto; + min-width: 0; + max-width: 38%; overflow: hidden; color: var(--quiet); font-size: 11px; @@ -2114,21 +2134,42 @@ select { white-space: nowrap; } +/* The second line: the reason a failure needs no drawer, or the person and position that + make an application event mean something. Exactly one line tall, 15px, which is the + figure the row was measured with — it can truncate but it can never wrap, because a wrap + is a row overflowing into the one below it. */ +.logrow-second { + height: 15px; + overflow: hidden; + color: var(--quiet); + font-size: 11px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.logrow-second[data-tone="error"] { + color: var(--danger-ink); +} + /* A result is a word, not a badge. A success is understated to the point of being ignorable — which is the correct amount of attention for the four hundredth 200 in a row — and only a failure is given a fill. */ .logrow-result { min-width: 0; + height: 16px; + overflow: hidden; } .logrow-verdict { display: inline-block; max-width: 100%; color: var(--quiet); - font: 500 11px/1.5 var(--mono); + font: 500 11px/14px var(--mono); } .logrow-verdict[data-tone="ok"] { color: var(--muted); } .logrow-verdict[data-tone="info"] { color: var(--info-ink); } .logrow-verdict[data-tone="data"] { color: var(--data-ink); } +/* A fill still has to fit the row's line box: 14px of text and 1px either side is the + 16px every other cell on the line occupies. */ .logrow-verdict[data-tone="warn"], .logrow-verdict[data-tone="bad"] { padding: 1px 6px; @@ -2146,7 +2187,7 @@ select { .logrow-duration { color: var(--quiet); - font: 11px/1 var(--mono); + font: 11px/16px var(--mono); font-variant-numeric: tabular-nums; text-align: right; } @@ -2283,7 +2324,7 @@ select { @media (max-width: 1180px) { .loghead, .logrow { - grid-template-columns: 84px 46px minmax(130px, 170px) minmax(200px, 1fr) minmax(88px, 130px); + grid-template-columns: 84px 46px 170px minmax(200px, 1fr) 130px; gap: 10px; } .loghead > :last-child, @@ -2292,13 +2333,13 @@ select { } .loghead, .logbody { - min-width: 640px; + min-width: 680px; } } @media (max-width: 900px) { .loghead, .logrow { - grid-template-columns: 46px minmax(110px, 140px) minmax(180px, 1fr) minmax(72px, 110px); + grid-template-columns: 46px 140px minmax(180px, 1fr) 110px; gap: 8px; } .loghead > :first-child, @@ -3513,3 +3554,31 @@ details summary { background: inherit; } } + +/* The notification history's detail row. + * + * The drawer is a row inside the table rather than a panel beside it, so the record stays + * under the row it belongs to while an operator reads down a filtered list. It borrows the + * log viewer's drawer vocabulary wholesale — the two answer the same shape of question and + * a second look for it would be a second thing to keep in step. */ +.detail-row > td { + padding: 0 0 12px; + background: var(--surface-sunken, transparent); +} +.logdrawer-raw h4 { + margin: 0 0 6px; + color: var(--quiet); + font: 700 10px/1 var(--sans); + letter-spacing: .08em; + text-transform: uppercase; +} +/* The message is the one thing on the page rendered as prose rather than as a field: it is + the sentence a viewer actually read, and setting it in the mono field type would make it + look like a value rather than like the notification it is. */ +.notification-body { + max-width: 70ch; + margin: 0; + color: var(--text); + font: 13px/1.6 var(--sans); + overflow-wrap: anywhere; +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 303426e..adffe75 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -63,7 +63,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.76" +val defaultVersionName = "0.2.77" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 517b083..be01e80 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -20,6 +20,7 @@ import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest import com.ponzischeme89.memby.data.model.GatewayFeatures import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.PlaybackReport +import com.ponzischeme89.memby.data.model.RadarrMovieDetail import com.ponzischeme89.memby.data.model.PlaybackInfoRequest import com.ponzischeme89.memby.data.model.MediaSourceInfo import com.ponzischeme89.memby.data.model.h264TranscodeFallback @@ -286,6 +287,32 @@ class EmbyRepository internal constructor( val showTitleLogo: Boolean get() = snapshot.showTitleLogo private val _playbackStops = MutableSharedFlow(extraBufferCapacity = 1) val playbackStops = _playbackStops.asSharedFlow() + + /** + * The position the player left a title at, published the instant it is known rather + * than once Emby has accepted the report. It is what lets the launcher show the + * progress a viewer just made on the card they are standing on, without waiting for a + * round trip that [playbackStops] triggers afterwards. + */ + private val _playbackPositions = MutableSharedFlow(extraBufferCapacity = 4) + val playbackPositions = _playbackPositions.asSharedFlow() + + /** + * Where this television last left each title. Consulted by [launchResumePositionMs], + * which explains why it has to exist: a card is only as fresh as the last home refresh, + * and neither backend re-reads Emby at launch, so without a record here a viewer who + * watched for twenty seconds and pressed Play again immediately is sent back to where + * they started. + * + * In memory and bounded, the [playableCache] arrangement: it covers the minutes between + * leaving the player and the next refresh, which is the entire window the defect lives + * in, and a process that died in between has WorkManager delivering the stop and a + * fresh set of rows to come back to. + */ + private val localResume = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > LOCAL_RESUME_CACHE_SIZE + } /** Emby session reports must arrive in order; an older progress request cannot follow Stop. */ private val playbackReportMutex = Mutex() private val playableMutex = Mutex() @@ -334,6 +361,16 @@ class EmbyRepository internal constructor( private val extrasCache = LinkedHashMap>(EXTRAS_CACHE_SIZE, 0.75f, true) private val extrasInFlight = mutableMapOf?>>() + private val radarrMovieMutex = Mutex() + /** + * The Radarr-only detail pages this session has opened. Small, because it is bounded by + * how many upcoming films a household is tracking, and worth keeping because walking + * Back and pressing the same card again is the ordinary way somebody browses a shelf of + * things that are not out yet. + */ + private val radarrMovieCache = + LinkedHashMap(RADARR_MOVIE_CACHE_SIZE, 0.75f, true) + private val radarrMovieInFlight = mutableMapOf>() fun cachedHome(): HomeCache? = settings.homeCache(snapshot) @@ -433,6 +470,7 @@ class EmbyRepository internal constructor( ): String { clearPlayableCache() clearSeriesEpisodeCache() + clearLocalResume() settings.ensureDeviceId() observedSettings = settings.snapshot() // pick up the freshly-generated device id @@ -524,6 +562,7 @@ class EmbyRepository internal constructor( cachedBaseUrl = null clearPlayableCache() clearSeriesEpisodeCache() + clearLocalResume() } suspend fun signOut() { @@ -538,6 +577,7 @@ class EmbyRepository internal constructor( cachedBaseUrl = null clearPlayableCache() clearSeriesEpisodeCache() + clearLocalResume() } suspend fun switchProfile(profile: EmbyProfile) { @@ -547,6 +587,7 @@ class EmbyRepository internal constructor( cachedBaseUrl = null clearPlayableCache() clearSeriesEpisodeCache() + clearLocalResume() } suspend fun removeProfile(profile: EmbyProfile) { @@ -563,6 +604,7 @@ class EmbyRepository internal constructor( cachedBaseUrl = null clearPlayableCache() clearSeriesEpisodeCache() + clearLocalResume() } } @@ -1122,6 +1164,17 @@ class EmbyRepository internal constructor( if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read") } + /** + * Puts a notification back to new. + * + * Its own route rather than a flag on [markNotificationRead], because the two are not the + * same kind of event: read is set by the page focusing a row, unread is only ever somebody + * pressing the toggle on it. + */ + suspend fun markNotificationUnread(id: Long) { + if (ServerConfig.isGateway) requireGateway().updateNotification(id, "unread") + } + suspend fun dismissNotification(id: Long) { if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss") } @@ -1528,6 +1581,9 @@ class EmbyRepository internal constructor( /** Sets watched state explicitly and returns the value confirmed by Emby. */ suspend fun setPlayed(itemId: String, played: Boolean): Boolean { + // Watched or unwatched, this decides the title's position from outside playback, so + // whatever the player last recorded no longer describes anything. + forgetLocalResume(itemId) if (ServerConfig.isGateway) { return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played } @@ -1543,6 +1599,8 @@ class EmbyRepository internal constructor( /** Removes a title from Continue Watching without changing its watched state. */ suspend fun removeFromContinueWatching(itemId: String) { + // Taking a title off the shelf is a statement that its playhead no longer matters. + forgetLocalResume(itemId) if (ServerConfig.isGateway) { requireGateway().hideFromResume(itemId) return @@ -1735,6 +1793,56 @@ class EmbyRepository internal constructor( return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull()) } + /** + * The Radarr-only page for a film the household is tracking and has no copy of. + * + * Single-flighted on the repository's own scope like [getExtras], for the same reason: + * the caller is a page that can be left before its answer lands, and a request tied to + * the caller would be abandoned by exactly the navigation about to want it back. + * + * Never throws. There is no gateway on the direct path, an older container answers 404 + * and a film Radarr has since forgotten answers nothing — all three are "no page", and + * the card stays where it was rather than opening onto an error. + * + * The **successful** answer is cached and a failure is not, so one bad minute does not + * leave a card inert for the session. It is deliberately not refreshed while it is + * held: the facts on it — a release date, a certificate, a studio — move on the scale of + * weeks, and the one that does not is [RadarrMovieDetail.embyItemId], which the home + * row's own refresh reports first anyway. + */ + suspend fun getRadarrMovie(itemId: String): RadarrMovieDetail? { + if (itemId.isBlank() || !ServerConfig.isGateway) return null + val inFlight = radarrMovieMutex.withLock { + radarrMovieCache[itemId]?.let { return it } + radarrMovieInFlight[itemId] ?: newRadarrMovieRequest(itemId) + } + return inFlight.await() + } + + private fun newRadarrMovieRequest(itemId: String): Deferred { + val request = scope.async(start = CoroutineStart.LAZY) { + try { + val loaded = runCatching { requireGateway().radarrMovie(itemId) }.getOrNull() + ?: return@async null + radarrMovieMutex.withLock { + radarrMovieCache[itemId] = loaded + while (radarrMovieCache.size > RADARR_MOVIE_CACHE_SIZE) { + radarrMovieCache.entries.iterator().run { + next() + remove() + } + } + loaded + } + } finally { + radarrMovieMutex.withLock { radarrMovieInFlight.remove(itemId) } + } + } + radarrMovieInFlight[itemId] = request + request.start() + return request + } + /** * A title's extras: trailers, featurettes, deleted scenes, behind-the-scenes material. * @@ -1910,7 +2018,14 @@ class EmbyRepository internal constructor( else -> null }, isSeries = item.isSeries, - resumePositionMs = item.resumePositionMs, + // The card is only as fresh as the last home refresh, so the ledger corrects it + // here — at the one funnel every launch passes through, which is also what the + // player is opened with and what the gateway is given as its resume hint. + resumePositionMs = launchResumePositionMs( + resolvedPositionMs = 0L, + requestedPositionMs = item.resumePositionMs, + localPositionMs = localResumePositionMs(item.id), + ), logoUrl = logoUrl(item), overview = item.overview, episodeCode = episodeCode(item), @@ -1937,6 +2052,7 @@ class EmbyRepository internal constructor( resumePositionMs = launchResumePositionMs( resolvedPositionMs = entry.playable.resumePositionMs, requestedPositionMs = request.resumePositionMs, + localPositionMs = localResumePositionMs(request.itemId), ), ) } finally { @@ -1970,6 +2086,7 @@ class EmbyRepository internal constructor( resumePositionMs = launchResumePositionMs( resolvedPositionMs = cached.resumePositionMs, requestedPositionMs = request.resumePositionMs, + localPositionMs = localResumePositionMs(request.itemId), ), ) } @@ -1984,6 +2101,7 @@ class EmbyRepository internal constructor( resumePositionMs = launchResumePositionMs( resolvedPositionMs = resolved.resumePositionMs, requestedPositionMs = request.resumePositionMs, + localPositionMs = localResumePositionMs(request.itemId), ), ).also { playableMutex.withLock { playableCache.remove(request.itemId) } @@ -2273,6 +2391,11 @@ class EmbyRepository internal constructor( /** Drop negotiated playback state when a catalogue refresh can change episode selection. */ suspend fun invalidatePlaybackPrefetch() = clearPlayableCache() + /** + * Deliberately does not touch [localResume]: a playback stop clears this cache, and the + * whole point of the ledger is to outlive that and answer the launch that follows. + * Session changes call [clearLocalResume] beside this one. + */ private suspend fun clearPlayableCache() { playableMutex.withLock { playableCache.clear() @@ -2313,6 +2436,62 @@ class EmbyRepository internal constructor( extrasInFlight.values.forEach { it.cancel() } extrasInFlight.clear() } + radarrMovieMutex.withLock { + radarrMovieCache.clear() + radarrMovieInFlight.values.forEach { it.cancel() } + radarrMovieInFlight.clear() + } + } + + /** + * Remembers where the player left [itemId], or forgets it where the title was finished + * — a completed title has its position reset by the server, so a record kept past that + * would send somebody back into the closing minutes of something they had deliberately + * started again. + * + * Called before the report goes out rather than after it lands, because the failure this + * exists for is a viewer pressing Play again inside the second or two the round trip + * takes, and a record written on success would not be there yet. + */ + private fun recordLocalResume(itemId: String, positionMs: Long, durationMs: Long) { + if (itemId.isBlank()) return + synchronized(localResume) { + if (positionMs <= 0L || playbackCompletesItem(positionMs, durationMs)) { + localResume.remove(itemId) + } else { + localResume[itemId] = LocalResume(positionMs, System.currentTimeMillis()) + } + } + } + + /** The remembered playhead for [itemId], or zero where there is nothing current to say. */ + private fun localResumePositionMs(itemId: String): Long { + if (itemId.isBlank()) return 0L + val now = System.currentTimeMillis() + return synchronized(localResume) { + val entry = localResume[itemId] ?: return@synchronized 0L + if (isFreshLocalResume(entry.recordedAtMs, now)) { + entry.positionMs + } else { + localResume.remove(itemId) + 0L + } + } + } + + /** + * Drops the remembered playhead for [itemId]. Marking a title watched or unwatched sets + * its position from outside playback entirely, so a record made before that decision has + * nothing left to describe. + */ + private fun forgetLocalResume(itemId: String) { + if (itemId.isBlank()) return + synchronized(localResume) { localResume.remove(itemId) } + } + + /** Another viewer's playheads are not this one's; cleared wherever the session changes. */ + private fun clearLocalResume() { + synchronized(localResume) { localResume.clear() } } suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) { @@ -2332,6 +2511,10 @@ class EmbyRepository internal constructor( eventName: String, durationMs: Long = 0L, ): String? = playbackReportMutex.withLock { + // The heartbeat is not what the resume point depends on any more, but it is free + // evidence: a process killed between two reports still leaves the ledger describing + // a position within ten seconds of the truth. + recordLocalResume(session.itemId, positionMs, durationMs) if (ServerConfig.isGateway) { requireGateway().report( "progress", @@ -2345,13 +2528,24 @@ class EmbyRepository internal constructor( } } - suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) { + suspend fun reportPlaybackStopped( + session: PlaybackSession, + positionMs: Long, + durationMs: Long = 0L, + ) { + // Recorded and published before the report is even attempted. Everything after this + // line can fail, be retried by WorkManager or simply be slower than the viewer, and + // the position they left at is still the one the next launch starts from. + publishFinalPosition(session.itemId, positionMs, durationMs) try { playbackReportMutex.withLock { if (ServerConfig.isGateway) { // Stopping is also what drops the gateway's cached rows for this user, // so Continue Watching reflects the new position on the next home load. - requireGateway().report("stopped", session.gatewayReport(positionMs, true, null)) + requireGateway().report( + "stopped", + session.gatewayReport(positionMs, true, null, durationMs), + ) } else { requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null)) } @@ -2364,13 +2558,31 @@ class EmbyRepository internal constructor( } } + /** + * The final playhead, taken as authoritative the moment the player reports it: written + * to the local ledger and announced to the launcher. It is idempotent, which matters + * because the durable WorkManager fallback replays the same stop. + */ + private fun publishFinalPosition(itemId: String, positionMs: Long, durationMs: Long) { + if (itemId.isBlank()) return + recordLocalResume(itemId, positionMs, durationMs) + _playbackPositions.tryEmit( + PlaybackPosition(itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)), + ) + } + fun enqueuePlaybackStopped( session: PlaybackSession, positionMs: Long, + durationMs: Long = 0L, onSuccess: () -> Unit = {}, ) { + // Synchronously, before the coroutine is even scheduled: leaving the player and + // pressing Play again is a couple of hundred milliseconds, and the ledger has to be + // right by then rather than whenever the dispatcher gets round to it. + publishFinalPosition(session.itemId, positionMs, durationMs) scope.launch { - runCatching { reportPlaybackStopped(session, positionMs) } + runCatching { reportPlaybackStopped(session, positionMs, durationMs) } .onSuccess { onSuccess() } } } @@ -3066,6 +3278,22 @@ data class PlaybackSession( val playMethod: String = "DirectPlay", ) +/** Where the player left a title, and when this set recorded that. */ +internal data class LocalResume(val positionMs: Long, val recordedAtMs: Long) + +/** + * A playhead this television is sure of, published as it leaves the player so the launcher + * can move the card's progress bar without waiting on the server. + */ +data class PlaybackPosition( + val itemId: String, + val positionMs: Long, + /** Zero where the runtime was not known; only a positive value can say a title finished. */ + val durationMs: Long = 0L, +) { + val completed: Boolean get() = playbackCompletesItem(positionMs, durationMs) +} + private data class PlaybackDiscovery( val subtitles: List = emptyList(), val mediaSourceId: String = "", @@ -3205,6 +3433,9 @@ private const val CONTINUE_PLAY_LOOKBACK = 120 */ private const val TRICKPLAY_CACHE_SIZE = 12 +/** Enough titles to cover an evening's browsing; the record only has to outlive one refresh. */ +private const val LOCAL_RESUME_CACHE_SIZE = 32 + // A season's worth, so working through one show in an evening never asks twice. private const val INTRO_CACHE_SIZE = 24 @@ -3240,6 +3471,7 @@ private const val RELATED_LIMIT = 12 */ private const val TRAILER_CACHE_SIZE = 64 private const val EXTRAS_CACHE_SIZE = 64 +private const val RADARR_MOVIE_CACHE_SIZE = 32 private const val MAX_TRAILER_CANDIDATES = 12 // Long enough that walking back and forth between a row and a detail page never re-asks, @@ -3252,9 +3484,61 @@ internal fun millisecondsToTicks(milliseconds: Long): Long = internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean = resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS -/** A positive position on the pressed card outranks an older prefetched zero. */ -internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long = - if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L) +/** Emby's own rule for a finished title; a stop past it resets the position rather than saving it. */ +internal const val PLAYBACK_COMPLETION_FRACTION = 0.9 + +/** How long the television argues with a stale card before deferring to the server again. */ +internal const val LOCAL_RESUME_MAX_AGE_MS = 12L * 60L * 60L * 1_000L + +/** + * Where a launch starts from. + * + * The television's own record of where it last left this title outranks both of the + * others, and that is the whole of the fix for a short session losing its progress. + * Neither of the other two can be trusted to be current: [requestedPositionMs] is read + * off the card, which is only as fresh as the last home refresh, and [resolvedPositionMs] + * is no better, because the direct path resolves nothing at all and the gateway accepts + * the client's position as a hint rather than reading Emby again. So the position the + * player left at has to be remembered here, or a viewer who exits and presses Play again + * before the refresh lands is sent back to where they were before they watched. + * + * It is a **greatest**, never simply a preference, which is what retires the record with + * no bookkeeping at all: once a refresh brings the card back carrying that position — or a + * further one, watched on another set — the card is at least as current and the local + * record can no longer change the answer. + */ +internal fun launchResumePositionMs( + resolvedPositionMs: Long, + requestedPositionMs: Long, + localPositionMs: Long = 0L, +): Long { + val known = maxOf(requestedPositionMs, localPositionMs) + return if (known > 0L) known else resolvedPositionMs.coerceAtLeast(0L) +} + +/** + * Whether a title stopped at [positionMs] has been finished, in which case there is no + * resume point worth remembering: the server resets a completed title's position, and a + * record kept past that would drop somebody back into the closing minutes of something + * they had chosen to watch again from the start. + */ +internal fun playbackCompletesItem( + positionMs: Long, + durationMs: Long, + completedFraction: Double = PLAYBACK_COMPLETION_FRACTION, +): Boolean = durationMs > 0L && positionMs >= (durationMs * completedFraction).toLong() + +/** + * Whether a locally recorded resume point may still speak for a title. The backstop is + * deliberately generous — the record retires itself as soon as a refreshed card catches up + * with it — but not unbounded, or a set that recorded a position and was then left alone + * for a week would still be arguing with the server about it. + */ +internal fun isFreshLocalResume( + recordedAtMs: Long, + nowMs: Long, + maxAgeMs: Long = LOCAL_RESUME_MAX_AGE_MS, +): Boolean = recordedAtMs in 1L..nowMs && nowMs - recordedAtMs <= maxAgeMs private val BaseItem.resumePositionMs: Long get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index aafa392..cc0dd54 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -304,6 +304,8 @@ data class Settings( val username: String? = null, /** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */ val profileInitials: String = "", + /** Admin-defined friendly name for the launcher's greeting; blank uses [username]. */ + val shortName: String = "", val deviceId: String = "", val deviceName: String = "", val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS, @@ -479,6 +481,7 @@ data class EmbyProfile( val userId: String, val username: String, val profileInitials: String = "", + val shortName: String = "", val serverId: String? = null, val homeCacheJson: String? = null, val forYouMinutes: Int = 0, @@ -574,6 +577,7 @@ class SettingsStore(private val context: Context) { val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows") val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style") val PROFILE_INITIALS = stringPreferencesKey("profile_initials") + val SHORT_NAME = stringPreferencesKey("short_name") val THEME_ID = stringPreferencesKey("theme_id") val THEME_PALETTE = stringPreferencesKey("theme_palette") val THEME_ICON_SET = stringPreferencesKey("theme_icon_set") @@ -806,6 +810,7 @@ class SettingsStore(private val context: Context) { context.dataStore.edit { store -> store[Keys.HOME_SECTIONS] = sections store[Keys.PROFILE_INITIALS] = preferences.profileInitials + store[Keys.SHORT_NAME] = preferences.shortName store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata @@ -830,6 +835,7 @@ class SettingsStore(private val context: Context) { updateActiveProfile(store) { it.copy( profileInitials = preferences.profileInitials, + shortName = preferences.shortName, homeSections = sections, homeCardDensity = preferences.homeCardDensity, homeArtworkStyle = preferences.homeArtworkStyle, @@ -1080,10 +1086,18 @@ class SettingsStore(private val context: Context) { } } - suspend fun markUpdateAlertRead() { + /** + * The local update notice's read flag, both ways. + * + * This alert is the one row on the Notifications page the gateway knows nothing about — + * it is a property of the APK on *this* set — so its seen toggle has to be written here + * rather than posted. Guarded on the version still being recorded: a flag left behind by + * an alert somebody has already dismissed describes nothing. + */ + suspend fun setUpdateAlertRead(read: Boolean) { context.dataStore.edit { preferences -> if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) { - preferences[Keys.UPDATE_ALERT_READ] = true + preferences[Keys.UPDATE_ALERT_READ] = read } } } @@ -1246,6 +1260,7 @@ class SettingsStore(private val context: Context) { userId = userId, username = username, profileInitials = previous?.profileInitials.orEmpty(), + shortName = previous?.shortName.orEmpty(), serverId = serverId, homeCacheJson = previous?.homeCacheJson, forYouMinutes = previous?.forYouMinutes ?: 0, @@ -1367,6 +1382,7 @@ class SettingsStore(private val context: Context) { // this TV believe it had already synced settings it has never seen. preferences.remove(Keys.PREFERENCES_REVISION) preferences.remove(Keys.PROFILE_INITIALS) + preferences.remove(Keys.SHORT_NAME) preferences.remove(Keys.USERNAME) } @@ -1376,6 +1392,7 @@ class SettingsStore(private val context: Context) { preferences[Keys.USER_ID] = profile.userId preferences[Keys.USERNAME] = profile.username preferences[Keys.PROFILE_INITIALS] = profile.profileInitials + preferences[Keys.SHORT_NAME] = profile.shortName if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID) else preferences[Keys.SERVER_ID] = profile.serverId // Prefer the profile's dedicated cache key; fall back to a copy embedded in the @@ -1442,6 +1459,7 @@ class SettingsStore(private val context: Context) { userId = userId, username = username, profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(), + shortName = preferences[Keys.SHORT_NAME].orEmpty(), serverId = preferences[Keys.SERVER_ID], homeCacheJson = activeHomeCache(preferences), forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, @@ -1491,6 +1509,7 @@ class SettingsStore(private val context: Context) { serverId = preferences[Keys.SERVER_ID], username = preferences[Keys.USERNAME], profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(), + shortName = preferences[Keys.SHORT_NAME].orEmpty(), deviceId = preferences[Keys.DEVICE_ID].orEmpty(), deviceName = preferences[Keys.DEVICE_NAME].orEmpty(), rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 2c0c8ff..0f3ee76 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -25,6 +25,12 @@ import kotlinx.serialization.json.putJsonArray data class UserPreferences( /** Admin-defined avatar text; blank keeps the name-derived fallback. */ val profileInitials: String = "", + /** + * The friendly name the launcher greets this person by — "Matt" for an account called + * MattCohen. It is not a second username: nothing is keyed on it and nothing signs in + * with it, so blank is the ordinary state and the account name stands in. + */ + val shortName: String = "", val homeSections: List = DEFAULT_SECTIONS, val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY, val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, @@ -78,6 +84,7 @@ data class UserPreferences( */ fun Settings.toUserPreferences(): UserPreferences = UserPreferences( profileInitials = profileInitials, + shortName = shortName, homeSections = homeSections.decodeCommaList(), homeCardDensity = homeCardDensity, homeArtworkStyle = homeArtworkStyle, @@ -120,6 +127,7 @@ fun decodeUserPreferences( fallback: UserPreferences = UserPreferences(), ): UserPreferences = UserPreferences( profileInitials = json.string("profileInitials", fallback.profileInitials), + shortName = json.string("shortName", fallback.shortName), homeSections = json.stringList("homeSections", fallback.homeSections) .ifEmpty { fallback.homeSections }, homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity), @@ -155,6 +163,7 @@ fun decodeUserPreferences( /** The document as the gateway expects it. The server normalises whatever arrives. */ fun UserPreferences.encode(): JsonObject = buildJsonObject { put("profileInitials", profileInitials) + put("shortName", shortName) putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } } put("homeCardDensity", homeCardDensity) put("homeArtworkStyle", homeArtworkStyle) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index d1300c6..02856f0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -457,6 +457,10 @@ data class BaseItem( // The Emby series a schedule card stands for, when the library holds it. Absent for a // show Sonarr follows but Emby has never imported, so the card stays informational. @SerialName("MembySeriesItemId") val membySeriesItemId: String? = null, + // The Emby film a movie-schedule card stands for, when the library holds it. Absent for + // a film Radarr is tracking but Emby has never imported, which is what sends the card to + // the Radarr-only detail page instead of to the ordinary one. + @SerialName("MembyMovieItemId") val membyMovieItemId: String? = null, // Derived by the TV from the weekly schedule row and retained in the local home cache. @SerialName("MembyAiringToday") val membyAiringToday: Boolean = false, // Explainability supplied only by the gateway's dedicated For You endpoint. @@ -491,6 +495,14 @@ data class BaseItem( val isMovieSchedule: Boolean get() = membySource == "radarr" val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule + /** + * A film Radarr is tracking that Emby has no copy of, which is the one card in the app + * with a page of its own rather than an Emby one. The moment the library imports it the + * gateway attaches [membyMovieItemId] and this is false, so a title stops being a + * Radarr card without anything having to be invalidated. + */ + val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank() + /** * Whether more episodes are expected. Sonarr's answer wins where the gateway attached * one, since it knows about a season announced but not yet imported; Emby's own 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 5a9968a..a0d8d5c 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 @@ -417,6 +417,59 @@ data class GatewayActiveHero( val rows: List = emptyList(), ) +/** + * A film Radarr is tracking that Emby has never imported. + * + * Deliberately not a [BaseItem]. There is no Emby record behind it, no user data, and + * nothing to play, so dressing it as one would put a Play button, a watched tick and a + * progress bar on a page where all three would be lies. When Emby does hold the film, + * [embyItemId] arrives and the television opens the ordinary detail page instead. + * + * Every word on it is the gateway's — the state treatment, the expected-release wording and + * the date labels alike — the arrangement the schedule cards and lifecycle tags already + * take, so a phrasing invented on the server next month reads correctly here. + */ +@Serializable +data class RadarrMovieDetail( + val id: String = "", + val title: String = "", + val originalTitle: String = "", + val overview: String = "", + val year: Int = 0, + val runtimeMinutes: Int = 0, + val genres: List = emptyList(), + val studio: String = "", + val certificate: String = "", + val monitored: Boolean = false, + val lifecycle: String = "", + val lifecycleText: String = "", + val stateLabel: String = "", + val stateDetail: String = "", + val expectedLabel: String = "", + val releaseDates: List = emptyList(), + val availabilityNotice: String = "", + /** + * Whether to offer Trailer at all. Decided by the gateway, which is what knows whether + * there is a candidate to resolve — a button that fails after being pressed is the one + * outcome this page must not produce. + */ + val trailerAvailable: Boolean = false, + val ratings: List = emptyList(), + /** + * Emby's own id for the film, once the library holds it. Its arrival is what retires + * this page for that title, with nothing to invalidate on either side. + */ + val embyItemId: String = "", +) + +/** One of Radarr's three dates: `kind` is a lookup key, `label` and `value` are prose. */ +@Serializable +data class RadarrReleaseDate( + val kind: String = "", + val label: String = "", + val value: String = "", +) + /** Normalised third-party rating shared by cards, banners, and detail pages. */ @Serializable data class MediaRating( diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index a91abc2..130d422 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -220,6 +220,18 @@ interface GatewayApi { @GET("v1/people/{id}/filmography") suspend fun personFilmography(@Path("id") personId: String): GatewayItems + /** + * Everything the Radarr-only detail page draws, for a film the household is tracking but + * has no copy of. Its own route rather than `v1/items/{id}` because there is no Emby + * item to ask about: the answer is Radarr's catalogue, the ratings store and whether + * Emby has since imported it. A gateway that predates the route answers 404, which the + * repository reads as "no page to open" rather than as an error. + */ + @GET("v1/radarr/movies/{id}") + suspend fun radarrMovie( + @Path("id") movieId: String, + ): com.ponzischeme89.memby.data.model.RadarrMovieDetail + /** Optional, server-filtered external movie ratings. Empty is always a valid result. */ @GET("v1/items/{id}/ratings") suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings 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 cf5e8be..653c21e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -1027,6 +1027,21 @@ private fun SkeletonBlock(width: Dp, height: Dp) { ) } +/** + * One entry in the long-press menu. + * + * The menu is built as a list rather than as a fixed sequence of blocks with hand-written + * indices, because what belongs on it depends on the card: a film Radarr is tracking has no + * Emby record to favourite or mark watched, and it has a trailer where an ordinary card + * does not. Deriving the focus indices from the list is what keeps that from being four + * pieces of arithmetic to hold in step. + */ +private data class QuickAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, +) + @Composable fun MediaQuickActionsOverlay( item: BaseItem, @@ -1034,6 +1049,13 @@ fun MediaQuickActionsOverlay( onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, onRemoveFromContinueWatching: (() -> Unit)? = null, + /** + * Offered from the row for a film that is not in the library yet, which is the one card + * whose trailer is the only thing there is to play. Pressing the card itself still opens + * its page — a long press is where a shortcut belongs, not where the ordinary action is + * replaced by it. + */ + onPlayTrailer: (() -> Unit)? = null, rowTitle: String? = null, rowPinned: Boolean = false, onToggleRowPinned: (() -> Unit)? = null, @@ -1045,10 +1067,56 @@ fun MediaQuickActionsOverlay( onToggleRowPinned != null && onHideRow != null && onMoveRow != null - val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0 - val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) + - (if (hasRowActions) 4 else 0) - val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } } + // Library state belongs to an Emby item. A card standing for a film the household does + // not hold has none, and a "Mark watched" that answers 404 is worse than no entry. + val hasLibraryActions = !item.isRadarrOnly + val itemActions = buildList { + add(QuickAction("View details", MembyIcon.Info.mark) { onOpenDetails(item) }) + onPlayTrailer?.let { play -> + add(QuickAction("Play trailer", MembyIcon.Movie.mark) { play() }) + } + if (hasLibraryActions) { + add( + QuickAction( + label = if (item.isFavorite) "Remove from favourites" else "Add to favourites", + icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, + ) { + onSetFavorite(item, !item.isFavorite) + onClose() + }, + ) + add( + QuickAction( + label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", + icon = MembyIcon.CheckCircle.mark, + ) { + onSetPlayed(item, item.userData?.played != true) + onClose() + }, + ) + } + onRemoveFromContinueWatching?.let { + add(QuickAction("Remove from Continue Watching", MembyIcon.PlaylistRemove.mark, it)) + } + } + val rowActions = if (!hasRowActions) { + emptyList() + } else { + listOf( + QuickAction( + label = if (rowPinned) "Unpin row" else "Pin row to top", + icon = MembyIcon.Pin.mark, + onClick = onToggleRowPinned!!, + ), + QuickAction("Move row up", MembyIcon.ArrowUp.mark) { onMoveRow!!(-1) }, + QuickAction("Move row down", MembyIcon.ArrowDown.mark) { onMoveRow!!(1) }, + QuickAction("Hide this row", MembyIcon.HideWatched.mark, onHideRow!!), + ) + } + val actions = itemActions + rowActions + + QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose) + val actionCount = actions.size + val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } } var focusedIndex by remember(item.id) { mutableStateOf(0) } // The menu can appear while OK is still physically held. Until that opening press // is released, consume all activation events so it cannot trigger the first action. @@ -1127,118 +1195,53 @@ fun MediaQuickActionsOverlay( overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp), ) - QuickActionMenuItem( - label = "View details", - icon = MembyIcon.Info.mark, - modifier = Modifier - .focusRequester(focusRequesters[0]) - .onFocusChanged { if (it.isFocused) focusedIndex = 0 }, - onClick = { onOpenDetails(item) }, - ) - Spacer(Modifier.height(2.dp)) - QuickActionMenuItem( - label = if (item.isFavorite) "Remove from favourites" else "Add to favourites", - icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, - modifier = Modifier - .focusRequester(focusRequesters[1]) - .onFocusChanged { if (it.isFocused) focusedIndex = 1 }, - onClick = { - onSetFavorite(item, !item.isFavorite) - onClose() - }, - ) - Spacer(Modifier.height(2.dp)) - QuickActionMenuItem( - label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", - icon = MembyIcon.CheckCircle.mark, - modifier = Modifier - .focusRequester(focusRequesters[2]) - .onFocusChanged { if (it.isFocused) focusedIndex = 2 }, - onClick = { - onSetPlayed(item, item.userData?.played != true) - onClose() - }, - ) - if (onRemoveFromContinueWatching != null) { - Spacer(Modifier.height(2.dp)) + actions.forEachIndexed { index, action -> + // The row actions are about the shelf rather than about the title, and + // Close is about neither, so each is introduced by its own rule. + when { + rowActions.isNotEmpty() && index == itemActions.size -> { + Spacer(Modifier.height(6.dp)) + QuickActionDivider() + Text( + rowTitle.orEmpty(), + color = QuietText, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp), + ) + } + index == actionCount - 1 -> { + Spacer(Modifier.height(6.dp)) + QuickActionDivider() + Spacer(Modifier.height(6.dp)) + } + index > 0 -> Spacer(Modifier.height(2.dp)) + } QuickActionMenuItem( - label = "Remove from Continue Watching", - icon = MembyIcon.PlaylistRemove.mark, + label = action.label, + icon = action.icon, modifier = Modifier - .focusRequester(focusRequesters[3]) - .onFocusChanged { if (it.isFocused) focusedIndex = 3 }, - onClick = onRemoveFromContinueWatching, + .focusRequester(focusRequesters[index]) + .onFocusChanged { if (it.isFocused) focusedIndex = index }, + onClick = action.onClick, ) } - if (hasRowActions) { - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(Color.White.copy(alpha = 0.07f)), - ) - Text( - rowTitle.orEmpty(), - color = QuietText, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp), - ) - QuickActionMenuItem( - label = if (rowPinned) "Unpin row" else "Pin row to top", - icon = MembyIcon.Pin.mark, - modifier = Modifier - .focusRequester(focusRequesters[rowActionStartIndex]) - .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex }, - onClick = onToggleRowPinned!!, - ) - QuickActionMenuItem( - label = "Move row up", - icon = MembyIcon.ArrowUp.mark, - modifier = Modifier - .focusRequester(focusRequesters[rowActionStartIndex + 1]) - .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 }, - onClick = { onMoveRow!!(-1) }, - ) - QuickActionMenuItem( - label = "Move row down", - icon = MembyIcon.ArrowDown.mark, - modifier = Modifier - .focusRequester(focusRequesters[rowActionStartIndex + 2]) - .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 }, - onClick = { onMoveRow!!(1) }, - ) - QuickActionMenuItem( - label = "Hide this row", - icon = MembyIcon.HideWatched.mark, - modifier = Modifier - .focusRequester(focusRequesters[rowActionStartIndex + 3]) - .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 }, - onClick = onHideRow!!, - ) - } - Spacer(Modifier.height(6.dp)) - Box( - Modifier - .fillMaxWidth() - .height(1.dp) - .background(Color.White.copy(alpha = 0.07f)), - ) - Spacer(Modifier.height(6.dp)) - QuickActionMenuItem( - label = "Close", - icon = MembyIcon.ChevronLeft.mark, - modifier = Modifier - .focusRequester(focusRequesters[actionCount - 1]) - .onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 }, - onClick = onClose, - ) } } } +@Composable +private fun QuickActionDivider() { + Box( + Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color.White.copy(alpha = 0.07f)), + ) +} + @Composable private fun QuickActionMenuItem( label: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt index dffcf6a..9e94304 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt @@ -24,6 +24,19 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour * It still goes away further down the launcher: by then somebody is looking for something * to watch rather than being welcomed. */ +/** + * The name Memby addresses somebody by, on the launcher and anywhere else it speaks to + * them directly. + * + * The short name is an operator's answer and wins where there is one, because it is the + * only one of the two a person actually chose to be called. Everything else falls back to + * [friendlyProfileName]'s reading of the account name, so a household that has never set + * one is greeted exactly as it was before — a blank or whitespace-only value is the + * ordinary state, not a name. + */ +internal fun greetingName(shortName: String?, username: String?): String? = + shortName?.trim()?.takeIf(String::isNotEmpty) ?: friendlyProfileName(username) + internal fun shouldShowHomeGreeting( hasHero: Boolean, focusedRowId: String?, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index ec5f3d0..4591b99 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.HomeSnapshot +import com.ponzischeme89.memby.data.PlaybackPosition import com.ponzischeme89.memby.data.analytics.RowAnalytics import com.ponzischeme89.memby.data.analytics.JourneyAnalytics import com.ponzischeme89.memby.data.analytics.JourneySink import com.ponzischeme89.memby.data.analytics.JourneyTracker import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.isMaintenanceError +import com.ponzischeme89.memby.data.millisecondsToTicks import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.UserItemData @@ -181,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { init { refreshAll() + viewModelScope.launch { + // Arrives as the player exits, ahead of the report and well ahead of the rows + // coming back, so the card a viewer is standing on already shows the progress + // they just made rather than the progress bar they left home with. + repository.playbackPositions.collect(::applyPlaybackPosition) + } viewModelScope.launch { repository.playbackStops.collect { refreshWatching() } } @@ -423,6 +431,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { * of the request. */ private suspend fun warmDetailPage(item: BaseItem) { + // A movie-schedule card whose film is not in the library opens a page of its own, + // and that page is one request. Warming it here is what makes it open on the press + // rather than a moment after it, and it is the only warm a schedule card has any + // use for — there is no Emby item behind it to fetch anything else about. + if (item.isRadarrOnly) { + runCatching { repository.getRadarrMovie(item.id) } + return + } if (item.isSchedule) return // Focus settling on a playable card is the best warning of a Play press this app // gets. Opening the connection to Emby now means the press pays for bytes rather @@ -556,6 +572,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } + /** + * Moves a card's playhead to where the player just left it, before anything is asked of + * the server. [refreshWatching] follows and replaces this with the server's own answer; + * this is what stands in for it in the meantime, which is exactly the window a viewer + * who exits and presses Play again is inside. + * + * A completed title is left alone rather than pushed to its own end: what happens to it + * is that it leaves Continue Watching, which is the refresh's answer to give. + */ + private fun applyPlaybackPosition(position: PlaybackPosition) { + if (position.itemId.isBlank() || position.completed) return + val ticks = millisecondsToTicks(position.positionMs) + updateUserData(position.itemId) { + // Never backwards: a stop and the ten-second report before it can arrive in + // either order, and the card must not step back to the earlier of the two. + if (ticks > it.playbackPositionTicks) it.copy(playbackPositionTicks = ticks) else it + } + viewModelScope.launch { persistCurrentHome() } + } + private suspend fun refreshWatching() { refreshMutex.withLock { _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) } 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 7f1fccd..9901739 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -140,6 +140,7 @@ import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig import com.ponzischeme89.memby.ui.alerts.MyAlertsPage import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.airingNoticeFor +import com.ponzischeme89.memby.ui.detail.scheduleMovieStub import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub import com.ponzischeme89.memby.ui.calendar.CalendarScreen import com.ponzischeme89.memby.ui.requests.RequestsScreen @@ -1942,6 +1943,10 @@ private fun HomeScreen( // same series reached from Favourites or a search never claims a schedule. var detailsAiringNotice by remember { mutableStateOf(null) } var quickMenuItem by remember { mutableStateOf(null) } + // Whether the long-press menu may offer a trailer for the Radarr card it is open on. + // Asked once, when the menu opens, and false until answered: a row entry that appears + // and then fails is worse than one that arrives a moment late. + var quickMenuTrailerAvailable by remember { mutableStateOf(false) } var quickMenuRowId by remember { mutableStateOf(null) } var focusedHomeRowId by remember { mutableStateOf(null) } var sectionHeroRows by remember(settings.userId) { @@ -3216,13 +3221,22 @@ private fun HomeScreen( // the viewer asked for is the show — carrying the air // time across, since that is why they pressed it. val seriesStub = scheduleSeriesStub(item) + // A movie-schedule card whose film Emby has since + // imported is the ordinary movie page; one whose film + // is still only Radarr's opens its own. Neither is + // inert, which is what the card used to be. + val movieStub = scheduleMovieStub(item) if (seriesStub != null) { detailsAiringNotice = airingNoticeFor(item) homeViewModel.focusItem(seriesStub) detailsItem = seriesStub + } else if (movieStub != null) { + detailsAiringNotice = null + homeViewModel.focusItem(movieStub) + detailsItem = movieStub } else { homeViewModel.focusItem(item) - if (item.membyPlayable) { + if (item.membyPlayable || item.isRadarrOnly) { detailsAiringNotice = null detailsItem = item } @@ -3233,7 +3247,13 @@ private fun HomeScreen( returnRowKind = row.kind.name returnItemId = item.id homeViewModel.focusItem(item) - if (item.membyPlayable) { + if (item.membyPlayable || item.isRadarrOnly) { + // Cleared here rather than only in the effect that + // answers it: the effect runs after the menu's + // first frame, and the previous card's answer + // showing on it would be an entry that appears and + // then vanishes. + quickMenuTrailerAvailable = false quickMenuRowId = row.id quickMenuItem = item } @@ -3269,6 +3289,7 @@ private fun HomeScreen( HomeClock( showGreeting = showHomeGreeting, username = settings.username, + shortName = settings.shortName, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 24.dp, bottom = 18.dp), @@ -3524,6 +3545,14 @@ private fun HomeScreen( // and should not: it was news, and it has been read. detailsAiringNotice = null }, + onOpenEmbyItem = { embyItem -> + // Not a step in the trail: the Radarr page and the Emby page are two + // answers about one title, so Back from here still belongs where the + // card was pressed rather than on the page it replaced. + homeViewModel.focusItem(embyItem) + detailsAiringNotice = null + detailsItem = embyItem + }, onPlay = { // Kept, not discarded: this is what the viewer comes back to when the // film ends or they press Back out of the player. @@ -3780,61 +3809,50 @@ private fun HomeScreen( notificationsLoading = false } }, - onToggleEnabled = { - if (notificationsMutationBusy) return@MyAlertsPage - notificationsMutationBusy = true - scope.launch { - val updated = notificationState.preferences.copy( - enabled = !notificationState.preferences.enabled, - ) - runCatching { repo.setNotificationPreferences(updated) } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsMutationBusy = false - } - }, - onToggleShowReturns = { - if (notificationsMutationBusy) return@MyAlertsPage - notificationsMutationBusy = true - scope.launch { - val updated = notificationState.preferences.copy( - showReturnAlerts = !notificationState.preferences.showReturnAlerts, - ) - runCatching { repo.setNotificationPreferences(updated) } - .onSuccess { notificationState = it } - .onFailure { notificationsError = friendlyEmbyError(it) } - notificationsMutationBusy = false - } - }, - // Marked read locally first. This fires on *focus*, so on a slow - // connection walking down the list and back up would send the same row's - // request once per pass — clearing the flag immediately is what makes the - // row stop asking. - onRead = onRead@{ notification -> + // The seen toggle, and the only thing that moves a row between Inbox and + // Seen — nothing is marked read merely by being looked at any more, because + // with the two halves split that would empty the Inbox under the remote. + // + // Optimistic and reversed on failure, like the dismissal below: the flag is + // the only thing that changed, so a row that sat unmoved while its request + // was in flight is one pressed a second time. The locally-held update notice + // has no server row to post, so its flag is written to this television's own + // settings instead — it is a fact about the APK on this set. + onToggleSeen = onToggleSeen@{ notification -> + val markingSeen = notification.unread if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { - scope.launch { ServiceLocator.settings.markUpdateAlertRead() } - return@onRead + scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) } + return@onToggleSeen } val previousReadAt = notification.readAt notificationState = notificationState.copy( notifications = notificationState.notifications.map { - if (it.id == notification.id) it.copy(readAt = "now") else it + if (it.id == notification.id) { + it.copy(readAt = if (markingSeen) "now" else null) + } else { + it + } }, ) scope.launch { - runCatching { repo.markNotificationRead(notification.id) } - .onFailure { failure -> - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) { - it.copy(readAt = previousReadAt) - } else { - it - } - }, - ) - notificationsError = friendlyEmbyError(failure) + runCatching { + if (markingSeen) { + repo.markNotificationRead(notification.id) + } else { + repo.markNotificationUnread(notification.id) } + }.onFailure { failure -> + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) { + it.copy(readAt = previousReadAt) + } else { + it + } + }, + ) + notificationsError = friendlyEmbyError(failure) + } } }, // Optimistic, for the reason "Dismiss all" beneath it already is: this @@ -3868,17 +3886,28 @@ private fun HomeScreen( // list is emptied optimistically: the page is judged on emptying itself, // and a row that lingered while its request was in flight would be pressed // a second time. - onDismissAll = { + onDismissAll = { pending -> if (notificationsMutationBusy) return@MyAlertsPage notificationsMutationBusy = true val previous = notificationState - val pending = notificationState.notifications.map(UserNotification::id) - val dismissLocalUpdate = settings.updateAlertVersion != null - notificationState = notificationState.copy(notifications = emptyList()) + // Only the half on screen. The page dismisses what it is showing, so + // emptying Seen must not also throw away an Inbox the viewer has not + // read — a bulk action nobody can see the extent of is one nobody presses. + val pendingIds = pending.map(UserNotification::id).toSet() + val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds && + settings.updateAlertVersion != null + notificationState = notificationState.copy( + notifications = notificationState.notifications.filterNot { + it.id in pendingIds + }, + ) scope.launch { if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() - val failed = pending.filter { id -> - runCatching { repo.dismissNotification(id) }.isFailure + // The local update notice has no server row, so asking the gateway to + // dismiss it would be one guaranteed failure per pass. + val failed = pendingIds.filter { id -> + id != MEMBY_UPDATE_NOTIFICATION_ID && + runCatching { repo.dismissNotification(id) }.isFailure } runCatching { repo.getNotifications() } .onSuccess { notificationState = it } @@ -3896,6 +3925,10 @@ private fun HomeScreen( ) } quickMenuItem?.let { selected -> + LaunchedEffect(selected.id) { + quickMenuTrailerAvailable = selected.isRadarrOnly && + repo.getRadarrMovie(selected.id)?.trailerAvailable == true + } val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> quickMenuItem = null quickMenuRowId = null @@ -3925,6 +3958,18 @@ private fun HomeScreen( }, onSetFavorite = homeViewModel::setFavorite, onSetPlayed = homeViewModel::setPlayed, + // Only for a film with no page to play from, and only when the gateway has + // a candidate to resolve — the same answer the detail page's button waits + // for, so the two can never disagree about whether there is a trailer. + onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) { + { + quickMenuItem = null + quickMenuRowId = null + playTrailer(selected) + } + } else { + null + }, onRemoveFromContinueWatching = if ( rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE ) { @@ -4021,6 +4066,7 @@ private fun HomeScreen( !settings.hasOpenedForYou && liveMaintenance == null, username = settings.username, + shortName = settings.shortName, modifier = Modifier.align(Alignment.TopCenter), ) // Emby has stopped answering. Persistent, unlike the news bar below it, because @@ -4170,6 +4216,7 @@ private fun RecentSearchesRow( private fun HomeClock( showGreeting: Boolean, username: String?, + shortName: String?, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -4189,7 +4236,7 @@ private fun HomeClock( val period = homeGreetingPeriod( Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY), ) - val name = friendlyProfileName(username) + val name = greetingName(shortName, username) Row( modifier = modifier, horizontalArrangement = Arrangement.End, @@ -4353,11 +4400,27 @@ private fun FocusedDetailsOverlay( onTogglePlayed: (BaseItem, Boolean) -> Unit, onClose: () -> Unit, onOpenItem: (BaseItem) -> Unit, + /** + * Where a Radarr card goes once Emby has imported the film. The row is cached for the + * day on the gateway, so the card can still arrive without an Emby id long after the + * import; the detail request is what notices, and this is what acts on it. + */ + onOpenEmbyItem: (BaseItem) -> Unit = {}, airingNotice: AiringNotice? = null, ) { val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val item = focusedItem?.takeIf { it.id == selected.id } ?: selected - if (item.isSeries) { + if (item.isRadarrOnly) { + // A film Radarr is tracking that Emby has never imported. It is the one card with a + // page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why + // it is not the movie page with its playable parts taken away. + RadarrMovieDetailsOverlay( + card = item, + onPlayTrailer = onPlayTrailer, + onClose = onClose, + onOpenEmbyItem = onOpenEmbyItem, + ) + } else if (item.isSeries) { SeriesDetailsOverlay( item = item, onPlay = onPlay, @@ -4405,6 +4468,7 @@ private fun FocusedQuickActionsOverlay( onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, onRemoveFromContinueWatching: (() -> Unit)?, + onPlayTrailer: (() -> Unit)?, rowTitle: String?, rowPinned: Boolean, onToggleRowPinned: (() -> Unit)?, @@ -4419,6 +4483,7 @@ private fun FocusedQuickActionsOverlay( onSetFavorite = onSetFavorite, onSetPlayed = onSetPlayed, onRemoveFromContinueWatching = onRemoveFromContinueWatching, + onPlayTrailer = onPlayTrailer, rowTitle = rowTitle, rowPinned = rowPinned, onToggleRowPinned = onToggleRowPinned, @@ -4777,9 +4842,12 @@ private fun ForYouTimeBudget( private fun ForYouNudgeBanner( visible: Boolean, username: String?, + shortName: String?, modifier: Modifier = Modifier, ) { - val name = friendlyProfileName(username) + // The same name the hero greeting uses: two places addressing one person by two + // different names is worse than neither of them being personalised. + val name = greetingName(shortName, username) androidx.compose.animation.AnimatedVisibility( visible = visible, enter = androidx.compose.animation.fadeIn(tween(220)), 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 c731115..e51d377 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MembyButtons.kt @@ -38,6 +38,7 @@ import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOutline /** * One green Play button, in two sizes. @@ -153,6 +154,45 @@ private fun PrimaryActionSurface( } } +/** + * The quiet counterpart to [MembyPlayButton]: an action that is available but is not what + * the screen is for. It is an outline rather than a fill, so the primary action stays the + * only green thing on the page and the two are told apart at three metres. + */ +@Composable +internal fun MembySecondaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus") + val shape = RoundedCornerShape(MembyCardCorner) + Box( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } + .clip(shape) + .background(if (focused) MembyOutline else Color.Transparent) + .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .padding( + horizontal = if (compact) 14.dp else 23.dp, + vertical = if (compact) 8.dp else 13.dp, + ), + contentAlignment = Alignment.Center, + ) { + Text( + label, + color = if (focused) Color.White else MembyMutedText, + fontSize = if (compact) 13.sp else 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + /** * A selectable chip for a small set of mutually exclusive choices. The tick is a real icon * on the selected chip rather than a character in the label, so the chip does not change diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt new file mode 100644 index 0000000..bed6e94 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/RadarrMovieDetailsOverlay.kt @@ -0,0 +1,377 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.RadarrMovieDetail +import com.ponzischeme89.memby.ui.detail.formatRuntime +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyAccentMuted +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembyControlSurface +import com.ponzischeme89.memby.ui.theme.MembyHairline +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.ValueSeparator +import kotlinx.coroutines.delay + +/** + * The page for a film Radarr is tracking that Emby has no copy of. + * + * It is its own page rather than the movie page with the playable parts removed, because + * the two answer different questions. The ordinary page's whole shape — Play, a progress + * bar, watched state, tabs of cast and extras and similar titles — is built around a file + * that exists, and none of it is true here. This one answers "when can I watch this, and + * what is it", which is three facts and a trailer, so it is one screen with no tabs and + * nothing to scroll. + * + * It also never manufactures an Emby item to get here. [card] is the schedule card exactly + * as the row received it — used for artwork and for the title before the request lands, so + * the page appears immediately — and everything else is [RadarrMovieDetail], which is a + * separate type on purpose. When Emby does import the film, + * [RadarrMovieDetail.embyItemId] arrives and [onOpenEmbyItem] takes the viewer to the + * ordinary page instead, with nothing on either side needing to be invalidated. + */ +@Composable +fun RadarrMovieDetailsOverlay( + card: BaseItem, + onPlayTrailer: (BaseItem) -> Unit, + onClose: () -> Unit, + onOpenEmbyItem: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + var detail by remember(card.id) { mutableStateOf(null) } + LaunchedEffect(card.id) { + detail = ServiceLocator.repository.getRadarrMovie(card.id) + } + // The row is cached for the day on the gateway, so a film imported since it was built + // still arrives here wearing no Emby id. The detail answer is live, and it is the one + // that gets the viewer to the page they actually wanted. + val embyItem = remember(card.id, detail?.embyItemId) { + detail?.let { radarrEmbyStub(card, it) } + } + LaunchedEffect(embyItem?.id) { + embyItem?.let(onOpenEmbyItem) + } + if (embyItem != null) return + RadarrMovieDetailContent( + card = card, + detail = detail, + onPlayTrailer = { onPlayTrailer(card) }, + onClose = onClose, + modifier = modifier, + ) +} + +/** + * The layout, with everything it draws as a parameter, so it can be screenshotted without a + * gateway behind it. [detail] is null while the request is still in flight — the page draws + * the artwork and the title it already has rather than a spinner, because the card that was + * pressed is most of what the viewer came to look at. + */ +@Composable +internal fun RadarrMovieDetailContent( + card: BaseItem, + detail: RadarrMovieDetail?, + onPlayTrailer: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val repository = ServiceLocator.repository + val poster = remember(card.id, card.imageTags) { repository.primaryUrl(card, maxWidth = 500) } + val title = detail?.title?.takeIf(String::isNotBlank) ?: card.name + val facts = remember(detail, card.productionYear, card.runTimeTicks) { + radarrMovieFacts(card, detail) + } + val back = remember(card.id) { FocusRequester() } + val trailer = remember(card.id) { FocusRequester() } + // Trailer takes the focus when there is one — it is the only thing on this page anybody + // came to press. Back is what claims it otherwise, so a page with no trailer still has + // somewhere for the remote to be. + val trailerOffered = detail?.trailerAvailable == true + LaunchedEffect(card.id, trailerOffered) { + delay(32L) + runCatching { if (trailerOffered) trailer.requestFocus() else back.requestFocus() } + } + + Box(modifier.fillMaxSize()) { + DetailBackdrop(card, Modifier.fillMaxSize()) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = RadarrPageGutter, vertical = 46.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadarrPoster(poster, title) + Spacer(Modifier.width(38.dp)) + Column(Modifier.weight(1f)) { + RadarrStatusRow(detail) + Spacer(Modifier.height(14.dp)) + Text( + text = title, + color = Color.White, + fontSize = 40.sp, + lineHeight = 44.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + detail?.originalTitle?.takeIf(String::isNotBlank)?.let { original -> + Spacer(Modifier.height(4.dp)) + Text(original, color = MembyQuietText, fontSize = 14.sp, maxLines = 1) + } + if (facts.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + DetailFactRow(facts) + } + detail?.genres?.filter(String::isNotBlank)?.takeIf(List::isNotEmpty) + ?.let { genres -> + Spacer(Modifier.height(8.dp)) + Text( + text = genres.take(4).joinToString(ValueSeparator), + color = MembyMutedText, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings -> + Spacer(Modifier.height(12.dp)) + RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f)) + } + Spacer(Modifier.height(16.dp)) + RadarrReleaseBand(detail) + val overview = detail?.overview?.takeIf(String::isNotBlank) + ?: card.overview?.takeIf(String::isNotBlank) + if (overview != null) { + Spacer(Modifier.height(16.dp)) + Text( + text = overview, + color = MembyMutedText, + fontSize = 15.sp, + lineHeight = 22.sp, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.86f), + ) + } + detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates -> + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + dates.forEach { date -> + Column { + Text( + text = date.label.uppercase(), + color = MembyQuietText, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.2.sp, + maxLines = 1, + ) + Spacer(Modifier.height(3.dp)) + Text( + text = date.value, + color = MembyOnSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } + } + } + Spacer(Modifier.height(26.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + // Offered only when the gateway has a candidate to resolve. A Trailer + // button that fails after being pressed is the one outcome this page + // must not produce, and the answer is known before it is drawn. + if (trailerOffered) { + MembyPlayButton( + label = "Play trailer", + onClick = onPlayTrailer, + onFocused = {}, + modifier = Modifier.focusRequester(trailer), + ) + } + MembySecondaryButton( + label = "Back", + onClick = onClose, + modifier = Modifier.focusRequester(back), + ) + } + } + } + } +} + +/** The gutter is the detail pages'; this page sits in the same column as the others. */ +private val RadarrPageGutter = DetailSideGutter + +private val RadarrPosterWidth = 236.dp + +@Composable +private fun RadarrPoster(url: String?, title: String) { + val shape = RoundedCornerShape(MembyCardCorner) + Box( + Modifier + .width(RadarrPosterWidth) + .aspectRatio(2f / 3f) + .clip(shape) + .background(MembyControlSurface) + .border(1.dp, MembyHairline, shape), + ) { + if (url != null) { + AsyncImage( + model = url, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +/** + * The status treatment: what this film is doing, and Radarr's own word for where it is in + * its life. Both are the gateway's wording — nothing here is derived on the television, so + * a phrasing added on the server next month reads correctly on this build. + */ +@Composable +private fun RadarrStatusRow(detail: RadarrMovieDetail?) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = detail?.stateLabel?.takeIf(String::isNotBlank)?.uppercase() ?: "NOT IN MEMBY", + color = MembyAccent, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.8.sp, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(MembyChipCorner)) + .background(MembyAccentMuted) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) + val lifecycle = detail?.lifecycleText?.takeIf(String::isNotBlank) + if (lifecycle != null) { + Spacer(Modifier.width(8.dp)) + LifecycleBadge(detail.lifecycle, lifecycle) + } + // What the state means for somebody who wanted to watch this tonight. It belongs + // beside the word it explains rather than under the date, which answers "when". + val stateDetail = detail?.stateDetail?.takeIf(String::isNotBlank) + if (stateDetail != null) { + Spacer(Modifier.width(10.dp)) + Text( + text = stateDetail, + color = MembyQuietText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * The one date the page leads with, and under it the sentence saying this cannot be watched + * here yet — which is the whole reason somebody is on this page rather than the other one. + * + * The band carries the notice and not [RadarrMovieDetail.stateDetail], which sits with the + * status treatment it explains: the two are one sentence apart on an unannounced film + * ("Release date not yet announced" over "Release date not yet announced"), and a page that + * says a thing twice reads as one that has lost track of what it has said. + */ +@Composable +private fun RadarrReleaseBand(detail: RadarrMovieDetail?) { + val expected = detail?.expectedLabel?.takeIf(String::isNotBlank) ?: return + val notice = detail.availabilityNotice.takeIf(String::isNotBlank).orEmpty() + Column( + Modifier + .clip(RoundedCornerShape(MembyChipCorner)) + .background(MembyControlSurface) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = expected, + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (notice.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text(notice, color = MembyMutedText, fontSize = 13.sp, maxLines = 2) + } + } +} + +/** + * The hero fact line: year, runtime, certificate, studio. + * + * Radarr's answer wins over the card's where it has one, since the card carries only what + * the schedule row needed, but the card is what is there on the opening frame — so the line + * is drawn from whichever of the two knows, rather than waiting for the request. + */ +/** + * The ordinary Emby page this card should have opened, once the gateway reports that the + * library holds the film — or null while it does not, which is the whole of this page's + * reason to exist. Pure, so the one decision that retires this page is testable. + */ +internal fun radarrEmbyStub(card: BaseItem, detail: RadarrMovieDetail): BaseItem? { + val embyItemId = detail.embyItemId.trim().takeIf(String::isNotEmpty) ?: return null + return BaseItem( + id = embyItemId, + name = detail.title.takeIf(String::isNotBlank) ?: card.name, + type = "Movie", + genres = detail.genres.ifEmpty { card.genres }, + productionYear = detail.year.takeIf { it > 0 } ?: card.productionYear, + ) +} + +internal fun radarrMovieFacts(card: BaseItem, detail: RadarrMovieDetail?): List = + buildList { + val year = detail?.year?.takeIf { it > 0 } ?: card.productionYear?.takeIf { it > 0 } + year?.let { add(it.toString()) } + val runtime = detail?.runtimeMinutes?.takeIf { it > 0 } ?: card.runtimeMinutes + runtime?.takeIf { it > 0 }?.let { add(formatRuntime(it)) } + detail?.certificate?.takeIf(String::isNotBlank)?.let(::add) + detail?.studio?.takeIf(String::isNotBlank)?.let(::add) + } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt index c3447ee..679998d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt @@ -1,5 +1,7 @@ package com.ponzischeme89.memby.ui.alerts +import com.ponzischeme89.memby.data.model.UserNotification + /** * The wording and the counting behind Notifications, kept pure so the badge a viewer sees in the * user picker and the summary line on the page itself are the same arithmetic tested once. @@ -32,3 +34,39 @@ internal fun alertsSummary(total: Int, unread: Int): String = when { "$notifications · $unread new" } } + +/** + * The two halves of the page: what is waiting, and what has been dealt with. + * + * The split is what replaced the on/off switch. A viewer who could turn notifications off was + * being offered a way to make the page permanently useless — and the reason to reach for it + * was that a list mixing new news with everything already read never emptied. Two named + * halves with their counts on them is the same relief without the off switch: the Inbox is + * the short list somebody has to do something about, and Seen is where it goes. + * + * Membership is the *read* flag and nothing else, which is what lets both counts and both + * panes be derived from one list the caller already holds — there is no third state to keep + * in step, and dismissing a row still removes it from the page entirely. + */ +enum class AlertsTab(val label: String) { INBOX("Inbox"), SEEN("Seen") } + +/** The alerts [tab] holds, in the order the caller gave them. */ +internal fun alertsForTab( + tab: AlertsTab, + notifications: List, +): List = notifications.filter { (tab == AlertsTab.INBOX) == it.unread } + +/** Above this a tab states the cap rather than widening past the tab beside it. */ +internal const val AlertTabCountMax = 99 + +/** + * The count printed on a tab. + * + * Zero is printed rather than hidden: a tab whose count disappeared when it emptied would + * read as a tab that had failed to count, and "Seen 0" is a useful thing to be told. + */ +internal fun alertTabCountLabel(count: Int): String = when { + count <= 0 -> "0" + count > AlertTabCountMax -> "$AlertTabCountMax+" + else -> count.toString() +} 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 e3bef06..765590d 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset @@ -61,10 +62,10 @@ import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.NotificationPreferences import com.ponzischeme89.memby.data.model.UserNotification -import com.ponzischeme89.memby.ui.MembyChoiceChip import com.ponzischeme89.memby.ui.formatMyShowDate import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyQuietText @@ -81,14 +82,26 @@ import kotlinx.coroutines.delay * drawn on Home, and cost a focus target on every set in the house whether or not there was * anything behind it. * - * It reads like Settings on purpose — black canvas, flat rows on a shared 16dp inset with - * hairlines between them, and the row under focus the only lit surface on the page. + * It is laid out as My Requests is — the marked heading, a tab strip under it, one pane at a + * time — because the two pages answer the same shape of question about a person's own list, + * and a household should not have to learn two of them. + * + * **There is no off switch.** Turning notifications off was the page offering a way to make + * itself permanently useless, and the reason to reach for it was that a single list mixing + * new news with everything already read never emptied. [AlertsTab] is the answer instead: + * Inbox is the short list to do something about, Seen is where it goes, and both wear their + * count so a viewer can see from the strip whether there is anything to open. The stored + * preferences are still honoured — they are just no longer the viewer's to switch from here, + * which is why the empty state still says so when nothing is arriving. * * Two things are worth preserving. **A press dismisses**, with the focused row saying so, and * the hint is what makes that safe: this is the only page whose whole job is emptying itself, * and a second confirmation press on every alert is what made the old panel not worth - * opening. And **focus marks read** — a row can only be read by being looked at, so nothing - * has to be pressed to clear the "new" flag on it. + * opening. And **nothing moves under the remote by being looked at** — focus used to mark a + * row read, which was harmless while the list was one list and would now empty the Inbox + * merely by somebody scrolling it. Seen is a state a viewer puts a row into, with the toggle + * beside it, and focus lands back on that toggle afterwards so a run of them is a run of one + * press. * * Stateless by design: the caller owns the list and the requests, so this can be previewed * and screenshotted with no server. @@ -100,48 +113,74 @@ fun MyAlertsPage( loading: Boolean = false, errorMessage: String? = null, onRetry: () -> Unit = {}, - onToggleEnabled: () -> Unit, - onToggleShowReturns: () -> Unit, - onRead: (UserNotification) -> Unit, + onToggleSeen: (UserNotification) -> Unit = {}, onDismiss: (UserNotification) -> Unit, - onDismissAll: () -> Unit, + onDismissAll: (List) -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier, ) { - val actionsFocusRequester = remember { FocusRequester() } - val notificationIds = notifications.map(UserNotification::id) - val rowFocusRequesters = remember(notificationIds) { - List(notificationIds.size) { FocusRequester() } - } + var tab by remember { mutableStateOf(AlertsTab.INBOX) } + var page by remember { mutableStateOf(0) } + val inbox = remember(notifications) { alertsForTab(AlertsTab.INBOX, notifications) } + val seen = remember(notifications) { alertsForTab(AlertsTab.SEEN, notifications) } + val tabNotifications = if (tab == AlertsTab.INBOX) inbox else seen + val pageCount = alertPageCount(tabNotifications.size) + // Derived rather than only corrected in an effect: an effect runs after the frame, so a + // list that shrank under a viewer standing on the last page would draw one empty frame + // before the correction landed. [page] is written back below to keep the two in step. + val safePage = alertPageAfterChange(page, tabNotifications.size) + val pageNotifications = alertPageItems(tabNotifications, safePage) + val pageIds = pageNotifications.map(UserNotification::id) + // Two requesters per row, because a row holds two focus targets and which of them a list + // change should land on depends on what the viewer just pressed — see [pendingFocusToggle]. + val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } } + val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } } + val tabsFocusRequester = remember { FocusRequester() } val listState = rememberLazyListState() var pendingFocusIndex by remember { mutableStateOf(null) } - val hasAlerts = notifications.isNotEmpty() - LaunchedEffect(Unit) { - // One frame for the list to place its first row; an empty page has nothing below - // the actions to land on, so the chips take the remote instead. + var pendingFocusToggle by remember { mutableStateOf(false) } + var pendingFocusPage by remember { mutableStateOf(0) } + val hasAlerts = tabNotifications.isNotEmpty() + LaunchedEffect(safePage) { page = safePage } + // Opening the page, and every tab press after it. A tab press moves focus into the pane it + // opened, the stance My Requests takes: the strip is what Back returns to, so leaving + // focus on it would cost a press before anything could be read. + LaunchedEffect(tab) { + // One frame for the list to place its first row; an empty pane has nothing below the + // strip to land on, so the strip keeps the remote instead. delay(16) runCatching { - if (hasAlerts) rowFocusRequesters.first().requestFocus() else actionsFocusRequester.requestFocus() + val first = rowFocusRequesters.firstOrNull() + if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus() } } - LaunchedEffect(notificationIds) { - if (notifications.isEmpty()) { + LaunchedEffect(pageIds) { + if (!hasAlerts) { pendingFocusIndex = null delay(16) - runCatching { actionsFocusRequester.requestFocus() } + runCatching { tabsFocusRequester.requestFocus() } return@LaunchedEffect } val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect + val wantsToggle = pendingFocusToggle // Spent on this list change however it turns out. Left set, a request that could // not be honoured — an empty list, a dismissal the server refused and put back — // would be honoured against the *next* change instead, which is commonly an alert // arriving on its own: focus would jump for a press made minutes ago. pendingFocusIndex = null - val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size) - ?: return@LaunchedEffect + val targetIndex = if (safePage != pendingFocusPage) { + // The last row of a page went, so the pager stepped back one. The row the eye is + // already nearest is the bottom of the page now on screen, not its top. + pageIds.lastIndex.takeIf { it >= 0 } + } else { + alertFocusIndexAfterRemoval(requestedIndex, pageIds.size) + } ?: return@LaunchedEffect runCatching { listState.scrollToItem(targetIndex) } delay(16) - runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() } + runCatching { + val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters + targets.getOrNull(targetIndex)?.requestFocus() + } } Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) { @@ -149,69 +188,98 @@ fun MyAlertsPage( modifier = Modifier .fillMaxSize() .padding(horizontal = 56.dp) - .padding(top = 40.dp, bottom = 28.dp), + .padding(top = 32.dp, bottom = 24.dp), ) { - AlertsHeader( - total = notifications.size, - unread = notifications.count(UserNotification::unread), - ) - Spacer(Modifier.height(20.dp)) + AlertsHeader(total = notifications.size, unread = inbox.size) + Spacer(Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth().focusGroup(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - MembyChoiceChip( - label = if (preferences.enabled) "Notifications on" else "Notifications off", - selected = preferences.enabled, - onClick = onToggleEnabled, - modifier = Modifier.focusRequester(actionsFocusRequester), - ) - MembyChoiceChip( - label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off", - selected = preferences.enabled && preferences.showReturnAlerts, - onClick = { if (preferences.enabled) onToggleShowReturns() }, - ) - Spacer(Modifier.width(1.dp)) + AlertsTab.entries.forEach { entry -> + AlertsTabChip( + tab = entry, + selected = entry == tab, + count = if (entry == AlertsTab.INBOX) inbox.size else seen.size, + // Both anchors hang off the selected tab rather than off a fixed + // index: it is where the page opens and where Back returns to. + focusRequester = tabsFocusRequester.takeIf { entry == tab }, + // Only ever pointed at a row that is actually placed this frame. + paneFocusRequester = rowFocusRequesters.firstOrNull(), + onClick = { + if (entry != tab) { + page = 0 + pendingFocusIndex = null + tab = entry + } + }, + ) + } + Spacer(Modifier.weight(1f)) if (hasAlerts) { - MembyChoiceChip( + AlertsPillButton( label = "Dismiss all", - selected = false, - onClick = onDismissAll, + icon = MembyIcon.PlaylistRemove.mark, + // What this pane is showing, not the whole page: emptying Seen must + // not take an unread Inbox with it. + onClick = { onDismissAll(tabNotifications) }, ) } if (errorMessage != null) { - MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry) + AlertsPillButton( + label = "Try again", + icon = MembyIcon.Refresh.mark, + onClick = onRetry, + ) } - Spacer(Modifier.weight(1f)) - MembyChoiceChip(label = "Close", selected = false, onClick = onClose) + AlertsPillButton( + label = "Close", + icon = MembyIcon.Close.mark, + onClick = onClose, + ) } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(14.dp)) Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline)) - if (loading && !hasAlerts) { + if (loading && notifications.isEmpty()) { AlertsNotice("Loading notifications…") - } else if (errorMessage != null && !hasAlerts) { + } else if (errorMessage != null && notifications.isEmpty()) { AlertsNotice(errorMessage) } else if (!hasAlerts) { - AlertsEmptyState(enabled = preferences.enabled) + AlertsEmptyState(tab = tab, listening = preferences.enabled) } else { LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f), contentPadding = PaddingValues(vertical = 6.dp), ) { - itemsIndexed(notifications, key = { _, notification -> notification.id }) { + itemsIndexed(pageNotifications, key = { _, notification -> notification.id }) { index, notification -> AlertRow( notification = notification, - modifier = Modifier.focusRequester(rowFocusRequesters[index]), - onFocused = { if (notification.unread) onRead(notification) }, - onClick = { + focusRequester = rowFocusRequesters[index], + toggleFocusRequester = toggleFocusRequesters[index], + // Up out of the top row reaches the strip. Only the first row + // states it; the rest are found by the ordinary focus search. + upFocusRequester = tabsFocusRequester.takeIf { index == 0 }, + onToggleSeen = { + // The row leaves this pane for the other one, so it needs the + // same re-aim a dismissal does — landing on the toggle rather + // than the body, or a run of "Mark as seen" presses would put + // the remote on something that dismisses. pendingFocusIndex = index + pendingFocusToggle = true + pendingFocusPage = safePage + onToggleSeen(notification) + }, + onDismiss = { + pendingFocusIndex = index + pendingFocusToggle = false + pendingFocusPage = safePage onDismiss(notification) }, ) - if (notification.id != notifications.last().id) { + if (notification.id != pageNotifications.last().id) { Box( Modifier .fillMaxWidth() @@ -222,11 +290,188 @@ fun MyAlertsPage( } } } + if (pageCount > 1) { + AlertsPager( + page = safePage, + pageCount = pageCount, + onPrevious = { page = (safePage - 1).coerceAtLeast(0) }, + onNext = { page = (safePage + 1).coerceAtMost(pageCount - 1) }, + ) + } } } } } +/** + * One tab, wearing its count. + * + * The count is the whole reason the strip is worth its band: a viewer can see from here + * whether the Inbox has anything in it without opening it, which is the question they came to + * the page with. Focus is *not* selection — pressing a tab moves the remote into the pane it + * opened, so following the D-pad across the strip would throw somebody out of the list they + * were reading on the way to Close. + */ +@Composable +private fun AlertsTabChip( + tab: AlertsTab, + selected: Boolean, + count: Int, + focusRequester: FocusRequester?, + paneFocusRequester: FocusRequester?, + onClick: () -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(MembyChipCorner) + Row( + modifier = Modifier + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .focusProperties { if (paneFocusRequester != null) down = paneFocusRequester } + .clip(shape) + .background( + when { + focused -> Color.White + selected -> MembyAccent.copy(alpha = 0.18f) + else -> Color.White.copy(alpha = 0.05f) + }, + ) + .border( + 1.dp, + when { + focused -> Color.Transparent + selected -> MembyAccent.copy(alpha = 0.55f) + else -> MembyHairline + }, + shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .semantics { contentDescription = "${tab.label}, $count" } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Icon( + if (tab == AlertsTab.INBOX) MembyIcon.Inbox.mark else MembyIcon.CheckCircle.mark, + contentDescription = null, + tint = if (focused) MembySurface else MembyAccent, + modifier = Modifier.size(15.dp), + ) + Text( + tab.label, + color = if (focused) MembySurface else Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Text( + alertTabCountLabel(count), + color = if (focused) MembySurface else MembyQuietText, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (focused) Color.Black.copy(alpha = 0.10f) else Color.White.copy(alpha = 0.08f), + ) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + } +} +@Composable +private fun AlertsPager( + page: Int, + pageCount: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp, start = 16.dp, end = 16.dp) + .focusGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(AlertsPagerSlotWidth), contentAlignment = Alignment.CenterStart) { + if (page > 0) { + AlertsPillButton( + label = "Previous", + icon = MembyIcon.ChevronLeft.mark, + onClick = onPrevious, + ) + } + } + Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { + Text( + alertPageLabel(page, pageCount), + color = MembyQuietText, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.6.sp, + maxLines = 1, + ) + } + Box(Modifier.width(AlertsPagerSlotWidth), contentAlignment = Alignment.CenterEnd) { + if (page < pageCount - 1) { + AlertsPillButton( + label = "Next", + icon = MembyIcon.ChevronRight.mark, + iconLeading = false, + onClick = onNext, + ) + } + } + } +} + +/** Wide enough for "Previous" and its mark, so neither end of the pager reflows. */ +private val AlertsPagerSlotWidth = 122.dp + +/** + * The small focusable control this page uses for both pager arrows and the seen toggle on a + * row. One button language rather than two: they sit within a few centimetres of each other, + * and a viewer travelling between them by remote should not be able to tell they were written + * on different days. + */ +@Composable +private fun AlertsPillButton( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + iconLeading: Boolean = true, + accented: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(MembyChipCorner) + val content = when { + focused -> Color.Black + accented -> MembyAccent + else -> MembyMutedText + } + Row( + modifier = modifier + .clip(shape) + .background(if (focused) Color.White else Color.White.copy(alpha = 0.07f)) + .border(1.dp, if (focused) Color.Transparent else MembyHairline, shape) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .semantics { contentDescription = label } + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (iconLeading) { + Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(14.dp)) + } + Text(label, color = content, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + if (!iconLeading) { + Icon(icon, contentDescription = null, tint = content, modifier = Modifier.size(14.dp)) + } + } +} + @Composable private fun AlertsNotice(message: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -238,33 +483,68 @@ private fun AlertsNotice(message: String) { internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) +/** + * The marked heading My Requests wears, so the two pages a person opens about their own list + * are recognisably the same page. The summary sits at the end of the row rather than under + * the title: it is a caption for the whole page, and the strip below it already accounts for + * each half. + */ @Composable private fun AlertsHeader(total: Int, unread: Int) { - Column( - modifier = Modifier.padding(start = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) - Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp) + Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + MembyIcon.NotificationActive.mark, + contentDescription = null, + tint = MembyAccent, + modifier = Modifier.size(21.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text("NOTIFICATIONS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text("Your news", color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) + } + Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 13.sp) } } +/** + * The empty state, which is the state this page is usually in — its whole job is emptying + * itself, so two lines of grey text in the middle of a black screen would read as a screen + * that failed to load rather than as good news. + * + * An empty Inbox and an empty Seen pane are different pieces of news and say so: one is + * "nothing to deal with", the other is "you have not put anything here yet". Notifications + * being switched off is no longer something a viewer did — the page has no such switch any + * more — but it is still true when the household has them off, and an Inbox that will never + * fill is worth explaining rather than leaving as an unexplained silence. + */ @Composable -private fun AlertsEmptyState(enabled: Boolean) { +private fun AlertsEmptyState(tab: AlertsTab, listening: Boolean) { + val inbox = tab == AlertsTab.INBOX Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - AlertsEmptyMark(listening = enabled) + AlertsEmptyMark(listening = listening && inbox) Spacer(Modifier.height(18.dp)) - Text("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + Text( + if (inbox) "You’re all caught up." else "Nothing marked as seen.", + color = MembyMutedText, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + ) Spacer(Modifier.height(8.dp)) Text( - if (enabled) { - "Notifications about the shows you follow will show up here." - } else { - "Notifications are switched off, so nothing new will arrive here." + when { + !inbox -> "Alerts you mark as seen wait here until you dismiss them." + listening -> "Notifications about the shows you follow will show up here." + else -> "Notifications are switched off for this profile, so nothing new will arrive here." }, color = MembyQuietText, fontSize = 14.sp, @@ -399,98 +679,136 @@ private fun ringSwing(progress: Float): Float { return (kotlin.math.sin(phase * 3f * TWO_PI) * (1.0 - phase) * 5.0).toFloat() } +/** + * One notification. + * + * The row holds **two** focus targets rather than one, and the split is what makes a seen + * toggle possible at all on a remote with a single confirm key. The body keeps the press it + * always had — OK dismisses, with the hint stated on the row about to go — and Right reaches + * a toggle beside it. Down still moves to the next row from either, so the second target + * costs nothing to somebody walking the list who never wants it. + * + * The lit surface belongs to the whole row, driven by `hasFocus` rather than `isFocused`, so + * a row does not go dark the moment the remote steps sideways into its own toggle. + */ @Composable private fun AlertRow( notification: UserNotification, - onFocused: () -> Unit, - onClick: () -> Unit, + focusRequester: FocusRequester, + toggleFocusRequester: FocusRequester, + upFocusRequester: FocusRequester?, + onToggleSeen: () -> Unit, + onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - var focused by remember { mutableStateOf(false) } + var rowHasFocus by remember { mutableStateOf(false) } + var bodyFocused by remember { mutableStateOf(false) } val shape = RoundedCornerShape(MembyCardCorner) Row( modifier = modifier .fillMaxWidth() - .onFocusChanged { - focused = it.isFocused - if (it.isFocused) onFocused() - } + // Observer before the group it observes: `onFocusChanged` reports the state of + // the focus target that follows it in the chain, so the two the other way round + // leave the row's own lit surface permanently dark. + .onFocusChanged { rowHasFocus = it.hasFocus } + .focusProperties { if (upFocusRequester != null) up = upFocusRequester } + .focusGroup() .clip(shape) - .background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent) + .background(if (rowHasFocus) Color.White.copy(alpha = 0.11f) else Color.Transparent) .border( - width = if (focused) 2.dp else 1.dp, - color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent, + width = if (rowHasFocus) 2.dp else 1.dp, + color = if (rowHasFocus) Color.White.copy(alpha = 0.88f) else Color.Transparent, shape = shape, ) - .clickable(onClick = onClick) - .semantics { - contentDescription = "${notification.title}. ${notification.message}. Press to dismiss." - } - .padding(horizontal = 16.dp, vertical = 15.dp), + .padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box( - Modifier.size(38.dp).clip(CircleShape).background( - if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f), - ), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { bodyFocused = it.isFocused } + .clip(RoundedCornerShape(MembyCardCorner)) + .clickable(onClick = onDismiss) + .semantics { + contentDescription = + "${notification.title}. ${notification.message}. Press to dismiss." + } + .padding(horizontal = 6.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon( - alertIcon(notification.kind), - contentDescription = null, - tint = if (notification.unread) MembyAccent else MembyQuietText, - modifier = Modifier.size(19.dp), - ) - } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { + Box( + Modifier.size(34.dp).clip(CircleShape).background( + if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + alertIcon(notification.kind), + contentDescription = null, + tint = if (notification.unread) MembyAccent else MembyQuietText, + modifier = Modifier.size(17.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + // Weighted, so the date and the NEW flag are measured first and pinned to + // the end while the title takes whatever is left. A title long enough to + // reach them ellipsises rather than pushing them off the row. + Text( + notification.title, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // On the title's line rather than under the message: a page of four rows + // has no line to spare for a date, and this is where the eye already is. + notification.eventAt?.takeIf { it.isNotBlank() }?.let { + Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 11.sp, maxLines = 1) + } + } Text( - notification.title, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + notification.message, + color = MembyMutedText, + fontSize = 13.sp, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - if (notification.unread) { + } + // The hint is the whole reason a single press is allowed to dismiss: it is stated + // on the row about to go, and only while the body itself holds the remote — with + // focus on the toggle beside it, OK does something else entirely. + Box(Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) { + if (bodyFocused) { Text( - "NEW", - color = MembyAccent, - fontSize = 9.sp, + "OK to dismiss", + color = Color.White, + fontSize = 12.sp, fontWeight = FontWeight.Bold, - letterSpacing = 1.sp, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(MembyAccent.copy(alpha = 0.14f)) - .padding(horizontal = 5.dp, vertical = 2.dp), + maxLines = 1, ) } } - Text( - notification.message, - color = MembyMutedText, - fontSize = 14.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - notification.eventAt?.takeIf { it.isNotBlank() }?.let { - Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1) - } - } - // The hint is the whole reason a single press is allowed to dismiss: it is stated on - // the row about to go, and only on the row under focus. - Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) { - if (focused) { - Text( - "OK to dismiss", - color = Color.White, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - } } + AlertsPillButton( + modifier = Modifier.focusRequester(toggleFocusRequester), + label = alertSeenActionLabel(notification.unread), + icon = if (notification.unread) { + MembyIcon.CheckCircle.mark + } else { + MembyIcon.NotificationActive.mark + }, + onClick = onToggleSeen, + accented = notification.unread, + ) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPaging.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPaging.kt new file mode 100644 index 0000000..4617e21 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPaging.kt @@ -0,0 +1,76 @@ +package com.ponzischeme89.memby.ui.alerts + +/** + * How Notifications is cut into pages, kept pure so the pager's wording, the rows a page + * holds and where focus lands after a dismissal are the same arithmetic tested once. + * + * **Paged on the television, not on the wire.** The gateway answers with the whole + * undismissed list and this cuts it up locally, which is deliberate: a page flip then costs + * nothing on a weak box, the locally-held update notice merges into page one without making + * the server's own page boundaries lie about it, and dismissing a row stays optimistic + * instead of needing the page it left refetched. What is paged is presentation, so it lives + * where the presentation is. + */ + +/** + * Rows to a page. + * + * Four, because a television is 540dp tall and this page spends a third of that on its + * heading and its controls — a page whose last row is below the fold is one somebody has to + * scroll *and* page through, which is worse than either on its own. + */ +internal const val AlertsPageSize = 4 + +/** Pages [total] rows fill. Zero for an empty list: no list is no pages, not one blank one. */ +internal fun alertPageCount(total: Int, pageSize: Int = AlertsPageSize): Int = + if (total <= 0 || pageSize <= 0) 0 else (total + pageSize - 1) / pageSize + +/** The index into the whole list that [page] begins at. */ +internal fun alertPageFirstIndex(page: Int, pageSize: Int = AlertsPageSize): Int = + if (page <= 0 || pageSize <= 0) 0 else page * pageSize + +/** The rows [page] holds, or nothing when it lies past the end of [items]. */ +internal fun alertPageItems( + items: List, + page: Int, + pageSize: Int = AlertsPageSize, +): List { + if (pageSize <= 0) return items + val start = alertPageFirstIndex(page, pageSize) + if (start >= items.size) return emptyList() + return items.subList(start, minOf(start + pageSize, items.size)) +} + +/** + * The page to stand on once the list holds [remainingTotal] rows. + * + * Clamping rather than resetting is the whole of it: this page's job is emptying itself, so + * the common change is the last row of the last page going away, and a viewer sent back to + * page one for it would lose their place every time they finished a page. They step back one + * page and carry on. An emptied list answers 0, which is the page the empty state occupies. + */ +internal fun alertPageAfterChange( + page: Int, + remainingTotal: Int, + pageSize: Int = AlertsPageSize, +): Int { + val count = alertPageCount(remainingTotal, pageSize) + return if (count <= 0) 0 else page.coerceIn(0, count - 1) +} + +/** + * The pager's own line. One-based, because it is read aloud by a person and nobody counts + * pages from zero; empty when there is no pager to label. + */ +internal fun alertPageLabel(page: Int, pageCount: Int): String = + if (pageCount <= 0) "" else "Page ${page.coerceIn(0, pageCount - 1) + 1} of $pageCount" + +/** + * What the seen toggle on a row says. + * + * It names the action rather than the state — "Mark as seen" on a new row — because it is a + * button, and a button labelled with the state it is already in reads as a claim rather than + * as something to press. + */ +internal fun alertSeenActionLabel(unread: Boolean): String = + if (unread) "Mark as seen" else "Mark as new" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/AiringNotice.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/AiringNotice.kt index 0e31ad4..f67911b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/AiringNotice.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/AiringNotice.kt @@ -71,6 +71,29 @@ fun scheduleSeriesStub(card: BaseItem): BaseItem? { ) } +/** + * The Emby film a movie-schedule card stands for, or null when the library has no copy. + * + * The sibling of [scheduleSeriesStub], and the same substitution: what the viewer pressed is + * the film, and once Emby holds it the ordinary movie page is the page they wanted. A card + * with no Emby id is the Radarr-only case and is answered by its own page instead, which is + * why this returns null rather than something inert. + * + * No airing notice goes with it. A digital release date is not an air time, and the film is + * there to be played — the schedule the card came from has stopped being news about it. + */ +fun scheduleMovieStub(card: BaseItem): BaseItem? { + if (!card.isMovieSchedule) return null + val movieId = card.membyMovieItemId?.trim()?.takeIf(String::isNotEmpty) ?: return null + return BaseItem( + id = movieId, + name = card.name, + type = "Movie", + genres = card.genres, + productionYear = card.productionYear, + ) +} + private fun airingNoticeLabel(day: String, airLabel: String, availability: String?): String = when { // The episode is already on the server, so "airing" would send someone to wait for // something they could watch now. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentity.kt new file mode 100644 index 0000000..61ccd02 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackIdentity.kt @@ -0,0 +1,106 @@ +package com.ponzischeme89.memby.ui.player + +/** + * The station ident — the short identity treatment shown over the opening seconds of a + * programme — and the rule that keeps it out of the transport's way. + * + * There are two places in this player that draw "what is playing", and they had no idea + * about each other: this ident (activity-owned, top-start, above the PlayerView) and the + * transport controller's own `player_now_playing_group` (the same logo, the same title, at + * the same corner four density pixels away). Whenever the controller happened to be up + * inside the ident's five seconds — a remote press, a pause, closing the cast or subtitle + * overlay, media3's own `auto_show` — both drew, and the result read as one ident rendered + * twice. Nothing was ever shown twice; two different surfaces answered the same question in + * the same place. + * + * So the region has one owner at a time, chosen by [playerIdentitySlot], and the ident is a + * one-shot per programme: it opens once, and the transport appearing *ends* it rather than + * being drawn over it. + */ +internal enum class PlayerIdentitySlot { + /** The station ident owns the corner: the transport is down and playback is running. */ + IDENT, + + /** The transport is up, so its own now-playing block is the identity on screen. */ + TRANSPORT, + + /** + * Nobody draws it. Paused is this case: the pause overlay carries the poster, the title + * and the synopsis, and a logo in the corner above it is the same programme said twice. + */ + NONE, +} + +/** + * Who may draw the identity, given the ident's own window and what the player is doing. + * + * Pause outranks everything, then the transport, then the ident — stated in one pure rule + * so the two surfaces cannot disagree about which of them is on screen. + */ +internal fun playerIdentitySlot( + identWindowOpen: Boolean, + transportVisible: Boolean, + paused: Boolean, +): PlayerIdentitySlot = when { + paused -> PlayerIdentitySlot.NONE + transportVisible -> PlayerIdentitySlot.TRANSPORT + identWindowOpen -> PlayerIdentitySlot.IDENT + else -> PlayerIdentitySlot.NONE +} + +/** + * The ident's phase for one programme. It is deliberately not a boolean: "has not opened + * yet" and "has already had its turn" are different answers to whether an arriving playback + * event should raise it, and conflating them is what let a re-prepare mid-programme open a + * second one. + */ +internal enum class PlaybackIdentityPhase { PENDING, SHOWING, DONE } + +/** + * Whether an arriving "playback has started" should raise the ident. + * + * Every path into the player reports that at least once and several report it more than + * once — a first frame, a pre-roll hand-off, a recovery re-prepare — so the answer has to be + * a function of the phase rather than of the event. + */ +internal fun shouldRaiseIdent( + phase: PlaybackIdentityPhase, + transportVisible: Boolean, + paused: Boolean, +): Boolean = phase == PlaybackIdentityPhase.PENDING && + playerIdentitySlot(identWindowOpen = true, transportVisible = transportVisible, paused = paused) == + PlayerIdentitySlot.IDENT + +/** Separator between the episode code and the episode's own title. */ +private const val EPISODE_SEPARATOR = " — " + +/** + * Episode wording for the station ident, separate from the logo/fallback presentation. + * + * The logo belongs to the *series*, so this is the only thing on the ident that says which + * episode it is: `S01E01 — Bob Smith`. A title that is just the show's name again, or the + * show's name with the episode appended, is reduced to the part that adds something. + */ +internal fun playbackIdentityEpisodeLabel( + title: String, + seriesName: String?, + episodeCode: String?, +): String? { + val code = episodeCode?.trim().orEmpty().uppercase() + if (code.isEmpty()) return null + val series = seriesName?.trim().orEmpty() + var episodeTitle = title.trim() + if (series.isNotEmpty()) { + // Emby and the gateway have both been seen to hand over "Series – Episode"; the + // dash is whichever one the metadata carried. + for (dash in listOf(" – ", " — ", " - ")) { + val prefix = series + dash + if (episodeTitle.startsWith(prefix, ignoreCase = true)) { + episodeTitle = episodeTitle.removePrefix(prefix).trim() + break + } + } + } + if (episodeTitle.isEmpty() || episodeTitle.equals(series, ignoreCase = true)) return code + return code + EPISODE_SEPARATOR + episodeTitle +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt index 554de80..0da0672 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlaybackStopWorker.kt @@ -41,6 +41,7 @@ class PlaybackStopWorker( ServiceLocator.repository.reportPlaybackStopped( session, inputData.getLong(POSITION_MS, 0L), + inputData.getLong(DURATION_MS, 0L), ) }.fold( onSuccess = { Result.success() }, @@ -54,19 +55,32 @@ class PlaybackStopWorker( private const val PLAY_SESSION_ID = "play_session_id" private const val PLAY_METHOD = "play_method" private const val POSITION_MS = "position_ms" + private const val DURATION_MS = "duration_ms" private const val ENQUEUED_AT_MS = "enqueued_at_ms" private const val MAX_RETRIES = 5 private fun workName(session: PlaybackSession): String = "emby-playback-stop-${session.playSessionId.ifBlank { session.itemId }}" - fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) { + /** + * [durationMs] is the title's own length where the player knows it, and it is + * carried for one reason: a stop past the end of a title is a completion, and a + * completed title has no resume point to remember. Zero simply means the runtime + * was not known, never that the title is zero long. + */ + fun enqueue( + context: Context, + session: PlaybackSession, + positionMs: Long, + durationMs: Long = 0L, + ) { val data = Data.Builder() .putString(ITEM_ID, session.itemId) .putString(MEDIA_SOURCE_ID, session.mediaSourceId) .putString(PLAY_SESSION_ID, session.playSessionId) .putString(PLAY_METHOD, session.playMethod) .putLong(POSITION_MS, positionMs.coerceAtLeast(0L)) + .putLong(DURATION_MS, durationMs.coerceAtLeast(0L)) .putLong(ENQUEUED_AT_MS, System.currentTimeMillis()) .build() val request = OneTimeWorkRequestBuilder() @@ -81,7 +95,7 @@ class PlaybackStopWorker( // WorkManager is the process-death fallback, not the ordinary delivery path. // Send now from the repository's process scope, which survives Activity // destruction, then cancel this exact fallback request once Emby accepts it. - ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) { + ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs, durationMs) { workManager.cancelWorkById(request.id) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index a8c9a58..5404008 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -129,21 +129,6 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri else -> "${preference.codecs.size} formats" } -/** Episode wording for the station ident, separate from the logo/fallback presentation. */ -internal fun playbackIdentityEpisodeLabel( - title: String, - seriesName: String?, - episodeCode: String?, -): String? { - val code = episodeCode?.trim().orEmpty() - if (code.isEmpty()) return null - val episodeTitle = title.trim() - .removePrefix(seriesName?.trim().orEmpty() + " – ") - .trim() - .takeUnless { it.isEmpty() || it == seriesName?.trim() } - return listOfNotNull(code, episodeTitle).joinToString(" · ") -} - /** * Fullscreen Media3 player with native stream-track selection. Press Menu while * playing to choose an audio or subtitle track; the subtitle controller button @@ -251,7 +236,19 @@ class PlayerActivity : ComponentActivity() { private var nowPlayingGroup: View? = null private var playbackIdentityView: View? = null private var playbackIdentityHideJob: Job? = null - private var playbackIdentityShown = false + + /** + * The ident is one-shot per programme, so its phase — never a boolean — is what decides + * whether an arriving playback event may raise it. See [PlaybackIdentity]. + */ + private var playbackIdentityPhase = PlaybackIdentityPhase.PENDING + + /** + * What the transport is doing, as media3 reports it. The ident and the transport's own + * now-playing block occupy the same corner, so this is the input that keeps exactly one + * of them on screen. + */ + private var transportVisible = false private var bufferingStartedAtMs: Long? = null private var totalBufferingMs = 0L private var bufferingCount = 0 @@ -641,6 +638,15 @@ class PlayerActivity : ComponentActivity() { controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS } applySubtitleAppearance(view) + // The ident and the transport's own now-playing block share the top-start corner, + // so the player has to say which of them is on screen rather than each deciding for + // itself. This is the only thing that reports the transport's real visibility — + // media3 raises it for reasons the activity never hears about, auto_show among them. + view.setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visibility -> + onTransportVisibilityChanged(visibility == View.VISIBLE) + }, + ) playerView = view view.findViewById(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener { showSubtitleOverlay() @@ -873,6 +879,7 @@ class PlayerActivity : ComponentActivity() { this@PlayerActivity, playbackSession(completedId), previewResumeDurationMs, + previewResumeDurationMs, ) } } @@ -1047,6 +1054,7 @@ class PlayerActivity : ComponentActivity() { playMethod = playable.playMethod playbackTitle = playable.title.ifBlank { request.title + " trailer" } bindTitleArtwork(playbackTitle, logoUrl) + resetPlaybackIdentity() setUpPlaybackIdentity(playbackTitle, null, null, logoUrl) startMedia(playable.url, emptyList(), 0L, playWhenReady = true) } @@ -2250,6 +2258,15 @@ class PlayerActivity : ComponentActivity() { } } + /** + * Binds what the ident says. Never shows it: raising it belongs to + * [showPlaybackIdentity] alone, so re-binding for a corrected title mid-launch — which + * [adoptPlayable] does on every gateway launch — cannot open a second one. + * + * The logo and its text fallback are mutually exclusive and both start hidden, so the + * corner is never briefly the series name *and* the series logo while Coil is still + * fetching the artwork. + */ private fun setUpPlaybackIdentity( title: String, seriesName: String?, @@ -2262,8 +2279,9 @@ class PlayerActivity : ComponentActivity() { text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" } } findViewById(R.id.player_playback_identity_episode).apply { - text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty() - visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE + val label = playbackIdentityEpisodeLabel(title, seriesName, episodeCode) + text = label.orEmpty() + visibility = if (label.isNullOrBlank()) View.GONE else View.VISIBLE } if (logoUrl.isNullOrBlank()) { logo.clearColorFilter() @@ -2272,6 +2290,7 @@ class PlayerActivity : ComponentActivity() { fallback.visibility = View.VISIBLE return } + fallback.visibility = View.GONE logo.load(logoUrl) { crossfade(false) listener( @@ -2288,10 +2307,54 @@ class PlayerActivity : ComponentActivity() { } } + /** + * Forgets that this programme has had its ident. Called wherever the subject of the + * player changes underneath a session that never went back to the launcher — an episode + * advance, a trailer resolving, a next-episode preview and the return from one — so the + * incoming title gets its own ident and never inherits the outgoing title's. + */ + private fun resetPlaybackIdentity() { + playbackIdentityPhase = PlaybackIdentityPhase.PENDING + playbackIdentityHideJob?.cancel() + playbackIdentityHideJob = null + playbackIdentityView?.apply { + animate().cancel() + alpha = 0f + visibility = View.GONE + } + applyIdentityRegion() + } + + /** + * Raises the ident, once, if the corner is actually free. + * + * Every path into playback reports "started" at least once and several report it more + * than once, so the guard is the phase rather than the event; and the transport being up + * means the identity is already on screen in its own block, so the ident stands down + * rather than drawing a second copy of it four density pixels away. + */ private fun showPlaybackIdentity() { - if (playbackIdentityShown) return val identity = playbackIdentityView ?: return - playbackIdentityShown = true + val paused = pauseOverlay?.visibility == View.VISIBLE + if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) { + MembyDiagnostics.debug( + "station_ident_withheld", + "playback" to playSessionId, + "item" to itemId, + "phase" to playbackIdentityPhase.name, + "transport_visible" to transportVisible, + "paused" to paused, + ) + // Whatever is on screen is already saying it. Spend the ident here rather than + // leaving it armed to appear when the controls time out, seconds into the + // programme. + if (playbackIdentityPhase == PlaybackIdentityPhase.PENDING) { + playbackIdentityPhase = PlaybackIdentityPhase.DONE + } + return + } + playbackIdentityPhase = PlaybackIdentityPhase.SHOWING + applyIdentityRegion() playbackIdentityHideJob?.cancel() identity.animate().cancel() identity.alpha = 0f @@ -2300,14 +2363,75 @@ class PlayerActivity : ComponentActivity() { .alpha(1f) .setDuration(PLAYBACK_IDENTITY_FADE_MS) .start() + MembyDiagnostics.info( + "station_ident_shown", + "playback" to playSessionId, + "item" to itemId, + "visible_ms" to PLAYBACK_IDENTITY_VISIBLE_MS, + ) playbackIdentityHideJob = lifecycleScope.launch { delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS) - identity.animate() + dismissPlaybackIdentity("elapsed") + } + } + + /** + * Ends the ident's turn. It never comes back for this programme: the identity is an + * opening announcement, and one that reappeared when the transport timed out would be a + * second ident for a title already minutes in. + */ + private fun dismissPlaybackIdentity(reason: String) { + playbackIdentityHideJob?.cancel() + playbackIdentityHideJob = null + val phase = playbackIdentityPhase + playbackIdentityPhase = PlaybackIdentityPhase.DONE + if (phase != PlaybackIdentityPhase.SHOWING) { + applyIdentityRegion() + return + } + MembyDiagnostics.debug( + "station_ident_dismissed", + "playback" to playSessionId, + "item" to itemId, + "reason" to reason, + ) + playbackIdentityView?.apply { + animate().cancel() + animate() .alpha(0f) .setDuration(PLAYBACK_IDENTITY_FADE_MS) - .withEndAction { identity.visibility = View.GONE } + .withEndAction { visibility = View.GONE } .start() } + applyIdentityRegion() + } + + /** + * Hands the top-start corner to whichever surface owns it right now. + * + * The ident and the transport's own now-playing block are the same information in the + * same place, and this is the one place that decides between them — the defect this + * replaces was each of them deciding for itself. + */ + private fun applyIdentityRegion() { + val slot = playerIdentitySlot( + identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING, + transportVisible = transportVisible, + paused = pauseOverlay?.visibility == View.VISIBLE, + ) + nowPlayingGroup?.visibility = + if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE + } + + /** + * Media3 tells us when the transport comes and goes. The transport appearing is what + * ends the ident — the alternative rules (moving it, fading it, letting the transport + * draw over it) all leave two answers to "what is playing" on screen at once. + */ + private fun onTransportVisibilityChanged(visible: Boolean) { + if (transportVisible == visible) return + transportVisible = visible + if (visible) dismissPlaybackIdentity("transport_shown") else applyIdentityRegion() } private fun updatePlaybackTiming(playback: Player) { @@ -3428,6 +3552,7 @@ class PlayerActivity : ComponentActivity() { pausePosterUrl = next.imageUrl pauseOverview = next.overview bindTitleArtwork(playbackTitle, logoUrl) + resetPlaybackIdentity() setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl) renderedFirstFrame = false showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview") @@ -3469,6 +3594,7 @@ class PlayerActivity : ComponentActivity() { playbackStarted = previewResumePlaybackStarted stopReported = previewResumeStopReported bindTitleArtwork(playbackTitle, logoUrl) + resetPlaybackIdentity() setUpPlaybackIdentity( playbackTitle, playbackSeriesName, @@ -3958,6 +4084,7 @@ class PlayerActivity : ComponentActivity() { this, playbackSession(completedItemId), playback?.currentPosition ?: 0L, + knownDurationMs(playback), ) } finish() @@ -4065,6 +4192,7 @@ class PlayerActivity : ComponentActivity() { this, playbackSession(previousId), playback?.currentPosition ?: 0L, + knownDurationMs(playback), ) } @@ -4100,13 +4228,9 @@ class PlayerActivity : ComponentActivity() { itemName = nextTitle(next), itemType = "Episode", ) - playbackIdentityShown = false - playbackIdentityHideJob?.cancel() - playbackIdentityView?.apply { - animate().cancel() - alpha = 0f - visibility = View.GONE - } + // The incoming episode gets its own ident; nothing of the outgoing one's is left + // armed, showing, or counted as already spent. + resetPlaybackIdentity() initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L) renderedFirstFrame = false automaticRetryAttempt = 0 @@ -4198,6 +4322,9 @@ class PlayerActivity : ComponentActivity() { private fun bindPauseOverlay(view: PlayerView) { pauseOverlay = view.findViewById(R.id.player_pause_overlay) nowPlayingGroup = view.findViewById(R.id.player_now_playing_group) + // The group is visible in the layout, so state it here too: nothing else runs before + // the transport is first raised, and the ident's five seconds are inside that window. + applyIdentityRegion() pauseOverlay?.findViewById(R.id.player_pause_title)?.text = playbackTitle pauseOverlay?.findViewById(R.id.player_pause_overview)?.apply { text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) } @@ -4217,7 +4344,11 @@ class PlayerActivity : ComponentActivity() { val paused = playbackStarted && !prerollActive && playback.playbackState == Player.STATE_READY && !playback.isPlaying pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE - nowPlayingGroup?.visibility = if (paused) View.GONE else View.VISIBLE + // Pausing during the ident hands the corner to the pause overlay, which carries the + // poster, the title and the synopsis: an ident over the top of that is the same + // programme announced twice, in two type sizes, in overlapping space. + if (paused) dismissPlaybackIdentity("paused") + applyIdentityRegion() if (paused) playerView?.showController() } @@ -5271,7 +5402,12 @@ class PlayerActivity : ComponentActivity() { stopReported = true stoppedInBackground = true itemId?.takeIf(String::isNotBlank)?.let { id -> - PlaybackStopWorker.enqueue(this, playbackSession(id), it.currentPosition) + PlaybackStopWorker.enqueue( + this, + playbackSession(id), + it.currentPosition, + knownDurationMs(it), + ) } } } @@ -5327,6 +5463,7 @@ class PlayerActivity : ComponentActivity() { this, playbackSession(itemId!!), playback?.currentPosition ?: 0L, + knownDurationMs(playback), ) } playerView?.player = null @@ -5431,7 +5568,7 @@ class PlayerActivity : ComponentActivity() { ) if (changed && playbackStarted && oldSession != null) { stopProgressUploading() - PlaybackStopWorker.enqueue(this, oldSession, positionMs) + PlaybackStopWorker.enqueue(this, oldSession, positionMs, knownDurationMs()) playbackStarted = false stopReported = false stoppedInBackground = false @@ -5442,6 +5579,15 @@ class PlayerActivity : ComponentActivity() { playMethod = newPlayMethod.ifBlank { "DirectPlay" } } + /** + * The title's own length, or zero where media3 does not yet know it. Carried with every + * stop so the resume ledger can tell a title somebody left part-way through from one + * they finished — a completed title has its position reset, and remembering a playhead + * for it would drop the next viewing into the closing minutes. + */ + private fun knownDurationMs(playback: Player? = player): Long = + playback?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L + private fun playbackSession(id: String) = PlaybackSession( itemId = id, mediaSourceId = mediaSourceId.ifBlank { id }, 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 index bf30471..7e4de10 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackFontAwesome.kt @@ -34,6 +34,7 @@ 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.Sync import com.composables.icons.fontawesome.solid.InfoCircle import com.composables.icons.fontawesome.solid.Magic import com.composables.icons.fontawesome.solid.Medal @@ -98,6 +99,7 @@ internal val fontAwesomeIconPack = MembyIconPack( MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble }, MembyIcon.Add to { FontAwesome.Solid.Plus }, MembyIcon.Close to { FontAwesome.Solid.Times }, + MembyIcon.Refresh to { FontAwesome.Solid.Sync }, MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft }, MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight }, MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown }, 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 index d8a2f6d..8fb0c98 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPackLucide.kt @@ -31,6 +31,7 @@ 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.RefreshCw import com.composables.icons.lucide.Info import com.composables.icons.lucide.LayoutGrid import com.composables.icons.lucide.LibraryBig @@ -98,6 +99,7 @@ internal val lucideIconPack = MembyIconPack( MembyIcon.Check to { Lucide.Check }, MembyIcon.Add to { Lucide.Plus }, MembyIcon.Close to { Lucide.X }, + MembyIcon.Refresh to { Lucide.RefreshCw }, MembyIcon.ChevronLeft to { Lucide.ChevronLeft }, MembyIcon.ChevronRight to { Lucide.ChevronRight }, MembyIcon.ChevronDown to { Lucide.ChevronDown }, 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 index 8773a6c..2e780c6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIconPacks.kt @@ -33,6 +33,7 @@ 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.Refresh import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Landscape @@ -116,6 +117,7 @@ object MaterialIconPack { MembyIcon.CheckAll to { Icons.Default.DoneAll }, MembyIcon.Add to { Icons.Default.Add }, MembyIcon.Close to { Icons.Default.Close }, + MembyIcon.Refresh to { Icons.Default.Refresh }, MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft }, MembyIcon.ChevronRight to { Icons.Default.ChevronRight }, MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown }, 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 index 53709d6..2fe6322 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/MembyIcons.kt @@ -51,6 +51,7 @@ enum class MembyIcon { CheckAll, Add, Close, + Refresh, // Movement ChevronLeft, diff --git a/app/src/main/res/drawable/player_identity_episode_background.xml b/app/src/main/res/drawable/player_identity_episode_background.xml new file mode 100644 index 0000000..f5d3d28 --- /dev/null +++ b/app/src/main/res/drawable/player_identity_episode_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/player_playback_identity.xml b/app/src/main/res/layout/player_playback_identity.xml index 5f76864..55075bd 100644 --- a/app/src/main/res/layout/player_playback_identity.xml +++ b/app/src/main/res/layout/player_playback_identity.xml @@ -1,6 +1,13 @@ - + - - - + android:layout_height="82dp"> + + + + + diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index 6489030..7b3aa11 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -18,6 +18,7 @@ import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback import com.ponzischeme89.memby.data.model.RecommendationOnboarding import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -453,6 +454,29 @@ class GatewayPayloadTest { assertEquals("incinemas", item.membyLifecycle) assertEquals("IN CINEMAS", item.membyLifecycleText) assertEquals(false, item.membyPlayable) + // No Emby id: the household has no copy, which is what sends this card to the + // Radarr-only page rather than to an ordinary movie one. + assertTrue(item.isRadarrOnly) + } + + @Test + fun `a Radarr card names the Emby film once the library holds it`() { + val payload = """ + { + "Id":"radarr:7", + "Name":"Arrival", + "Type":"MembyRadarrMovie", + "MembySource":"radarr", + "MembyPlayable":false, + "MembyMovieItemId":"emby-4821" + } + """.trimIndent() + + val item = json.decodeFromString(payload) + + assertEquals("emby-4821", item.membyMovieItemId) + assertTrue(item.isMovieSchedule) + assertFalse(item.isRadarrOnly) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt index 801108c..19755f6 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt @@ -19,6 +19,7 @@ class UserPreferencesTest { fun `encoding and decoding is a fixed point`() { val original = UserPreferences( profileInitials = "MC", + shortName = "Matt", homeSections = listOf("latest", "continue"), homeCardDensity = "large", homeArtworkStyle = "poster", diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt index 9729cde..cebb184 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt @@ -1,10 +1,15 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.data.LOCAL_RESUME_MAX_AGE_MS +import com.ponzischeme89.memby.data.isFreshLocalResume import com.ponzischeme89.memby.data.launchResumePositionMs +import com.ponzischeme89.memby.data.playbackCompletesItem import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.ui.detail.primaryActionLabel import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ContinueWatchingResumeTest { @@ -41,4 +46,81 @@ class ContinueWatchingResumeTest { launchResumePositionMs(resolvedPositionMs = 42_000L, requestedPositionMs = 0L), ) } + + @Test + fun `the position the player left at outranks a card the refresh has not reached yet`() { + // The reported defect: twenty seconds watched from 10:00, exit, press Play again + // before Continue Watching has been refreshed. Both of the other answers still + // describe the launch before this one. + assertEquals( + 620_000L, + launchResumePositionMs( + resolvedPositionMs = 600_000L, + requestedPositionMs = 600_000L, + localPositionMs = 620_000L, + ), + ) + } + + @Test + fun `a refreshed card retires the local record by catching up with it`() { + // No bookkeeping retires the ledger; being outranked does. A card carrying the + // recorded position, or a later one watched on another set, simply wins. + assertEquals( + 620_000L, + launchResumePositionMs( + resolvedPositionMs = 0L, + requestedPositionMs = 620_000L, + localPositionMs = 620_000L, + ), + ) + assertEquals( + 900_000L, + launchResumePositionMs( + resolvedPositionMs = 0L, + requestedPositionMs = 900_000L, + localPositionMs = 620_000L, + ), + ) + } + + @Test + fun `with nothing known locally the launch is unchanged`() { + assertEquals( + 36_000L, + launchResumePositionMs( + resolvedPositionMs = 0L, + requestedPositionMs = 36_000L, + localPositionMs = 0L, + ), + ) + assertEquals( + 0L, + launchResumePositionMs( + resolvedPositionMs = -1L, + requestedPositionMs = 0L, + localPositionMs = 0L, + ), + ) + } + + @Test + fun `a finished title has no resume point to remember`() { + val runtime = 45L * 60L * 1_000L + assertTrue(playbackCompletesItem(positionMs = runtime - 60_000L, durationMs = runtime)) + assertFalse(playbackCompletesItem(positionMs = 20_000L, durationMs = runtime)) + // Zero means the runtime was not known, never that the title is zero long. + assertFalse(playbackCompletesItem(positionMs = runtime, durationMs = 0L)) + } + + @Test + fun `a local record is trusted for hours but not indefinitely`() { + val recordedAt = 1_000_000L + assertTrue(isFreshLocalResume(recordedAt, recordedAt + 60_000L)) + assertTrue(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS)) + assertFalse(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS + 1L)) + // A clock that moved backwards is no evidence at all. + assertFalse(isFreshLocalResume(recordedAt, recordedAt - 1L)) + assertFalse(isFreshLocalResume(0L, recordedAt)) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt index 489233f..985b612 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt @@ -2,10 +2,25 @@ package com.ponzischeme89.memby.ui import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class HomeGreetingTest { + @Test + fun `short name is preferred and falls back to the account name`() { + assertEquals("Matt", greetingName("Matt", "MattCohen")) + // The account-name reading is unchanged for a household that has never set one, + // and blank or whitespace is the ordinary state rather than a name. + assertEquals("MattCohen", greetingName(null, "MattCohen")) + assertEquals("MattCohen", greetingName("", "MattCohen")) + assertEquals("Peter", greetingName(" ", "PeterC")) + // A short name stands on its own where there is no account name to fall back to. + assertEquals("Matt", greetingName(" Matt ", null)) + assertNull(greetingName(null, null)) + assertNull(greetingName("", " ")) + } + @Test fun `time of day selects the expected greeting`() { assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4)) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailScreenshotTest.kt new file mode 100644 index 0000000..e948555 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailScreenshotTest.kt @@ -0,0 +1,167 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.MediaRating +import com.ponzischeme89.memby.data.model.RadarrMovieDetail +import com.ponzischeme89.memby.data.model.RadarrReleaseDate +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 + +/** + * The Radarr-only movie page, to `build/screenshots/radarr-movie/`. + * + * ```powershell + * .\gradlew.bat :app:testDebugUnitTest --tests "*RadarrMovieDetailScreenshotTest" + * ``` + * + * The claim this page makes is one a unit test cannot check: that a film nobody can watch + * yet reads as *deliberately* unavailable rather than as a page that failed to load its + * Play button. What is worth looking at is whether the status treatment and the expected + * date carry that on their own, and whether the three states below — a full record, a film + * with no date announced, and the moment before the request lands — are all recognisably + * the same page. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class RadarrMovieDetailScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + /** Everything the gateway can answer with: three dates, scores, a certificate, a trailer. */ + @Test + fun `a film with a published release date`() { + capture("radarr-coming-soon") { + RadarrMovieDetailContent(card = card, detail = full, onPlayTrailer = {}, onClose = {}) + } + } + + /** + * The precision case. Radarr knows only that it was in cinemas, so the page names a + * month rather than a day — and there is no trailer, so the only button is Back. + */ + @Test + fun `a film whose date is only estimated`() { + capture("radarr-estimated") { + RadarrMovieDetailContent( + card = card, + detail = full.copy( + expectedLabel = "Expected November 2026", + stateDetail = "Not released yet", + trailerAvailable = false, + ratings = emptyList(), + releaseDates = listOf( + RadarrReleaseDate("cinema", "In cinemas", "2 October 2026"), + ), + ), + onPlayTrailer = {}, + onClose = {}, + ) + } + } + + /** Nothing announced at all, which must read as a fact rather than as a missing value. */ + @Test + fun `a film with no date announced`() { + capture("radarr-unannounced") { + RadarrMovieDetailContent( + card = card.copy(overview = null), + detail = RadarrMovieDetail( + id = "radarr:412", + title = "Untitled Kōwhai Project", + stateLabel = "Awaiting Release", + stateDetail = "Nothing to download until a date is announced", + expectedLabel = "Release date not yet announced", + availabilityNotice = "Not available to watch in Memby yet", + lifecycle = "tba", + lifecycleText = "TBA", + ), + onPlayTrailer = {}, + onClose = {}, + ) + } + } + + /** + * The opening frame, before the request answers. The page is drawn from the card that + * was pressed, so what matters is that it is already recognisably this film rather than + * an empty frame that fills in. + */ + @Test + fun `the frame before the answer arrives`() { + capture("radarr-opening") { + RadarrMovieDetailContent(card = card, detail = null, onPlayTrailer = {}, onClose = {}) + } + } + + private fun capture(name: String, content: @Composable () -> Unit) { + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { content() } + } + compose.onRoot().captureRoboImage("build/screenshots/radarr-movie/$name.png") + } + + // --------------------------------------------------------------------------- + + private val card = BaseItem( + id = "radarr:412", + name = "The Quiet Coast", + type = "MembyRadarrMovie", + overview = "A harbour town in winter, and the constable who has stopped " + + "pretending the tide brings anything back.", + productionYear = 2026, + runTimeTicks = 118L * 600_000_000L, + genres = listOf("Drama", "Mystery"), + membySource = "radarr", + membyPlayable = false, + membyAvailability = "upcoming", + membyAvailabilityText = "Upcoming digital release", + ) + + private val full = RadarrMovieDetail( + id = "radarr:412", + title = "The Quiet Coast", + overview = "A harbour town in winter, and the constable who has stopped " + + "pretending the tide brings anything back. Adapted from the novel.", + year = 2026, + runtimeMinutes = 118, + genres = listOf("Drama", "Mystery", "Thriller"), + studio = "Kōwhai Pictures", + certificate = "M", + monitored = true, + lifecycle = "announced", + lifecycleText = "ANNOUNCED", + stateLabel = "Coming Soon", + stateDetail = "Not released yet", + expectedLabel = "Expected 14 November 2026", + releaseDates = listOf( + RadarrReleaseDate("cinema", "In cinemas", "2 October 2026"), + RadarrReleaseDate("digital", "Digital release", "14 November 2026"), + RadarrReleaseDate("physical", "Physical release", "5 December 2026"), + ), + availabilityNotice = "Not available to watch in Memby yet", + trailerAvailable = true, + ratings = listOf( + MediaRating(source = "imdb", name = "IMDb", score = "7.8", scale = "/10"), + MediaRating(source = "tomatoes", name = "Rotten Tomatoes", score = "91", scale = "%"), + ), + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailTest.kt new file mode 100644 index 0000000..21c4789 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/RadarrMovieDetailTest.kt @@ -0,0 +1,144 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.RadarrMovieDetail +import com.ponzischeme89.memby.ui.detail.scheduleMovieStub +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The rules that decide *which* page a movie-schedule card opens, and what its own page + * prints before the request lands. Everything the page says about a release is the + * gateway's wording and is pinned in `radarr_detail_test.go`; these are the television's + * half, which is the routing. + */ +class RadarrMovieDetailTest { + private val json = Json { ignoreUnknownKeys = true } + + private fun card(embyItemId: String? = null) = BaseItem( + id = "radarr:412", + name = "The Quiet Coast", + type = "MembyRadarrMovie", + membySource = "radarr", + membyPlayable = false, + membyMovieItemId = embyItemId, + productionYear = 2026, + genres = listOf("Drama"), + ) + + @Test + fun `a card Emby has no copy of is the Radarr-only case`() { + assertTrue(card().isRadarrOnly) + assertNull(scheduleMovieStub(card())) + } + + @Test + fun `a card Emby has imported opens the ordinary movie page`() { + val withCopy = card(embyItemId = "emby-99") + assertFalse(withCopy.isRadarrOnly) + val stub = scheduleMovieStub(withCopy) + assertEquals("emby-99", stub?.id) + assertEquals("Movie", stub?.type) + assertEquals("The Quiet Coast", stub?.name) + assertEquals(2026, stub?.productionYear) + } + + @Test + fun `a blank Emby id is no id at all`() { + // The gateway omits the field; a build or a cache that writes an empty string + // instead must not be read as a film the library holds. + val blank = card(embyItemId = " ") + assertTrue(blank.isRadarrOnly) + assertNull(scheduleMovieStub(blank)) + } + + @Test + fun `a TV schedule card is not a movie one`() { + val episode = BaseItem(id = "sonarr:3", name = "Some Show", membySource = "sonarr") + assertFalse(episode.isRadarrOnly) + assertNull(scheduleMovieStub(episode)) + } + + @Test + fun `the page steps aside the moment Emby holds the film`() { + val detail = RadarrMovieDetail(id = "radarr:412", title = "The Quiet Coast", year = 2026) + assertNull(radarrEmbyStub(card(), detail)) + + val imported = detail.copy(embyItemId = "emby-99", genres = listOf("Drama", "Mystery")) + val stub = radarrEmbyStub(card(), imported) + assertEquals("emby-99", stub?.id) + assertEquals("Movie", stub?.type) + assertEquals(listOf("Drama", "Mystery"), stub?.genres) + assertEquals(2026, stub?.productionYear) + } + + @Test + fun `the fact line is drawn from the card until the request answers`() { + // The card is what exists on the opening frame, and a page that printed nothing + // until the gateway answered would be one that visibly assembles itself. + val fromCard = radarrMovieFacts(card().copy(runTimeTicks = 118L * 600_000_000L), null) + assertEquals(listOf("2026", "1h 58m"), fromCard) + + val detail = RadarrMovieDetail( + year = 2026, + runtimeMinutes = 118, + certificate = "M", + studio = "Kōwhai Pictures", + ) + assertEquals( + listOf("2026", "1h 58m", "M", "Kōwhai Pictures"), + radarrMovieFacts(card(), detail), + ) + } + + @Test + fun `a fact nothing knows is omitted rather than printed empty`() { + val bare = BaseItem(id = "radarr:9", name = "Untitled", membySource = "radarr") + assertEquals(emptyList(), radarrMovieFacts(bare, RadarrMovieDetail())) + } + + @Test + fun `the gateway's answer decodes, including the fields an older one omits`() { + val payload = """ + { + "id":"radarr:412", + "title":"The Quiet Coast", + "overview":"A harbour town in winter.", + "year":2026, + "runtimeMinutes":118, + "genres":["Drama","Mystery"], + "studio":"Kōwhai Pictures", + "certificate":"M", + "monitored":true, + "lifecycle":"announced", + "lifecycleText":"ANNOUNCED", + "stateLabel":"Coming Soon", + "stateDetail":"Not released yet", + "expectedLabel":"Expected 14 November 2026", + "releaseDates":[ + {"kind":"cinema","label":"In cinemas","value":"2 October 2026"}, + {"kind":"digital","label":"Digital release","value":"14 November 2026"} + ], + "availabilityNotice":"Not available to watch in Memby yet", + "trailerAvailable":true, + "ratings":[{"source":"imdb","name":"IMDb","score":"7.8","scale":"/10"}] + } + """.trimIndent() + + val detail = json.decodeFromString(payload) + + assertEquals("Expected 14 November 2026", detail.expectedLabel) + assertEquals("Coming Soon", detail.stateLabel) + assertEquals(2, detail.releaseDates.size) + assertEquals("digital", detail.releaseDates[1].kind) + assertTrue(detail.trailerAvailable) + assertEquals("IMDb", detail.ratings.single().name) + // Absent because the library has no copy — which is the whole reason this page is + // the one that opened. + assertEquals("", detail.embyItemId) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt index c93e894..eb423a7 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby.ui.alerts +import com.ponzischeme89.memby.data.model.UserNotification import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -38,4 +39,66 @@ class AlertsFormatTest { assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2)) assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1)) } + + @Test + fun `the two tabs are the read flag and nothing else`() { + val notifications = listOf( + notification(id = 1, readAt = null), + notification(id = 2, readAt = "2026-08-06T09:00:00Z"), + notification(id = 3, readAt = null), + ) + assertEquals( + listOf(1L, 3L), + alertsForTab(AlertsTab.INBOX, notifications).map { it.id }, + ) + assertEquals( + listOf(2L), + alertsForTab(AlertsTab.SEEN, notifications).map { it.id }, + ) + } + + /** Every alert is in exactly one half, or the counts on the strip could not add up. */ + @Test + fun `every alert lands in one half`() { + val notifications = (1..7).map { + notification(id = it.toLong(), readAt = if (it % 2 == 0) "2026-08-06T09:00:00Z" else null) + } + val inbox = alertsForTab(AlertsTab.INBOX, notifications) + val seen = alertsForTab(AlertsTab.SEEN, notifications) + assertEquals(notifications.size, inbox.size + seen.size) + assertEquals(emptyList(), inbox.map { it.id }.intersect(seen.map { it.id }.toSet()).toList()) + } + + /** The order the caller gave is kept: both panes page the same way the one list did. */ + @Test + fun `a tab keeps the order it was given`() { + val notifications = listOf( + notification(id = 9, readAt = null), + notification(id = 4, readAt = null), + notification(id = 6, readAt = null), + ) + assertEquals(listOf(9L, 4L, 6L), alertsForTab(AlertsTab.INBOX, notifications).map { it.id }) + } + + @Test + fun `a tab count is drawn as itself, zero included`() { + assertEquals("0", alertTabCountLabel(0)) + assertEquals("0", alertTabCountLabel(-3)) + assertEquals("1", alertTabCountLabel(1)) + assertEquals("99", alertTabCountLabel(99)) + } + + @Test + fun `a large tab count states the cap rather than widening the tab`() { + assertEquals("99+", alertTabCountLabel(100)) + assertEquals("99+", alertTabCountLabel(4210)) + } + + private fun notification(id: Long, readAt: String?) = UserNotification( + id = id, + kind = "series_return", + title = "Northbound returns", + message = "Season 3 starts on Thursday.", + readAt = readAt, + ) } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt index ba33fda..bcd5295 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt @@ -1,7 +1,9 @@ package com.ponzischeme89.memby.ui.alerts import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick import com.github.takahirom.roborazzi.captureRoboImage import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.NotificationPreferences @@ -40,18 +42,79 @@ class AlertsPageScreenshotTest { capture("my-alerts-populated", sampleAlerts) } - /** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */ + /** + * Everything already read, which on this page means an empty Inbox — the pane the page + * opens on. The capture is the check that the strip still says where the three alerts + * went: an empty half beside a Seen tab reading 3 is good news, where an empty page with + * no counts on it reads as a list that lost them. + */ @Test fun `everything already read`() { capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") }) } + /** + * More alerts than a page holds, which is what puts the pager on screen. The capture is + * the check that four rows and the pager under them fit the 540dp a television has — + * a page whose last row is below the fold would be one somebody has to scroll *and* + * page through. + */ + @Test + fun `paged`() { + capture("my-alerts-paged", manyAlerts) + } + + /** + * One alert past a full page. The capture opens on page one, so what it shows is the + * pager appearing for a list barely long enough to need it — the case where a pager + * that took a row's worth of height would not have earned it. + */ + @Test + fun `just past one page`() { + capture("my-alerts-paged-shallow", manyAlerts.take(AlertsPageSize + 1)) + } + + /** + * A full page of the tallest row this page can draw — every message wrapping onto a + * second line. This is the capture the four-rows-to-a-page figure is answerable to: the + * pager is anchored to the bottom edge, so the thing to look at is whether the last row + * still clears it. + */ + @Test + fun `a crowded page`() { + capture( + "my-alerts-paged-crowded", + manyAlerts.take(AlertsPageSize + 2).map { + it.copy( + message = "Season 4 of this show returns on Thursday, and the first two " + + "episodes will be in Emby that morning if the download lands.", + ) + }, + ) + } + + /** + * The other half, reached the way a viewer reaches it — by pressing the tab. The strip is + * the only thing on the page saying which half is open, so a capture that set the pane + * some other way would not be a picture of what a television shows. + */ + @Test + fun `the seen half`() { + capture("my-alerts-seen", manyAlerts) { + compose.onNodeWithContentDescription("Seen, 3").performClick() + } + } + @Test fun `nothing waiting`() { capture("my-alerts-empty", emptyList()) } - /** Alerts switched off has its own empty wording — and both toggles read as off. */ + /** + * Notifications switched off for the profile. The page has no switch for it any more, so + * this is the capture that the empty state still explains why nothing is arriving — the + * only place a viewer can now be told. + */ @Test fun `alerts switched off`() { capture( @@ -125,24 +188,37 @@ class AlertsPageScreenshotTest { name: String, notifications: List, preferences: NotificationPreferences = NotificationPreferences(), + act: () -> Unit = {}, ) { compose.setContent { MembyTheme { MyAlertsPage( notifications = notifications, preferences = preferences, - onToggleEnabled = {}, - onToggleShowReturns = {}, - onRead = {}, onDismiss = {}, onDismissAll = {}, onClose = {}, ) } } + act() compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png") } + /** + * Eleven alerts: three pages, the last of them part-filled. Built from the samples so the + * paged captures and the single-page ones cannot drift into looking like different pages. + */ + private val manyAlerts: List + get() = (0 until 11).map { index -> + val sample = sampleAlerts[index % sampleAlerts.size] + sample.copy( + id = index + 1L, + title = sample.title + " (" + (index + 1) + ")", + readAt = if (index % 3 == 2) "2026-08-05T11:00:00Z" else null, + ) + } + private val sampleAlerts = listOf( UserNotification( id = 1, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPagingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPagingTest.kt new file mode 100644 index 0000000..98253e7 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPagingTest.kt @@ -0,0 +1,102 @@ +package com.ponzischeme89.memby.ui.alerts + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The arithmetic behind the Notifications pager. It is worth pinning because every one of + * these answers is reached while a viewer is holding a remote at a list that is emptying + * itself underneath them — the cases that go wrong are the ones nobody reproduces by hand. + */ +class AlertsPagingTest { + + @Test + fun `an empty list is no pages at all`() { + assertEquals(0, alertPageCount(0, 4)) + assertEquals(0, alertPageCount(-3, 4)) + } + + @Test + fun `a part-filled page still counts`() { + assertEquals(1, alertPageCount(1, 4)) + assertEquals(1, alertPageCount(4, 4)) + assertEquals(2, alertPageCount(5, 4)) + assertEquals(25, alertPageCount(100, 4)) + } + + @Test + fun `a page holds its own slice and the last one holds the remainder`() { + val items = (1..9).toList() + assertEquals(listOf(1, 2, 3, 4), alertPageItems(items, 0, 4)) + assertEquals(listOf(5, 6, 7, 8), alertPageItems(items, 1, 4)) + assertEquals(listOf(9), alertPageItems(items, 2, 4)) + } + + /** Reached for one frame whenever the list shrinks under somebody on the last page. */ + @Test + fun `a page past the end holds nothing rather than throwing`() { + assertEquals(emptyList(), alertPageItems(listOf(1, 2), 5, 4)) + assertEquals(emptyList(), alertPageItems(emptyList(), 0, 4)) + } + + /** + * The property the whole feature rests on: every row appears on exactly one page, and the + * pages together are the list in order. + */ + @Test + fun `the pages reassemble the list`() { + for (total in 0..40) { + val items = (1..total).toList() + val pages = (0 until alertPageCount(items.size, 4)).flatMap { + alertPageItems(items, it, 4) + } + assertEquals("total=$total", items, pages) + } + } + + @Test + fun `dismissing the last row of the last page steps back a page`() { + // Five alerts on two pages; the viewer is on page 1 holding its only row. + assertEquals(0, alertPageAfterChange(page = 1, remainingTotal = 4, pageSize = 4)) + } + + @Test + fun `a page that still has rows is kept rather than reset`() { + assertEquals(2, alertPageAfterChange(page = 2, remainingTotal = 11, pageSize = 4)) + } + + @Test + fun `an emptied list stands on the page the empty state occupies`() { + assertEquals(0, alertPageAfterChange(page = 7, remainingTotal = 0, pageSize = 4)) + } + + @Test + fun `the label counts from one`() { + assertEquals("Page 1 of 3", alertPageLabel(0, 3)) + assertEquals("Page 3 of 3", alertPageLabel(2, 3)) + } + + @Test + fun `there is nothing to label without pages`() { + assertEquals("", alertPageLabel(0, 0)) + } + + /** A page out of range is clamped rather than printed, or the pager contradicts itself. */ + @Test + fun `the label never reports a page past the end`() { + assertEquals("Page 2 of 2", alertPageLabel(9, 2)) + } + + /** The toggle names what pressing it does, not the state the row is already in. */ + @Test + fun `the seen toggle names its action`() { + assertEquals("Mark as seen", alertSeenActionLabel(unread = true)) + assertEquals("Mark as new", alertSeenActionLabel(unread = false)) + } + + /** The page size is what the layout was measured against; changing it is a layout change. */ + @Test + fun `a page holds four rows`() { + assertEquals(4, AlertsPageSize) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt index eaa2011..3268846 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/PlaybackIdentityScreenshotTest.kt @@ -88,7 +88,7 @@ class PlaybackIdentityScreenshotTest { } identity.findViewById(R.id.player_playback_identity_title).visibility = View.GONE identity.findViewById(R.id.player_playback_identity_episode).apply { - text = "S02E04 · The Other You" + text = "S02E04 — The Other You" visibility = View.VISIBLE } root.addView(identity) @@ -100,6 +100,60 @@ class PlaybackIdentityScreenshotTest { ) } + @Test + fun `a film shows its logo and nothing beneath it`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = backdropRoot(activity) + val identity = LayoutInflater.from(activity) + .inflate(R.layout.player_playback_identity, root, false) + .apply { + visibility = View.VISIBLE + alpha = 1f + } + identity.findViewById(R.id.player_playback_identity_logo).apply { + setImageBitmap(colourLogo()) + visibility = View.VISIBLE + } + identity.findViewById(R.id.player_playback_identity_title).visibility = View.GONE + // A film has no episode line at all — the plate must not be reserved for one. + identity.findViewById(R.id.player_playback_identity_episode).visibility = View.GONE + root.addView(identity) + activity.setContentView(root) + + root.captureRoboImage( + "build/screenshots/playback-identity/player-playback-identity-movie.png", + ) + } + + @Test + fun `a long episode title is held to one line beside the show logo`() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val root = backdropRoot(activity) + val identity = LayoutInflater.from(activity) + .inflate(R.layout.player_playback_identity, root, false) + .apply { + visibility = View.VISIBLE + alpha = 1f + } + // No logo: the fallback heading stands in, and the episode line must sit in the + // same place under it as it does under artwork. + identity.findViewById(R.id.player_playback_identity_logo).visibility = View.GONE + identity.findViewById(R.id.player_playback_identity_title).apply { + text = "Friends" + visibility = View.VISIBLE + } + identity.findViewById(R.id.player_playback_identity_episode).apply { + text = "S04E08 — The One Where They All Go To A Wedding And Nobody Says Anything" + visibility = View.VISIBLE + } + root.addView(identity) + activity.setContentView(root) + + root.captureRoboImage( + "build/screenshots/playback-identity/player-playback-identity-long-episode.png", + ) + } + @Test fun `loading keeps the selected backdrop visible`() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() @@ -120,7 +174,7 @@ class PlaybackIdentityScreenshotTest { @Test fun `episode ident separates the series from the episode`() { assertEquals( - "S02E04 · The Other You", + "S02E04 — The Other You", playbackIdentityEpisodeLabel( title = "Dark Matter – The Other You", seriesName = "Dark Matter", @@ -130,6 +184,95 @@ class PlaybackIdentityScreenshotTest { assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null)) } + @Test + fun `an episode named after its show is announced by its code alone`() { + assertEquals( + "S01E01", + playbackIdentityEpisodeLabel( + title = "Dark Matter", + seriesName = "Dark Matter", + episodeCode = "s01e01", + ), + ) + } + + @Test + fun `a long episode title survives to the view, which ellipsises it`() { + val label = playbackIdentityEpisodeLabel( + title = "The One Where They All Go To A Wedding And Nobody Says Anything", + seriesName = "Friends", + episodeCode = "S04E08", + ) + assertEquals( + "S04E08 — The One Where They All Go To A Wedding And Nobody Says Anything", + label, + ) + } + + @Test + fun `the transport owns the corner while it is up, and pause owns it outright`() { + // The ident and the transport's now-playing block are the same information in the + // same place: exactly one of them may draw. + assertEquals( + PlayerIdentitySlot.IDENT, + playerIdentitySlot(identWindowOpen = true, transportVisible = false, paused = false), + ) + assertEquals( + PlayerIdentitySlot.TRANSPORT, + playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = false), + ) + assertEquals( + PlayerIdentitySlot.NONE, + playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = true), + ) + assertEquals( + PlayerIdentitySlot.NONE, + playerIdentitySlot(identWindowOpen = false, transportVisible = false, paused = false), + ) + } + + @Test + fun `the ident opens once and is never raised a second time`() { + assertTrue( + shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = false), + ) + // A re-prepare, a recovery retry or a second report of "playback started" arrives + // with the ident already spent, and must not open another. + assertFalse( + shouldRaiseIdent(PlaybackIdentityPhase.SHOWING, transportVisible = false, paused = false), + ) + assertFalse( + shouldRaiseIdent(PlaybackIdentityPhase.DONE, transportVisible = false, paused = false), + ) + // And it is withheld outright where something else already answers the question. + assertFalse( + shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = true, paused = false), + ) + assertFalse( + shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = true), + ) + } + + private fun backdropRoot(activity: Activity): FrameLayout { + val root = FrameLayout(activity) + val backdrop = ImageView(activity).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + setImageBitmap( + javaClass.classLoader + ?.getResourceAsStream("home_hero_preview_art.png") + ?.use(BitmapFactory::decodeStream), + ) + } + root.addView( + backdrop, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + return root + } + private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.rgb(82, 181, 75)) } diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go index e0e92ba..6b49ef1 100644 --- a/server/cmd/memby-server/main.go +++ b/server/cmd/memby-server/main.go @@ -29,6 +29,7 @@ import ( "github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/mdblist" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/scheduler" @@ -262,7 +263,12 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro // takes all three: a handler that could not publish would have to check for nil at // every call site, which is exactly how an event comes to be silently dropped. adminBus := adminevents.New(st, log) - dispatcher := integrations.New(st, log, adminBus) + // The notification service is built before both the dispatcher and the server, because + // both write into it: the dispatcher records what it posted to Discord, and the server + // registers the in-app and broadcast providers on it. The store is its recorder, which + // is the whole audit trail. + notifier := notify.New(st, log) + dispatcher := integrations.New(st, log, adminBus, notifier) adminBus.AddSink(dispatcher) sched := scheduler.New(st, log, adminBus) @@ -286,6 +292,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro AdminEvents: adminBus, Scheduler: sched, Integrations: dispatcher, + Notify: notifier, LogLevel: logLevel, }) if err := server.LoadQuietTime(ctx); err != nil { diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 43ffa6e..d43c2dc 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -88,6 +88,11 @@ func (s *Server) adminRoutes() http.Handler { mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices)) mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail)) + // The outbound notification history. Distinct from the feed below it: that is the + // operator's own activity bell, this is the record of what Memby sent to viewers and + // to external services. + mux.Handle("GET /admin/api/notification-log", s.adminAuth(s.handleAdminNotificationLog)) + // The administrative feed behind the notification bell. mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications)) mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead)) diff --git a/server/internal/api/admin_accounts.go b/server/internal/api/admin_accounts.go index 64d2f39..cfdca1d 100644 --- a/server/internal/api/admin_accounts.go +++ b/server/internal/api/admin_accounts.go @@ -32,9 +32,13 @@ type adminOnboardingPreferences struct { } type adminMembyAccount struct { - ID string `json:"id"` - Username string `json:"username"` - Initials string `json:"initials"` + ID string `json:"id"` + Username string `json:"username"` + Initials string `json:"initials"` + // ShortName is the friendly name the launcher greets this person by, and is blank far + // more often than not — the directory reads it as "their account name" rather than as + // something missing. + ShortName string `json:"shortName"` CreatedAt time.Time `json:"createdAt"` LastSeen time.Time `json:"lastSeen"` Devices []store.MembyDevice `json:"devices"` @@ -153,8 +157,9 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { result = append(result, adminMembyAccount{ WatchTime: summariseWatchTime(watched, matchedWatchTime), ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, - Initials: stringPreference(accountSettings.Preferences, "profileInitials"), - LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings, + Initials: stringPreference(accountSettings.Preferences, "profileInitials"), + ShortName: stringPreference(accountSettings.Preferences, "shortName"), + LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings, Themes: nonNilStrings(themes[account.ID]), Notifications: notificationPrefs, Recommendations: adminOnboardingPreferences{ diff --git a/server/internal/api/admin_notification_log.go b/server/internal/api/admin_notification_log.go new file mode 100644 index 0000000..f1eb456 --- /dev/null +++ b/server/internal/api/admin_notification_log.go @@ -0,0 +1,138 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +// The console's window on the outbound notification log. +// +// One route rather than three, unlike the sign-in history: an operator arrives here with a +// *question* — "did the weekly summary go out", "why did nobody get told about that +// import" — and every part of the answer is the same filtered window. Splitting the table +// from its totals would mean two requests that could disagree with each other while a +// filter was being typed. +const ( + // notificationPageLimit caps one page. Large enough that the ordinary answer needs no + // paging, small enough that a household with a busy week does not send a megabyte. + notificationPageLimit = 100 + // notificationWindowDays is the widest window the page offers, derived from the + // retention period rather than written down: PruneNotificationLog removes anything + // older, so a page offering 180 days would draw a flat line for half of it. + notificationWindowDays = int(store.NotificationRetention / (24 * time.Hour)) +) + +type adminNotificationLogResponse struct { + Entries []store.NotificationLogEntry `json:"entries"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Totals store.NotificationLogTotals `json:"totals"` + Days []store.NotificationLogDay `json:"days"` + Facets store.NotificationFacets `json:"facets"` + Users []store.KnownUser `json:"users"` + Retention int `json:"retentionDays"` +} + +// notificationLogFilter reads the console's question off the query string. +// +// Every list filter is comma-separated and multi-valued, because the useful questions are +// plural: "everything that failed or was skipped", "both digest kinds". A single-valued +// filter would make the common troubleshooting question take two passes. +func notificationLogFilter(r *http.Request) store.NotificationLogFilter { + query := r.URL.Query() + filter := store.NotificationLogFilter{ + UserID: strings.TrimSpace(query.Get("user")), + Kinds: splitCSV(query.Get("kind")), + Channels: splitCSV(query.Get("channel")), + Statuses: splitCSV(query.Get("status")), + Sources: splitCSV(query.Get("source")), + Query: strings.TrimSpace(query.Get("q")), + Limit: queryInt(r, "limit", notificationPageLimit, 500), + Offset: queryInt(r, "offset", 0, 100000), + } + filter.From, filter.To = notificationWindow(r) + return filter +} + +// notificationWindow resolves the date range. +// +// An explicit `from` wins over the day window, the rule the sign-in history follows: an +// operator who typed a date meant it, and silently narrowing it to the last week would +// answer a question they did not ask. `to` is read as the *end* of the day named, because +// somebody filtering "to the 12th" means through the 12th, not up to midnight at its start. +func notificationWindow(r *http.Request) (time.Time, time.Time) { + query := r.URL.Query() + from := parseDay(query.Get("from")) + to := parseDay(query.Get("to")) + if !to.IsZero() { + to = to.AddDate(0, 0, 1) + } + if from.IsZero() { + days := queryInt(r, "days", 7, notificationWindowDays) + if days > 0 { + from = time.Now().UTC().AddDate(0, 0, -days) + } + } + return from, to +} + +func parseDay(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + day, err := time.Parse("2006-01-02", raw) + if err != nil { + return time.Time{} + } + return day +} + +// handleAdminNotificationLog answers the Notifications page. +// +// The log is the page and everything else is decoration, which is why only its failure is +// a 500: a facet list or a name lookup that will not answer costs a dropdown, and an +// operator reading this page after something went wrong must still get the rows. +func (s *Server) handleAdminNotificationLog(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + filter := notificationLogFilter(r) + + page, err := s.store.NotificationLog(ctx, filter) + if err != nil { + s.loggerFor(ctx).Error("notification log read failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read the notification history") + return + } + + response := adminNotificationLogResponse{ + Entries: page.Entries, Total: page.Total, Limit: page.Limit, Offset: page.Offset, + Days: []store.NotificationLogDay{}, Users: []store.KnownUser{}, + Retention: notificationWindowDays, + } + if totals, err := s.store.NotificationLogTotals(ctx, filter); err == nil { + response.Totals = totals + } else { + s.loggerFor(ctx).Warn("notification totals unavailable", "error", err) + } + if days, err := s.store.NotificationLogDays(ctx, filter); err == nil { + response.Days = days + } + // Facets are computed over the whole retention window rather than the current filter, + // so narrowing the table never removes the option that would widen it again. + since := time.Now().UTC().Add(-store.NotificationRetention) + if facets, err := s.store.NotificationLogFacets(ctx, since); err == nil { + response.Facets = facets + } else { + s.loggerFor(ctx).Warn("notification facets unavailable", "error", err) + } + if users, err := s.store.KnownUsers(ctx); err == nil { + response.Users = users + } + + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} diff --git a/server/internal/api/alerts.go b/server/internal/api/alerts.go index dd70ab0..ff15bcb 100644 --- a/server/internal/api/alerts.go +++ b/server/internal/api/alerts.go @@ -99,9 +99,25 @@ type storedAlert struct { // Failures are logged and swallowed. A missed banner is not worth failing the thing that // produced it — an import, a library sync, a health probe — none of which the viewer // would want retried for the sake of a notice. +// +// Producers no longer call this directly: they call Server.broadcast, which carries the +// same alert through the notification service so it lands in the audit trail beside every +// other thing Memby sent. This remains the delivery half of that path. func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) { + if err := s.publishAlertNow(ctx, alert, window); err != nil { + s.loggerFor(ctx).Warn("alert publish failed", "id", alert.ID, "error", err) + } +} + +// publishAlertNow is publishAlert with the failure returned rather than swallowed. +// +// The notification log needs the error — a banner nobody received is exactly the row an +// operator opens this feature to find — and swallowing it here would leave the audit trail +// reporting a success the cache never gave. Everything above still treats the answer as +// advisory; nothing retries on it. +func (s *Server) publishAlertNow(ctx context.Context, alert clientAlert, window time.Duration) error { if window <= 0 || alert.ID == "" || s.cache == nil { - return + return nil } // Read-modify-write on one key, so producers running on their own schedules need // serialising against each other. They are rare enough that a mutex is the whole @@ -113,14 +129,14 @@ func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window tim stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now) body, err := json.Marshal(stored) if err != nil { - s.loggerFor(ctx).Warn("alert encode failed", "error", err) - return + return fmt.Errorf("alert encode failed: %w", err) } // The key's own TTL is a floor sweep for a gateway that stops producing events; the // per-entry expiry is what actually decides what a client sees. if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil { - s.loggerFor(ctx).Warn("alert store failed", "error", err) + return fmt.Errorf("alert store failed: %w", err) } + return nil } // appendAlert is the pure half of publishing: prune what has expired, replace any earlier diff --git a/server/internal/api/analytics.go b/server/internal/api/analytics.go index 41bde37..a460630 100644 --- a/server/internal/api/analytics.go +++ b/server/internal/api/analytics.go @@ -34,18 +34,18 @@ type analyticsRequest struct { } type journeyEventPayload struct { - UserID string `json:"userId"` - JourneyID string `json:"journeyId"` - Sequence int `json:"sequence"` - Category string `json:"category"` - Action string `json:"action"` - Screen string `json:"screen"` - Feature string `json:"feature"` - Source string `json:"source"` - Target string `json:"target"` - ItemID string `json:"itemId"` - ItemName string `json:"itemName"` - ItemType string `json:"itemType"` + UserID string `json:"userId"` + JourneyID string `json:"journeyId"` + Sequence int `json:"sequence"` + Category string `json:"category"` + Action string `json:"action"` + Screen string `json:"screen"` + Feature string `json:"feature"` + Source string `json:"source"` + Target string `json:"target"` + ItemID string `json:"itemId"` + ItemName string `json:"itemName"` + ItemType string `json:"itemType"` // The Emby play session a playback step belongs to. Validated like every other // controlled field: it is Emby's string rather than ours, and an event carrying one this // cannot read is dropped whole, so the television sanitises it before sending. diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 98f6337..fa5ef8b 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -33,6 +33,7 @@ import ( "github.com/ponzischeme89/memby/server/internal/integrations" serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/mdblist" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/opensubtitles" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" @@ -71,7 +72,11 @@ type Server struct { adminEvents *adminevents.Bus scheduler *scheduler.Scheduler integrations *integrations.Dispatcher - sonarrMu sync.Mutex + // notify is the one door every outbound notification leaves through, and the only + // thing that writes the notification log. Producers never call a delivery provider + // directly any more — see internal/api/notifications.go. + notify *notify.Service + sonarrMu sync.Mutex // sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add // to My Shows never waits behind a launcher rebuilding the schedule row. sonarrSeriesMu sync.Mutex @@ -143,6 +148,10 @@ type Deps struct { AdminEvents *adminevents.Bus Scheduler *scheduler.Scheduler Integrations *integrations.Dispatcher + // Notify is optional. A server built without one still delivers every notification — + // Send falls through to the providers regardless — it simply records nothing, which is + // what every unit test in this package wants. + Notify *notify.Service // LogLevel is the live level of the process's own logger, so the console can turn // debug on and watch the thing it turned it on for. Nil is allowed and means the // level is fixed at whatever the container was started with. @@ -150,7 +159,7 @@ type Deps struct { } func New(cfg config.Config, deps Deps) *Server { - return &Server{ + server := &Server{ cfg: cfg, emby: deps.Emby, store: deps.Store, @@ -172,9 +181,16 @@ func New(cfg config.Config, deps Deps) *Server { scheduler: deps.Scheduler, integrations: deps.Integrations, + notify: deps.Notify, + logLevel: deps.LogLevel, deployedLogLevel: deployedLevel(deps.LogLevel), } + // The providers are installed here rather than by the caller so a producer can assume + // the channels it uses exist: a channel with no provider is a configuration fault the + // audit trail would faithfully record on every single notification. + server.registerNotifiers() + return server } func deployedLevel(level *slog.LevelVar) slog.Level { @@ -292,6 +308,7 @@ func (s *Server) Routes() http.Handler { // token arrives in the query string, the way artwork's does, because a media player // fetching a sidecar sends none of Memby's headers. v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle)) + v1.Handle("GET /v1/radarr/movies/{id}", s.authed(s.handleRadarrMovie)) v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers)) v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer)) diff --git a/server/internal/api/features.go b/server/internal/api/features.go index d114c7c..553cf5a 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -142,7 +142,7 @@ var featureCatalogue = []featureDefinition{ Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications", Description: "Tell each viewer how long they watched this week and this month, on " + "Sunday evening, with a summary of the month just gone once it ends. Read from " + - "Tracearr; a household running none never sends one.", + "Tracearr; a server running none never sends one.", DefaultEnabled: true, MinimumProtocol: 1, Recovery: "Server-enforced; takes effect before the next summary is due.", }, diff --git a/server/internal/api/housekeeping.go b/server/internal/api/housekeeping.go index bb4c353..8a7ec05 100644 --- a/server/internal/api/housekeeping.go +++ b/server/internal/api/housekeeping.go @@ -51,6 +51,22 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) { }, }) + // The outbound notification history, which is a different table from the one above: + // that prunes the operator's activity feed, this prunes the record of what Memby sent + // to viewers and to external services. + sched.Register(scheduler.Task{ + ID: "notification-log-retention", + Name: "Notification history retention", + Group: "Housekeeping", + Description: fmt.Sprintf("Removes outbound notification records older than %d days.", + int(store.NotificationRetention/(24*time.Hour))), + Interval: 24 * time.Hour, + Run: func(ctx context.Context) (string, error) { + removed, err := s.store.PruneNotificationLog(ctx, store.NotificationRetention) + return countDetail(removed, "notification record"), err + }, + }) + sched.Register(scheduler.Task{ ID: "device-activity-cleanup", Name: "Device activity cleanup", diff --git a/server/internal/api/ingest_alerts.go b/server/internal/api/ingest_alerts.go index 99c8736..9432de0 100644 --- a/server/internal/api/ingest_alerts.go +++ b/server/internal/api/ingest_alerts.go @@ -57,10 +57,14 @@ func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.Inges // episode. Neither is a title somebody can watch, and the episode that follows is. } +// The zero-window case is deliberately not short-circuited here any more. An operator who +// has switched movie import banners off is a reason nobody was told, and the notification +// log is where that answer belongs — deliverBroadcast records it as a skip rather than the +// producer returning in silence. func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) { window := s.radarrAlertWindow() title := strings.TrimSpace(result.Name) - if window <= 0 || title == "" || result.ItemID == "" { + if title == "" || result.ItemID == "" { return } now := time.Now().UTC() @@ -68,7 +72,7 @@ func (s *Server) announceImportedMovie(ctx context.Context, result library.Inges if result.Year > 0 { name = fmt.Sprintf("%s (%d)", title, result.Year) } - s.publishAlert(ctx, clientAlert{ + s.broadcast(ctx, notifySourceLibraryIngest, 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, @@ -90,7 +94,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing // 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 == "" { + if series == "" || result.ItemID == "" { return } now := time.Now().UTC() @@ -98,7 +102,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing seasonRunKey(series, result.Season), result.ItemID, episodeSummary(result), now, ingestRunWindow, ) - s.publishAlert(ctx, clientAlert{ + s.broadcast(ctx, notifySourceLibraryIngest, 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 diff --git a/server/internal/api/lifecycle_test.go b/server/internal/api/lifecycle_test.go index ca29ea1..2db622c 100644 --- a/server/internal/api/lifecycle_test.go +++ b/server/internal/api/lifecycle_test.go @@ -84,7 +84,7 @@ func buildSonarrRowForTest(t *testing.T, episode sonarr.Episode, now time.Time) func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem { t.Helper() - row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC) + row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC, nil) if err != nil { t.Fatalf("buildRadarrRow: %v", err) } diff --git a/server/internal/api/my_shows.go b/server/internal/api/my_shows.go index 4233c70..36b795c 100644 --- a/server/internal/api/my_shows.go +++ b/server/internal/api/my_shows.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -213,10 +214,18 @@ func (s *Server) syncReturnNotifications( message = show.Title + " returns next week." } sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02") - _ = s.store.UpsertNotification( - r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID, - "New episode coming", message, series.NextAiring, - ) + s.notifyUser(r.Context(), notify.Notification{ + Kind: "show-return", + Source: notifySourceShowReturn, + UserID: sess.EmbyUserID, + Username: sess.Username, + Title: "New episode coming", + Body: message, + ItemID: show.ItemID, + SourceKey: sourceKey, + EventAt: series.NextAiring, + Metadata: map[string]any{"show": show.Title, "leadDays": prefs.LeadDays}, + }) } } @@ -231,6 +240,12 @@ func (s *Server) handleNotificationAction( switch r.PathValue("action") { case "read": err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id) + // Marking a notification back to new is the viewer's own action, where "read" is set by + // the page merely focusing a row. That is why the two are separate routes rather than one + // carrying a boolean: an automatic mark and a deliberate one are different events, and only + // this one is ever a decision somebody made with the remote. + case "unread": + err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id) case "dismiss": err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id) default: diff --git a/server/internal/api/notifications.go b/server/internal/api/notifications.go new file mode 100644 index 0000000..c635fdc --- /dev/null +++ b/server/internal/api/notifications.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "errors" + "time" + + "github.com/ponzischeme89/memby/server/internal/notify" +) + +// The gateway's notification providers, and the two helpers every producer now calls. +// +// internal/notify owns the audit trail and knows nothing about Memby's channels; this file +// is the other half — what "in-app" and "broadcast" actually mean here. A feature says +// *what* it wants said; these decide how it is carried and what to report about it. +// +// The sources below name the service that decided to notify. They are stored and the +// console filters on them, so they are constants rather than string literals typed at each +// call site: a source spelled two ways is two rows in a dropdown for one feature. +const ( + notifySourceSonarrLifecycle = "sonarr-lifecycle" + notifySourceShowReturn = "show-return-scan" + notifySourceAutoFollow = "auto-follow" + notifySourceWatchTime = "watch-time-digest" + notifySourceLibraryIngest = "library-ingest" + notifySourceLibrarySync = "library-sync" + notifySourceDeployment = "deployment" + notifySourceEmbyHealth = "emby-health" + notifySourceIntegrations = "integrations" +) + +// registerNotifiers installs the gateway's delivery providers on the notification service. +// Called once from New, so every producer can assume the channels it uses exist. +func (s *Server) registerNotifiers() { + if s.notify == nil { + return + } + s.notify.Register( + notify.DelivererFunc{Name: notify.ChannelInApp, Fn: s.deliverInApp}, + notify.DelivererFunc{Name: notify.ChannelBroadcast, Fn: s.deliverBroadcast}, + ) +} + +// deliverInApp writes a notification into one viewer's own list. +// +// The three answers it can give are all real and all worth recording separately. A row was +// written: sent. A row with that source key was already there: skipped, because the +// producers here are deliberately re-run — the watch-time digest fires hourly and re-sends +// the same weekly key all evening so a gateway that was off still delivers — and every one +// of those catch-up passes would otherwise read as a summary somebody never got. And the +// write failed: failed, with the database's own words, which is the only thing that would +// explain a viewer's empty list. +func (s *Server) deliverInApp(ctx context.Context, n notify.Notification) notify.Outcome { + if s.store == nil { + return notify.Failed(errors.New("no database")) + } + if n.UserID == "" { + return notify.Failed(errors.New("an in-app notification needs a recipient")) + } + inserted, err := s.store.UpsertNotification( + ctx, n.UserID, n.SourceKey, n.Kind, n.ItemID, n.Title, n.Body, n.EventAt) + if err != nil { + return notify.Failed(err) + } + if !inserted { + return notify.Skipped("already in this viewer's list") + } + return notify.Sent() +} + +// broadcastWindow travels with a broadcast notification: how long the alert stays on offer +// to televisions that were switched off when it happened. +const broadcastWindowKey = "windowSeconds" + +// deliverBroadcast publishes a service alert to every signed-in television. +// +// The alert itself is carried in the notification's metadata rather than in its fields, +// because a clientAlert is a wire type with an id, a kind and an image tag that +// notify.Notification has no business modelling. broadcast() below is the only thing that +// builds one of these, so the round trip is contained. +func (s *Server) deliverBroadcast(ctx context.Context, n notify.Notification) notify.Outcome { + alert, ok := n.Metadata[broadcastAlertKey].(clientAlert) + if !ok { + return notify.Failed(errors.New("no alert to publish")) + } + window, _ := n.Metadata[broadcastWindowKey].(time.Duration) + if window <= 0 { + // An operator has this kind of news switched off. Deliberately a recorded skip + // rather than silence: "the window is zero" is the answer to why nobody was told, + // and it is not one anybody would find by reading the code. + return notify.Skipped("this alert window is switched off") + } + if s.cache == nil { + return notify.Failed(errors.New("no cache to publish alerts through")) + } + if err := s.publishAlertNow(ctx, alert, window); err != nil { + return notify.Failed(err) + } + return notify.Sent() +} + +// broadcastAlertKey is the metadata slot the clientAlert rides in. It is stripped before +// the record is written — the alert's fields are already the record's title and body, and +// storing the whole struct again would put a second copy of every banner in the log. +const broadcastAlertKey = "alert" + +// broadcast is what every service-alert producer calls in place of publishAlert. +// +// It is the one place a clientAlert becomes a notification, so the console's row for a +// banner says the same thing the television's bar said, with no producer having to +// describe its news twice. +func (s *Server) broadcast( + ctx context.Context, source string, alert clientAlert, window time.Duration, +) { + outcome := s.notify.Send(ctx, notify.Notification{ + Channel: notify.ChannelBroadcast, + Kind: alert.Kind, + Source: source, + Title: alert.Title, + Body: alert.Message, + ItemID: alert.ItemID, + SourceKey: alert.ID, + EventAt: alertEventTime(alert), + Metadata: map[string]any{ + broadcastAlertKey: alert, + broadcastWindowKey: window, + "label": alert.Label, + }, + }) + if outcome.Err != nil { + s.loggerFor(ctx).Warn("service alert not published", + "kind", alert.Kind, "id", alert.ID, "error", outcome.Err) + } +} + +func alertEventTime(alert clientAlert) *time.Time { + when, err := time.Parse(time.RFC3339, alert.AiredAt) + if err != nil { + return nil + } + return &when +} + +// notifyUser is what every per-viewer producer calls in place of store.UpsertNotification. +// +// It returns whether the notification actually reached the viewer's list, which is what +// the callers' own counters mean: the Sonarr scan reporting "14 notifications" must not +// count fourteen repeats of one it had already sent. +func (s *Server) notifyUser(ctx context.Context, n notify.Notification) bool { + n.Channel = notify.ChannelInApp + return s.notify.Send(ctx, n).Status == notify.StatusSent +} + +// declineUser records a notification a viewer's own preferences refused. +// +// This is the half a per-feature audit trail always misses, and it is the reason the page +// is worth having: "I never got the weekly summary" and "you have weekly summaries turned +// off" look identical from the outside, and only a recorded skip tells them apart. It is +// never delivered, so it goes through Log rather than Send. +func (s *Server) declineUser(ctx context.Context, n notify.Notification, reason string) { + n.Channel = notify.ChannelInApp + s.notify.Log(ctx, n, notify.Skipped(reason), 0) +} diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index b5b9d51..7b6a5ce 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -12,6 +12,7 @@ import ( "github.com/ponzischeme89/memby/server/internal/emby" serverlogging "github.com/ponzischeme89/memby/server/internal/logging" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -950,15 +951,30 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err) return "" } - if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) { - _ = s.store.UpsertNotification( - ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow", - episode.SeriesID, "Added to My Shows", - seriesItem.Name+" was added because you started watching it and it is still continuing.", nil, - ) - return seriesItem.Name + notification := notify.Notification{ + Kind: "auto-follow", + Source: notifySourceAutoFollow, + UserID: sess.EmbyUserID, + Username: sess.Username, + Title: "Added to My Shows", + Body: seriesItem.Name + " was added because you started watching it and it is still continuing.", + ItemID: episode.SeriesID, + SourceKey: "auto-follow:" + episode.SeriesID, + Metadata: map[string]any{"series": seriesItem.Name}, } - return "" + // The show is followed either way — that is the feature — and only the *notice* is + // conditional. Recording the refusal is what separates "Memby quietly followed this for + // you" from a bug, which from the viewer's side look the same. + if !prefs.Enabled { + s.declineUser(ctx, notification, "this viewer has notifications switched off") + return "" + } + if !s.featureEnabled(ctx, featureMyShowsNotification) { + s.declineUser(ctx, notification, "the My Shows notification feature is switched off") + return "" + } + s.notifyUser(ctx, notification) + return seriesItem.Name } func max64(v, floor int64) int64 { diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index 7ca4e85..4be1a40 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -61,8 +61,16 @@ type preferenceDefinition struct { // number reads as minutes, which is what the first one to exist happened to be. Unit string `json:"unit,omitempty"` MaxLength int `json:"maxLength,omitempty"` - AdminOnly bool `json:"adminOnly,omitempty"` - Default any `json:"default"` + // Uppercase folds a text value to capitals. It belongs to the definition rather than + // to the kind: initials are read as capitals, and a person's name is not — folding + // every text setting would have the launcher greeting somebody as MATT. + Uppercase bool `json:"uppercase,omitempty"` + // Placeholder is what the console shows in an empty field, which for these settings is + // what happens when nobody fills it in. Blank is a legal value for both of them, so the + // field has to say what blank means or it reads as a setting that was never finished. + Placeholder string `json:"placeholder,omitempty"` + AdminOnly bool `json:"adminOnly,omitempty"` + Default any `json:"default"` } func option(value, label string) preferenceOption { @@ -74,6 +82,17 @@ var preferenceCatalogue = []preferenceDefinition{ Key: "profileInitials", Name: "Profile initials", Area: "Profile", Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.", Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true, + Uppercase: true, Placeholder: "Generated from their name", + }, + { + // The friendly name Memby addresses somebody by, and nothing more: it is not a + // second username and nothing is keyed on it. Blank is the ordinary state — the + // television falls back to the account name — so this is only worth setting where + // the account name is not what anybody would call the person. + Key: "shortName", Name: "Short name", Area: "Profile", + Description: "The friendly name Memby greets this person by. Leave blank to use their account name.", + Kind: preferenceText, Default: "", MaxLength: shortNameMaxLength, AdminOnly: true, + Placeholder: "Their account name", }, { Key: "homeSections", Name: "Home rows", Area: "Home", @@ -237,6 +256,10 @@ var preferenceCatalogue = []preferenceDefinition{ }, } +// shortNameMaxLength bounds the friendly name. It is a first name on a launcher, not a +// field to write a sentence in, and the greeting it lands in shares its line with a clock. +const shortNameMaxLength = 24 + // maxListEntries bounds the free-form id lists. They come from a television, and a row // list long enough to matter is already a bug on that end. const maxListEntries = 200 @@ -334,7 +357,10 @@ func normalizePreference(definition preferenceDefinition, value any) any { trimmed := strings.TrimSpace(typed) if !strings.ContainsAny(trimmed, "\n\r") && (definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) { - return strings.ToUpper(trimmed) + if definition.Uppercase { + return strings.ToUpper(trimmed) + } + return trimmed } } } diff --git a/server/internal/api/preferences_test.go b/server/internal/api/preferences_test.go index 64e8ded..51f528a 100644 --- a/server/internal/api/preferences_test.go +++ b/server/internal/api/preferences_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "reflect" + "strings" "testing" ) @@ -41,6 +42,37 @@ func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) { } } +// A short name is a person's name, so unlike the initials beside it in the catalogue it +// keeps the case it was typed in. Folding it would have the launcher greeting somebody as +// MATT, which is the whole reason Uppercase is per-definition rather than per-kind. +func TestNormalizePreferencesKeepsShortNameCaseAndBoundsIt(t *testing.T) { + if got := normalizePreferences(map[string]any{"shortName": " Matt "})["shortName"]; got != "Matt" { + t.Errorf("shortName = %v, want Matt", got) + } + long := strings.Repeat("a", shortNameMaxLength+1) + for _, value := range []any{long, "Ma\ntt", 12} { + if got := normalizePreferences(map[string]any{"shortName": value})["shortName"]; got != "" { + t.Errorf("shortName for %v = %v, want the account-name fallback", value, got) + } + } + if got := normalizePreferences(nil)["shortName"]; got != "" { + t.Errorf("default shortName = %v, want blank", got) + } +} + +// The short name is admin-owned like the initials, so a television saving an unrelated +// setting must not be what quietly clears it. +func TestDevicePreferenceWritePreservesAdminShortName(t *testing.T) { + stored, err := json.Marshal(normalizePreferences(map[string]any{"shortName": "Matt"})) + if err != nil { + t.Fatal(err) + } + merged := preserveAdminPreferences(map[string]any{"showTitleLogo": false}, stored) + if normalizePreferences(merged)["shortName"] != "Matt" { + t.Errorf("shortName = %v, want preserved Matt", normalizePreferences(merged)["shortName"]) + } +} + func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) { stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"})) if err != nil { diff --git a/server/internal/api/radarr.go b/server/internal/api/radarr.go index 2b47f04..dd7ea31 100644 --- a/server/internal/api/radarr.go +++ b/server/internal/api/radarr.go @@ -56,6 +56,13 @@ type radarrScheduleItem struct { MembyLifecycle string `json:"MembyLifecycle,omitempty"` MembyLifecycleText string `json:"MembyLifecycleText,omitempty"` MembyPlayable bool `json:"MembyPlayable"` + // The Emby film this card stands for, when the library already holds it — the + // MembySeriesItemId arrangement, and for the same reason: it is what decides whether + // pressing the card opens the ordinary Memby page or the Radarr-only one. A film the + // household has not downloaded carries none. The detail route resolves it again from + // live data, because this row is cached for the day and a film imported at lunchtime + // must not be stuck behind a cache until midnight. + MembyMovieItemID string `json:"MembyMovieItemId,omitempty"` } func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) { @@ -90,7 +97,7 @@ func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, e if err != nil { return nil, err } - row, err := buildRadarrRow(movies, now, location) + row, err := buildRadarrRow(movies, now, location, s.embyMovieIndex(ctx, movies)) if err != nil { return nil, err } @@ -114,7 +121,37 @@ func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row return &row } -func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) { +// embyMovieIndex answers which of these films Emby already holds, keyed by TMDb id. +// +// Films are matched on the id both systems record rather than on their titles, which is +// what the Sonarr schedule row has to fall back on: Radarr writes a TMDb id and the +// library import asks Emby for ProviderIds, so there is nothing here to guess at. A +// failure is not fatal — the row is about what is coming, and losing the link only costs a +// downloaded card its ordinary detail page. +func (s *Server) embyMovieIndex(ctx context.Context, movies []radarr.Movie) map[int]string { + if s.store == nil || len(movies) == 0 { + return nil + } + ids := make([]int, 0, len(movies)) + for _, movie := range movies { + if movie.TMDBID > 0 { + ids = append(ids, movie.TMDBID) + } + } + found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", ids) + if err != nil { + s.loggerFor(ctx).Warn("emby movie index unavailable for schedule row", "error", err) + return nil + } + return found +} + +func buildRadarrRow( + movies []radarr.Movie, + now time.Time, + location *time.Location, + embyItems map[int]string, +) (*recommend.Row, error) { sort.SliceStable(movies, func(i, j int) bool { left, leftOK := effectiveRadarrRelease(movies[i]) right, rightOK := effectiveRadarrRelease(movies[j]) @@ -139,7 +176,9 @@ func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Locatio if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) { continue } - raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location)) + item := toRadarrScheduleItem(movie, release, now, location) + item.MembyMovieItemID = embyItems[movie.TMDBID] + raw, err := json.Marshal(item) if err != nil { return nil, err } diff --git a/server/internal/api/radarr_detail.go b/server/internal/api/radarr_detail.go new file mode 100644 index 0000000..381bbc6 --- /dev/null +++ b/server/internal/api/radarr_detail.go @@ -0,0 +1,306 @@ +package api + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/radarr" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// radarrItemPrefix is what a schedule card's id looks like: "radarr:412". The row has used +// it since the movie schedule shipped, and it is also what the trailer routes recognise — +// see radarrTrailerManifest — so a film with no Emby record can still be asked about +// through the ordinary /v1/items/{id}/trailers path. +const radarrItemPrefix = "radarr:" + +// radarrMovieDetail is everything the Radarr-only detail page draws. +// +// It is deliberately not a BaseItem. A film Radarr is tracking but Emby has never imported +// has no Emby record, no user data and nothing to play, and dressing it as one would put a +// Play button, a watched tick and a progress bar on a page where all three are lies. The +// television has a state of its own for this, and the moment Emby does hold the film +// [EmbyItemID] is what sends the viewer to the ordinary page instead. +// +// Every piece of wording here is the gateway's, the arrangement the schedule cards, the +// hero captions and the lifecycle tags already take: a phrasing invented next month reads +// correctly on a television that predates it. +type radarrMovieDetail struct { + ID string `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"originalTitle,omitempty"` + Overview string `json:"overview,omitempty"` + Year int `json:"year,omitempty"` + RuntimeMinutes int `json:"runtimeMinutes,omitempty"` + Genres []string `json:"genres"` + Studio string `json:"studio,omitempty"` + Certificate string `json:"certificate,omitempty"` + Monitored bool `json:"monitored"` + // Radarr's own lifecycle word, as the schedule card wears it: ANNOUNCED, IN CINEMAS, + // RELEASED. Distinct from [StateLabel], which is about the household's copy. + Lifecycle string `json:"lifecycle,omitempty"` + LifecycleText string `json:"lifecycleText,omitempty"` + // The subtle status treatment at the top of the page: "Coming Soon", "Awaiting + // Release", "Not Yet Available", with one line under it saying what that means here. + StateLabel string `json:"stateLabel"` + StateDetail string `json:"stateDetail,omitempty"` + // The one prominent date. "Expected 14 November 2026" when something has published the + // day, "Expected November 2026" when the day is inferred rather than published, and + // "Release date not yet announced" when nothing is known — never a precise-looking + // date standing in for a guess. + ExpectedLabel string `json:"expectedLabel"` + // Cinema, digital and physical dates as Radarr holds them, for the viewer who wants to + // know which of the three the headline came from. Any of them may be absent. + ReleaseDates []radarrReleaseDate `json:"releaseDates"` + // The sentence saying, in as many words, that this cannot be watched here yet. + AvailabilityNotice string `json:"availabilityNotice"` + // Whether the Trailer action should be offered at all. Deciding it here rather than on + // the television is what keeps the button from being one that fails after selection. + TrailerAvailable bool `json:"trailerAvailable"` + // Scores from the same store every other page reads, when this title has been looked + // up before. Empty is the honest answer and the strip simply does not appear. + Ratings []movieRating `json:"ratings"` + // Set once Emby holds the film. The television reopens on the ordinary detail page + // when it sees this, which is how a title stops being a Radarr card without anything + // having to be invalidated. + EmbyItemID string `json:"embyItemId,omitempty"` +} + +type radarrReleaseDate struct { + // cinema | digital | physical — a lookup key, not prose. + Kind string `json:"kind"` + Label string `json:"label"` + Value string `json:"value"` +} + +func (s *Server) handleRadarrMovie(w http.ResponseWriter, r *http.Request, sess store.Session) { + _ = sess + movieID, ok := radarrMovieID(r.PathValue("id")) + if !ok { + writeError(w, http.StatusBadRequest, "a radarr movie id is required") + return + } + if !s.radarrEnabled(r.Context()) { + writeError(w, http.StatusNotFound, "radarr is not available") + return + } + movie, err := s.radarrMovie(r.Context(), movieID) + if err != nil { + s.writeUpstreamError(r.Context(), w, err, "could not read that movie") + return + } + location := s.cfg.RadarrLocation + if location == nil { + location = time.Local + } + detail := buildRadarrMovieDetail(movie, time.Now().In(location), location) + detail.EmbyItemID = s.embyMovieItemID(r.Context(), movie.TMDBID) + detail.Ratings = s.radarrMovieRatings(r.Context(), movie) + writeJSON(w, http.StatusOK, detail) +} + +// radarrMovieID reads the movie out of either form of id: the card's own "radarr:412", and +// the bare number, because a caller holding the number should not have to know the prefix. +func radarrMovieID(raw string) (int, bool) { + trimmed := strings.TrimPrefix(strings.TrimSpace(raw), radarrItemPrefix) + id, err := strconv.Atoi(trimmed) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} + +// radarrMovie reads one film, preferring the household's cached catalogue. +// +// That catalogue is one request answering for every title, already shared across the house +// and already refreshed on its own schedule, so a detail page opening normally costs Radarr +// nothing at all. Asking directly is the fallback for a title added since it was read. +func (s *Server) radarrMovie(ctx context.Context, movieID int) (radarr.Movie, error) { + if movies, err := s.radarrMovieCatalogue(ctx); err == nil { + for _, movie := range movies { + if movie.ID == movieID { + return movie, nil + } + } + } + return s.radarr.Movie(ctx, movieID) +} + +// embyMovieItemID is embyMovieIndex for one title. A failure costs the redirect and never +// the page: the worst case is a Radarr page for a film Emby has quietly imported, which the +// next home refresh corrects. +func (s *Server) embyMovieItemID(ctx context.Context, tmdbID int) string { + if s.store == nil || tmdbID <= 0 { + return "" + } + found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", []int{tmdbID}) + if err != nil { + s.loggerFor(ctx).Warn("emby movie lookup failed for radarr detail", "error", err) + return "" + } + return found[tmdbID] +} + +// radarrMovieRatings reuses the household's ratings store rather than Radarr's own scores. +// Radarr carries a ratings block, but a page showing TMDb's number from Radarr here and +// MDBList's everywhere else would print two different scores for one film under one name. +func (s *Server) radarrMovieRatings(ctx context.Context, movie radarr.Movie) []movieRating { + settings, enabled := s.mdblistSettings(ctx) + if !enabled || s.mdblist == nil || s.store == nil { + return []movieRating{} + } + key := store.RatingKey{MediaType: "movie"} + switch { + case movie.TMDBID > 0: + key.Provider, key.ProviderID = "tmdb", strconv.Itoa(movie.TMDBID) + case strings.TrimSpace(movie.IMDBID) != "": + key.Provider, key.ProviderID = "imdb", strings.TrimSpace(movie.IMDBID) + default: + return []movieRating{} + } + ratings, err := s.loadMDBListRatings(ctx, settings.APIKey, key) + if err != nil { + s.logMDBListFailure(ctx, "ratings unavailable", radarrItemPrefix+strconv.Itoa(movie.ID), err) + return []movieRating{} + } + return selectedMovieRatings(settings.Sources, ratings) +} + +// buildRadarrMovieDetail is the whole of the page's wording, and it is pure so that every +// case a household can actually produce — a film with three dates, one with only a cinema +// date, one Radarr has never been given a date for at all — is answerable without a Radarr. +func buildRadarrMovieDetail(movie radarr.Movie, now time.Time, location *time.Location) radarrMovieDetail { + release, hasRelease := effectiveRadarrRelease(movie) + lifecycle := movieLifecycleTag(movie.Status) + detail := radarrMovieDetail{ + ID: radarrItemPrefix + strconv.Itoa(movie.ID), + Title: strings.TrimSpace(movie.Title), + Overview: strings.TrimSpace(movie.Overview), + Year: movie.Year, + RuntimeMinutes: movie.Runtime, + Genres: nonNilStrings(movie.Genres), + Studio: strings.TrimSpace(movie.Studio), + Certificate: strings.TrimSpace(movie.Certification), + Monitored: movie.Monitored, + Lifecycle: lifecycle.Status, + LifecycleText: lifecycle.Label, + ExpectedLabel: radarrExpectedLabel(release, hasRelease, now, location), + ReleaseDates: radarrReleaseDates(movie, location), + AvailabilityNotice: "Not available to watch in Memby yet", + TrailerAvailable: strings.TrimSpace(movie.YouTubeTrailerID) != "", + Ratings: []movieRating{}, + } + // Only when it says something the heading does not, the rule the ordinary Details pane + // already applies: a film whose original title is its title is the common case, and + // printing it is a row that reads as a mistake. + if original := strings.TrimSpace(movie.OriginalTitle); !strings.EqualFold(original, detail.Title) { + detail.OriginalTitle = original + } + detail.StateLabel, detail.StateDetail = radarrMovieState(movie, release, hasRelease, now) + return detail +} + +// radarrMovieState is the status treatment at the top of the page: two or three words for +// what this film is doing, and a line saying what that means to somebody who wanted to +// watch it tonight. +func radarrMovieState( + movie radarr.Movie, release radarrRelease, hasRelease bool, now time.Time, +) (string, string) { + switch { + case movie.HasFile: + // Downloaded, and yet this page is what opened — so Emby has not scanned it in + // yet. A matter of minutes rather than of months, and worth saying so. + return "Almost Ready", "Downloaded — waiting for Memby's library to pick it up" + case !movie.Monitored: + return "Not Tracked", "This film is not being monitored, so no copy is being sought" + case !hasRelease: + // Deliberately not "Release date not yet announced" — that is what the page has + // just printed as its headline, and the line under a state exists to add to it. + return "Awaiting Release", "Nothing to download until a date is announced" + case release.at.After(now): + return "Coming Soon", "Not released yet" + default: + return "Not Yet Available", "Released — waiting for a copy to arrive" + } +} + +// radarrExpectedLabel is the one date the page leads with, and most of its job is refusing +// to be precise about a date nothing has published. +// +// Radarr's digital date is a published fact and is printed to the day. The cinema-plus-a- +// month estimate the schedule row falls back on is not, so it is printed to the month: +// "Expected November 2026" is true where "Expected 14 November 2026" is a number somebody +// would plan an evening around. Nothing known at all is said plainly rather than guessed. +func radarrExpectedLabel( + release radarrRelease, hasRelease bool, now time.Time, location *time.Location, +) string { + if !hasRelease { + return "Release date not yet announced" + } + local := release.at.In(location) + verb := "Expected " + if !local.After(now.In(location)) { + verb = "Released " + } + if release.estimated { + return verb + local.Format("January 2006") + } + return verb + local.Format("2 January 2006") +} + +// radarrReleaseDates lists what Radarr actually holds, so a viewer can see which of the +// three the headline came from. Only dates that exist appear; an absent one is absent +// rather than dashed. +func radarrReleaseDates(movie radarr.Movie, location *time.Location) []radarrReleaseDate { + dates := []radarrReleaseDate{} + add := func(kind, label string, value *time.Time) { + if value == nil || value.IsZero() { + return + } + dates = append(dates, radarrReleaseDate{ + Kind: kind, + Label: label, + Value: value.In(location).Format("2 January 2006"), + }) + } + add("cinema", "In cinemas", movie.InCinemas) + add("digital", "Digital release", movie.DigitalRelease) + add("physical", "Physical release", movie.PhysicalRelease) + return dates +} + +// radarrTrailerManifest is the trailer chain for a film with no Emby record. +// +// It is the same manifest shape the ordinary path builds, so the television's existing +// trailer machinery — the availability check, the resolve call, the report, the player's +// candidate exclusion and its retry onto the next provider — works on a Radarr card with no +// second implementation anywhere. The one candidate is Radarr's own YouTube trailer id, +// which comes from TMDb's official trailer field and is ranked as an official source +// rather than as a spare. +func (s *Server) radarrTrailerManifest(ctx context.Context, itemID string, movieID int) (trailerManifest, error) { + manifest := trailerManifest{SubjectID: itemID, Candidates: []trailerCandidate{}} + if !s.radarrEnabled(ctx) { + return manifest, nil + } + movie, err := s.radarrMovie(ctx, movieID) + if err != nil { + return trailerManifest{}, err + } + manifest.Title = strings.TrimSpace(movie.Title) + trailerID := strings.TrimSpace(movie.YouTubeTrailerID) + if trailerID == "" { + return manifest, nil + } + source := "https://www.youtube.com/watch?v=" + trailerID + manifest.Candidates = append(manifest.Candidates, trailerCandidate{ + ID: trailerCandidateID("youtube", source), + Provider: "youtube", + Name: "Official Trailer", + SourceURL: source, + Priority: remoteTrailerPriority("youtube", "Official Trailer"), + }) + return manifest, nil +} diff --git a/server/internal/api/radarr_detail_test.go b/server/internal/api/radarr_detail_test.go new file mode 100644 index 0000000..4866c79 --- /dev/null +++ b/server/internal/api/radarr_detail_test.go @@ -0,0 +1,192 @@ +package api + +import ( + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/radarr" +) + +func radarrDetailDay(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) +} + +func TestRadarrMovieID(t *testing.T) { + for _, testCase := range []struct { + raw string + want int + ok bool + }{ + {raw: "radarr:412", want: 412, ok: true}, + {raw: " radarr:412 ", want: 412, ok: true}, + {raw: "412", want: 412, ok: true}, + {raw: "", ok: false}, + {raw: "radarr:", ok: false}, + {raw: "radarr:0", ok: false}, + {raw: "radarr:-3", ok: false}, + {raw: "abc123", ok: false}, + } { + id, ok := radarrMovieID(testCase.raw) + if ok != testCase.ok || id != testCase.want { + t.Fatalf("radarrMovieID(%q) = %d, %v; want %d, %v", + testCase.raw, id, ok, testCase.want, testCase.ok) + } + } +} + +// A published digital date is named to the day; the schedule row's cinema-plus-a-month +// estimate is named only to the month, because it is a guess and a guess printed as +// "14 November" is one somebody plans an evening around. +func TestRadarrExpectedLabelPrecision(t *testing.T) { + now := radarrDetailDay(2026, time.August, 19) + published := radarrRelease{at: radarrDetailDay(2026, time.November, 14)} + estimated := radarrRelease{at: radarrDetailDay(2026, time.November, 14), estimated: true} + past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)} + + if got := radarrExpectedLabel(published, true, now, time.UTC); got != "Expected 14 November 2026" { + t.Fatalf("published: %q", got) + } + if got := radarrExpectedLabel(estimated, true, now, time.UTC); got != "Expected November 2026" { + t.Fatalf("estimated: %q", got) + } + if got := radarrExpectedLabel(past, true, now, time.UTC); got != "Released 3 March 2026" { + t.Fatalf("past: %q", got) + } + if got := radarrExpectedLabel(radarrRelease{}, false, now, time.UTC); got != "Release date not yet announced" { + t.Fatalf("unknown: %q", got) + } +} + +func TestRadarrMovieStateReadsTheHouseholdsCopy(t *testing.T) { + now := radarrDetailDay(2026, time.August, 19) + future := radarrRelease{at: radarrDetailDay(2026, time.November, 14)} + past := radarrRelease{at: radarrDetailDay(2026, time.March, 3)} + + for _, testCase := range []struct { + name string + movie radarr.Movie + release radarrRelease + hasRelease bool + want string + }{ + {name: "coming soon", movie: radarr.Movie{Monitored: true}, release: future, hasRelease: true, want: "Coming Soon"}, + {name: "out but not here", movie: radarr.Movie{Monitored: true}, release: past, hasRelease: true, want: "Not Yet Available"}, + {name: "no date", movie: radarr.Movie{Monitored: true}, want: "Awaiting Release"}, + {name: "unmonitored", movie: radarr.Movie{}, release: future, hasRelease: true, want: "Not Tracked"}, + { + name: "downloaded but unscanned", + movie: radarr.Movie{Monitored: true, HasFile: true}, + release: past, + hasRelease: true, + want: "Almost Ready", + }, + } { + label, detail := radarrMovieState(testCase.movie, testCase.release, testCase.hasRelease, now) + if label != testCase.want { + t.Fatalf("%s: state = %q, want %q", testCase.name, label, testCase.want) + } + if detail == "" { + t.Fatalf("%s: a state with no explanation under it", testCase.name) + } + } +} + +func TestBuildRadarrMovieDetail(t *testing.T) { + cinema := radarrDetailDay(2026, time.October, 2) + digital := radarrDetailDay(2026, time.November, 14) + movie := radarr.Movie{ + ID: 412, + TMDBID: 9001, + Title: "The Quiet Coast", + OriginalTitle: "The Quiet Coast", + Overview: " A harbour town in winter. ", + Year: 2026, + Runtime: 118, + Genres: []string{"Drama", "Mystery"}, + Studio: "Kōwhai Pictures", + Certification: "M", + Status: "announced", + Monitored: true, + YouTubeTrailerID: "abc123", + InCinemas: &cinema, + DigitalRelease: &digital, + Images: []radarr.Image{{CoverType: "poster"}, {CoverType: "fanart"}}, + } + + detail := buildRadarrMovieDetail(movie, radarrDetailDay(2026, time.August, 19), time.UTC) + + if detail.ID != "radarr:412" { + t.Fatalf("id: %q", detail.ID) + } + if detail.Overview != "A harbour town in winter." { + t.Fatalf("overview: %q", detail.Overview) + } + // The same title twice is the common case and reads as a mistake when printed. + if detail.OriginalTitle != "" { + t.Fatalf("original title repeated: %q", detail.OriginalTitle) + } + if detail.ExpectedLabel != "Expected 14 November 2026" { + t.Fatalf("expected label: %q", detail.ExpectedLabel) + } + if detail.StateLabel != "Coming Soon" { + t.Fatalf("state: %q", detail.StateLabel) + } + if detail.LifecycleText != "ANNOUNCED" || detail.Lifecycle != "announced" { + t.Fatalf("lifecycle: %q/%q", detail.Lifecycle, detail.LifecycleText) + } + if !detail.TrailerAvailable { + t.Fatalf("a film with a trailer id must offer the action: %+v", detail) + } + if detail.AvailabilityNotice == "" { + t.Fatal("the page must say it cannot be watched here") + } + if len(detail.ReleaseDates) != 2 || + detail.ReleaseDates[0].Kind != "cinema" || detail.ReleaseDates[0].Value != "2 October 2026" || + detail.ReleaseDates[1].Kind != "digital" || detail.ReleaseDates[1].Value != "14 November 2026" { + t.Fatalf("release dates: %+v", detail.ReleaseDates) + } + // Never null on the wire: the television decodes these as lists. + if detail.Genres == nil || detail.Ratings == nil { + t.Fatalf("nil collections: %+v", detail) + } +} + +// A film with nothing but a cinema date is the case the precision rule exists for: the +// schedule row places it a month later, and the page must not present that as a fact. +func TestBuildRadarrMovieDetailCinemaOnly(t *testing.T) { + cinema := radarrDetailDay(2026, time.October, 2) + detail := buildRadarrMovieDetail( + radarr.Movie{ID: 7, Title: "Harbour Lights", InCinemas: &cinema, Monitored: true, Status: "inCinemas"}, + radarrDetailDay(2026, time.August, 19), + time.UTC, + ) + if detail.ExpectedLabel != "Expected November 2026" { + t.Fatalf("expected label: %q", detail.ExpectedLabel) + } + if len(detail.ReleaseDates) != 1 || detail.ReleaseDates[0].Kind != "cinema" { + t.Fatalf("release dates: %+v", detail.ReleaseDates) + } + if detail.TrailerAvailable { + t.Fatal("a film with no trailer id must not offer the action") + } +} + +func TestBuildRadarrMovieDetailUnannounced(t *testing.T) { + detail := buildRadarrMovieDetail( + radarr.Movie{ID: 9, Title: "Untitled", OriginalTitle: "Sans Titre", Monitored: true, Status: "tba"}, + radarrDetailDay(2026, time.August, 19), + time.UTC, + ) + if detail.ExpectedLabel != "Release date not yet announced" { + t.Fatalf("expected label: %q", detail.ExpectedLabel) + } + if detail.StateLabel != "Awaiting Release" { + t.Fatalf("state: %q", detail.StateLabel) + } + if len(detail.ReleaseDates) != 0 { + t.Fatalf("release dates: %+v", detail.ReleaseDates) + } + if detail.OriginalTitle != "Sans Titre" { + t.Fatalf("a differing original title is worth printing: %q", detail.OriginalTitle) + } +} diff --git a/server/internal/api/radarr_test.go b/server/internal/api/radarr_test.go index 5619bb5..efccf05 100644 --- a/server/internal/api/radarr_test.go +++ b/server/internal/api/radarr_test.go @@ -31,7 +31,7 @@ func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindo {ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true}, {ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true}, {ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true}, - }, now, location) + }, now, location, map[int]string{}) if err != nil { t.Fatal(err) } diff --git a/server/internal/api/server_alerts.go b/server/internal/api/server_alerts.go index 185deef..42e1a28 100644 --- a/server/internal/api/server_alerts.go +++ b/server/internal/api/server_alerts.go @@ -57,7 +57,7 @@ const ( // Nothing here waits for the deployment to finish. The gateway that publishes this is the // one being retired; the one that comes back has no memory of having said it. func (s *Server) AnnounceDeployment(ctx context.Context) { - s.publishAlert(ctx, deploymentAlert(time.Now().UTC()), deploymentAlertWindow) + s.broadcast(ctx, notifySourceDeployment, deploymentAlert(time.Now().UTC()), deploymentAlertWindow) } func deploymentAlert(now time.Time) clientAlert { @@ -84,7 +84,7 @@ func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result) return } now := time.Now().UTC() - s.publishAlert(ctx, clientAlert{ + s.broadcast(ctx, notifySourceLibrarySync, clientAlert{ // Keyed on the minute the sync finished: two runs are two pieces of news, but a // retried publish of the same run is not. ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()), @@ -153,13 +153,13 @@ func (s *Server) WatchEmbyReachability(ctx context.Context) { reachable = false s.log.Warn("emby unreachable, announcing", "component", "emby-health", "failures", failures, "error", err) - s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow) + s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(false), reachabilityAlertWindow) } continue } if !reachable { s.log.Info("emby reachable again, announcing", "component", "emby-health") - s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow) + s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(true), reachabilityAlertWindow) } reachable = true failures = 0 diff --git a/server/internal/api/sonarr_status.go b/server/internal/api/sonarr_status.go index 5578f7f..b042982 100644 --- a/server/internal/api/sonarr_status.go +++ b/server/internal/api/sonarr_status.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -95,21 +96,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error { } preferences[user.ID] = prefs } - if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts { - continue - } eventAt := change.Current.ObservedAt sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID) - message := change.Current.Title + " was added to Sonarr." - if err := s.store.UpsertNotification( - ctx, user.ID, sourceKey, "show-added", "", - "Show added", message, &eventAt, - ); err != nil { - s.log.Warn("Sonarr addition notification failed", - "user", user.ID, "show", change.Current.Title, "error", err) + notification := notify.Notification{ + Kind: "show-added", + Source: notifySourceSonarrLifecycle, + UserID: user.ID, + Username: user.Username, + Title: "Show added", + Body: change.Current.Title + " was added to Sonarr.", + SourceKey: sourceKey, + EventAt: &eventAt, + Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status}, + } + if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts { + s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID])) continue } - notifications++ + if s.notifyUser(ctx, notification) { + notifications++ + } } } for _, change := range cancellations { @@ -125,21 +131,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error { } preferences[user.ID] = prefs } - if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts { - continue - } eventAt := change.Current.ObservedAt sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID) - message := change.Current.Title + " is now listed as cancelled in Sonarr." - if err := s.store.UpsertNotification( - ctx, user.ID, sourceKey, "show-cancelled", "", - "Show cancelled", message, &eventAt, - ); err != nil { - s.log.Warn("Sonarr cancellation notification failed", - "user", user.ID, "show", change.Current.Title, "error", err) + notification := notify.Notification{ + Kind: "show-cancelled", + Source: notifySourceSonarrLifecycle, + UserID: user.ID, + Username: user.Username, + Title: "Show cancelled", + Body: change.Current.Title + " is now listed as cancelled in Sonarr.", + SourceKey: sourceKey, + EventAt: &eventAt, + Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status}, + } + if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts { + s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID])) continue } - notifications++ + if s.notifyUser(ctx, notification) { + notifications++ + } } } s.log.Info("Sonarr lifecycle scan complete", @@ -176,3 +187,21 @@ func sonarrBecameCancelled(previous, current string) bool { current == "cancelled" || current == "canceled" return active && cancelled } + +// sonarrDeclineReason is the sentence the console prints beside a skipped row. +// +// The three refusals are genuinely different answers to "why was I not told", and a page +// that collapsed them into "skipped" would send an operator to change a setting that was +// never the problem. A preference that would not load is its own case: it is read as "not +// now" rather than as consent, and that is a fact about the gateway rather than about the +// viewer. +func sonarrDeclineReason(prefs store.NotificationPreferences, unreadable bool) string { + switch { + case unreadable: + return "this viewer's notification preferences could not be read" + case !prefs.Enabled: + return "this viewer has notifications switched off" + default: + return "this viewer has Sonarr alerts switched off" + } +} diff --git a/server/internal/api/trailers.go b/server/internal/api/trailers.go index 94b03f4..1170f29 100644 --- a/server/internal/api/trailers.go +++ b/server/internal/api/trailers.go @@ -247,6 +247,13 @@ func (s *Server) resolveLocalTrailer( } func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) { + // A film Radarr is tracking has no Emby record to ask about local or remote trailers, + // so its chain is built from what Radarr knows. It joins here rather than beside the + // detail route because everything downstream — availability, resolve, report, the + // player's walk through the candidates — is then unchanged for both kinds of subject. + if movieID, ok := radarrMovieID(itemID); ok && strings.HasPrefix(itemID, radarrItemPrefix) { + return s.radarrTrailerManifest(ctx, itemID, movieID) + } key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID) if s.cache != nil { if raw, err := s.cache.Get(ctx, key); err == nil { diff --git a/server/internal/api/watch_time_digest.go b/server/internal/api/watch_time_digest.go index 53d2e68..65dd1e6 100644 --- a/server/internal/api/watch_time_digest.go +++ b/server/internal/api/watch_time_digest.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/scheduler" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -138,19 +139,31 @@ func (s *Server) sendWeeklyWatchTime( if total < watchTimeDigestFloor { continue } - if !s.watchTimeDigestWanted(ctx, account.ID) { - continue - } monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username) message := weeklyDigestMessage( total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle) - if err := s.store.UpsertNotification( - ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt, - ); err != nil { - s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err) + notification := notify.Notification{ + Kind: watchTimeWeeklyKind, + Source: notifySourceWatchTime, + UserID: account.ID, + Username: account.Username, + Title: "Your week in Memby", + Body: message, + SourceKey: key, + EventAt: &eventAt, + Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle}, + } + // The floor above is a judgement about the news; this is a judgement about the + // person, and only the second one is worth recording. "You have summaries switched + // off" is the answer to somebody reporting that they never get one, and it is not + // findable anywhere else. + if !s.watchTimeDigestWanted(ctx, account.ID) { + s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off") continue } - sent++ + if s.notifyUser(ctx, notification) { + sent++ + } } if sent > 0 { s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location)) @@ -183,18 +196,25 @@ func (s *Server) sendMonthlyWatchTime( if total < watchTimeDigestFloor { continue } - if !s.watchTimeDigestWanted(ctx, account.ID) { - continue - } message := monthlyDigestMessage(total, monthName, watched.TopTitle) - if err := s.store.UpsertNotification( - ctx, account.ID, key, watchTimeMonthlyKind, - "", monthName+" in Memby", message, &eventAt, - ); err != nil { - s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err) + notification := notify.Notification{ + Kind: watchTimeMonthlyKind, + Source: notifySourceWatchTime, + UserID: account.ID, + Username: account.Username, + Title: monthName + " in Memby", + Body: message, + SourceKey: key, + EventAt: &eventAt, + Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle, "month": monthID}, + } + if !s.watchTimeDigestWanted(ctx, account.ID) { + s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off") continue } - sent++ + if s.notifyUser(ctx, notification) { + sent++ + } } if sent > 0 { s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID) diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 8893a8e..920c3bd 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.55 +0.1.57 diff --git a/server/internal/integrations/integrations.go b/server/internal/integrations/integrations.go index ccb32b2..d88b17e 100644 --- a/server/internal/integrations/integrations.go +++ b/server/internal/integrations/integrations.go @@ -20,6 +20,7 @@ import ( "time" "github.com/ponzischeme89/memby/server/internal/adminevents" + "github.com/ponzischeme89/memby/server/internal/notify" "github.com/ponzischeme89/memby/server/internal/store" ) @@ -62,6 +63,11 @@ type Dispatcher struct { log *slog.Logger client *http.Client events *adminevents.Bus + // notify is the audit trail every outbound notification lands in. This package is the + // one producer that reports to it rather than being driven by it: the dispatcher has + // its own queue, pacing and transport registry, and routing deliveries through + // notify.Send would make the audit trail the thing deciding what Discord receives. + notify *notify.Service transports map[string]Transport queue chan job @@ -80,9 +86,12 @@ type Dispatcher struct { // SetPaused installs the server-wide quiet-time gate before Start is called. func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused } -func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher { +func New( + st *store.Store, log *slog.Logger, events *adminevents.Bus, notifier *notify.Service, +) *Dispatcher { dispatcher := &Dispatcher{ store: st, log: log.With("component", "integrations"), events: events, + notify: notifier, client: &http.Client{Timeout: requestTimeout}, transports: map[string]Transport{}, queue: make(chan job, queueDepth), @@ -211,11 +220,12 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev if err != nil { message = err.Error() } + took := time.Since(started) if d.store != nil { record := store.IntegrationDelivery{ IntegrationID: integration.ID, EventType: event.Type, Success: err == nil, StatusCode: status, - DurationMS: time.Since(started).Milliseconds(), Error: message, + DurationMS: took.Milliseconds(), Error: message, } if writeErr := d.store.RecordIntegrationDelivery( context.WithoutCancel(ctx), record, @@ -223,9 +233,53 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev d.log.Warn("delivery not recorded", "integration", integration.ID, "error", writeErr) } } + // The per-integration delivery history above answers "is this destination healthy", + // which is what the integrations page asks. This is the other question — "did Memby + // tell anybody about that event" — and it is answered in one place for every channel, + // which is the whole reason the notification log exists. + d.notify.Log(ctx, notify.Notification{ + Channel: notify.ChannelWebhook, + Kind: event.Type, + Source: "integrations", + Title: event.Title, + Body: event.Summary, + // The destination's NAME, never its address: a Discord webhook URL is the + // credential, and this row is rendered in the console. + Target: integration.Name, + SourceKey: integration.ID, + Metadata: map[string]any{ + "integrationId": integration.ID, + "kind": integration.Kind, + "statusCode": status, + }, + }, deliveryOutcome(status, err), took) return err } +// deliveryOutcome turns a transport's answer into an audit status. +// +// A webhook is the one channel that gets Delivered rather than Sent: somebody else's +// service actually acknowledged this, where writing a row into a viewer's list is +// finished the moment it returns with nobody to confirm it. The status code is kept in +// the detail because "failed" on its own sends an operator to the wrong place — a 404 is +// a webhook that has been deleted, a 429 is one that is merely busy. +func deliveryOutcome(status int, err error) notify.Outcome { + if err != nil { + if status > 0 { + return notify.Outcome{ + Status: notify.StatusFailed, + Detail: fmt.Sprintf("HTTP %d: %s", status, err.Error()), + Err: err, + } + } + return notify.Failed(err) + } + if status > 0 { + return notify.Delivered(fmt.Sprintf("HTTP %d", status)) + } + return notify.Delivered("") +} + // announceFailure puts a failed delivery back into the feed the operator is reading. // // It publishes a *different* type from the event that failed, and integration.failed is diff --git a/server/internal/notify/notify.go b/server/internal/notify/notify.go new file mode 100644 index 0000000..c9b193b --- /dev/null +++ b/server/internal/notify/notify.go @@ -0,0 +1,343 @@ +// Package notify is the one door every outbound notification leaves Memby through. +// +// Before it, each feature both decided to notify somebody and performed the delivery +// itself: the Sonarr lifecycle scanner wrote a row into user_notifications, the library +// ingester pushed a banner into Redis, the integrations dispatcher posted to Discord. Each +// knew how to deliver and none knew that the others existed, so the only way to answer +// "what did Memby send, to whom, and did it work" was to read three subsystems' log lines +// and hope every one of them had logged. +// +// The flow is now +// +// feature/event → notify.Service → Deliverer → notification log +// +// and the audit trail is a property of the door rather than something each feature +// remembers to do. A feature says *what* it wants said and to whom; which provider carries +// it, and the record of what happened, belong here. +// +// Two rules hold the package up: +// +// - **Logging never blocks delivery.** The record is written after the provider has +// already answered, on a context detached from the caller's, and a write that fails is +// logged and swallowed. A notification history that could suppress a notification would +// be worse than no history. +// - **Nothing secret is ever recorded.** A webhook's address is its credential, and an +// Emby token is a live upstream session; neither has any business in a table the +// console renders. Notification carries a Target — a destination's *name* — never its +// address, and Redact is the belt-and-braces pass before anything is stored. +package notify + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "time" +) + +// Channel is how a notification reaches somebody. It is stored, so it is part of the +// console's vocabulary: adding one means adding a Deliverer and nothing else. +type Channel string + +const ( + // ChannelInApp is a stored notification in one viewer's own list — My Alerts on the + // television. It follows the person to whichever set they sign into. + ChannelInApp Channel = "in-app" + // ChannelBroadcast is a service alert: the bar every signed-in television draws off + // the /v1/status poll. It has no recipient, because the recipient is the household. + ChannelBroadcast Channel = "broadcast" + // ChannelWebhook is an outbound HTTP delivery to somebody else's service — Discord + // today, and whatever the integrations package learns to speak next. + ChannelWebhook Channel = "webhook" +) + +// Status is what became of one notification. +// +// Sent and Delivered are deliberately different answers. Most of Memby's channels are +// stores rather than transports — writing a row into somebody's list is done the moment it +// returns, and there is nobody to acknowledge it — so those report Sent. Delivered is +// reserved for a provider that actually confirmed receipt, which today means a webhook +// that answered 2xx. Collapsing the two would make the console claim an acknowledgement +// that nothing ever gave. +type Status string + +const ( + StatusSent Status = "sent" + StatusDelivered Status = "delivered" + StatusFailed Status = "failed" + StatusPending Status = "pending" + // StatusSkipped is a notification that was deliberately not delivered, and it is the + // most useful row on the page: a viewer's preferences declined it, a duplicate was + // suppressed by its source key, or an operator has the window switched off. Without + // it, "Memby never told me" and "Memby decided not to tell you" are the same silence. + StatusSkipped Status = "skipped" +) + +// Notification is what a feature asks for. It describes the news, never the transport. +type Notification struct { + // Channel selects the provider. + Channel Channel + // Kind is the notification type: "show-return", "watch-time-week", "sonarr-import". + // It is the client's vocabulary too, so it is passed through rather than translated. + Kind string + // Source names the service that decided to send this — "sonarr-lifecycle", + // "watch-time-digest", "library-ingest". It answers "why did this arrive", which the + // kind alone often cannot: two features can legitimately produce the same kind. + Source string + // UserID and Username identify the recipient. Both empty is a household broadcast, + // which is a real answer rather than a missing one. + UserID string + Username string + Title string + Body string + // ItemID links the notification to a title, where there is one. + ItemID string + // Target names a destination that is not a person — an integration's name, for a + // webhook. Never its address: see the package comment. + Target string + // SourceKey is the caller's idempotency key where it has one. It is what lets the + // console explain a skipped row as "already sent" rather than as an unexplained gap. + SourceKey string + // EventAt is when the news happened, where that differs from when it was sent — an + // episode's broadcast time, a digest's period end. + EventAt *time.Time + // Metadata is free-form context for the detail view. Keep it small and keep it + // non-secret; Redact drops anything whose key looks like a credential. + Metadata map[string]any +} + +// Outcome is what the provider reported. +type Outcome struct { + Status Status + // Detail is the failure or the reason, and it is the whole value of the detail view: + // "429 Too Many Requests", "the viewer has summaries switched off", "already sent". + Detail string + // Err is the delivery error where there was one, returned to the caller so a feature + // that wants to react to a failure still can. It is never itself the audit trail. + Err error +} + +// Sent is the ordinary success for a store-shaped channel. +func Sent() Outcome { return Outcome{Status: StatusSent} } + +// Delivered is for a provider that confirmed receipt. +func Delivered(detail string) Outcome { return Outcome{Status: StatusDelivered, Detail: detail} } + +// Failed records a delivery that was attempted and did not work. +func Failed(err error) Outcome { + if err == nil { + return Outcome{Status: StatusFailed, Detail: "delivery failed"} + } + return Outcome{Status: StatusFailed, Detail: err.Error(), Err: err} +} + +// Skipped records a notification deliberately not delivered, with the reason. +func Skipped(reason string) Outcome { return Outcome{Status: StatusSkipped, Detail: reason} } + +// Deliverer is one channel's provider. A new channel is a type implementing this and a +// Register call; nothing else in the package has a case per channel. +type Deliverer interface { + Channel() Channel + Deliver(ctx context.Context, n Notification) Outcome +} + +// DelivererFunc adapts a plain function, which is what every provider in the gateway is: +// a small closure over an existing subsystem. +type DelivererFunc struct { + Name Channel + Fn func(ctx context.Context, n Notification) Outcome +} + +func (d DelivererFunc) Channel() Channel { return d.Name } + +func (d DelivererFunc) Deliver(ctx context.Context, n Notification) Outcome { + return d.Fn(ctx, n) +} + +// Record is one row of the audit trail — the notification as it was asked for, plus what +// happened to it. +type Record struct { + OccurredAt time.Time + Channel Channel + Kind string + Source string + UserID string + Username string + Title string + Body string + ItemID string + Target string + SourceKey string + Status Status + Detail string + DurationMS int64 + EventAt *time.Time + Metadata json.RawMessage +} + +// Recorder is the audit trail's storage. An interface rather than *store.Store so the +// package can be tested without a database, and so a Service built with no recorder — every +// unit test of a feature that notifies — still delivers. +type Recorder interface { + RecordNotification(ctx context.Context, record Record) error +} + +// recordTimeout bounds the audit write. It is short on purpose: the notification has +// already been delivered by the time this runs, so a slow database must cost the history +// rather than hold up the feature that produced the news. +const recordTimeout = 5 * time.Second + +// Service is the door. One instance, created at start-up. +type Service struct { + recorder Recorder + log *slog.Logger + deliverers map[Channel]Deliverer + now func() time.Time +} + +func New(recorder Recorder, log *slog.Logger) *Service { + if log == nil { + log = slog.Default() + } + return &Service{ + recorder: recorder, + log: log.With("component", "notify"), + deliverers: map[Channel]Deliverer{}, + now: time.Now, + } +} + +// Register installs a provider. Called at start-up only; the map is not guarded because +// nothing registers after the first request is served. +func (s *Service) Register(deliverers ...Deliverer) { + if s == nil { + return + } + for _, deliverer := range deliverers { + if deliverer != nil { + s.deliverers[deliverer.Channel()] = deliverer + } + } +} + +// Send delivers a notification and records what happened. +// +// The order is the design: deliver, then record. A history written first would be a claim +// rather than a record, and one written inside the delivery path would be able to fail the +// delivery. A Service that is nil, or has no provider for the channel, still answers — a +// feature must never have to nil-check the notification layer. +func (s *Service) Send(ctx context.Context, n Notification) Outcome { + if s == nil { + return Skipped("notifications are not configured") + } + n = Redact(n) + started := s.now() + deliverer, ok := s.deliverers[n.Channel] + var outcome Outcome + if !ok { + // A missing provider is a configuration fault, not a delivery failure, and it is + // worth a row: a console showing every "show-return" as failed on a gateway with + // no in-app provider is what would send somebody looking in the right place. + outcome = Failed(errNoDeliverer{channel: n.Channel}) + } else { + outcome = deliverer.Deliver(ctx, n) + } + s.record(ctx, n, outcome, s.now().Sub(started)) + return outcome +} + +// Log records a notification that some other code path delivered. +// +// It exists for the one producer that cannot reasonably be inverted: the integrations +// dispatcher is a subscriber on the admin event bus with its own queue, pacing and +// transport registry, and routing its deliveries back out through Send would make the +// audit trail the thing that decides what Discord receives. It posts, then says what +// happened. Prefer Send everywhere a feature is the one deciding to notify. +func (s *Service) Log(ctx context.Context, n Notification, outcome Outcome, took time.Duration) { + if s == nil { + return + } + s.record(ctx, Redact(n), outcome, took) +} + +func (s *Service) record(ctx context.Context, n Notification, outcome Outcome, took time.Duration) { + if s.recorder == nil { + return + } + record := Record{ + OccurredAt: s.now().UTC(), + Channel: n.Channel, + Kind: n.Kind, + Source: n.Source, + UserID: n.UserID, + Username: n.Username, + Title: n.Title, + Body: n.Body, + ItemID: n.ItemID, + Target: n.Target, + SourceKey: n.SourceKey, + Status: outcome.Status, + Detail: outcome.Detail, + DurationMS: took.Milliseconds(), + EventAt: n.EventAt, + } + if len(n.Metadata) > 0 { + if raw, err := json.Marshal(n.Metadata); err == nil { + record.Metadata = raw + } + } + // Detached from the caller's context, for the reason the search recorder is: a + // television that navigated away, or a request that timed out, still sent this. + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout) + defer cancel() + if err := s.recorder.RecordNotification(writeCtx, record); err != nil { + s.log.Warn("notification not recorded", + "channel", n.Channel, "kind", n.Kind, "status", outcome.Status, "error", err) + } +} + +type errNoDeliverer struct{ channel Channel } + +func (e errNoDeliverer) Error() string { + return "no delivery provider for the " + string(e.channel) + " channel" +} + +// secretish matches a metadata key whose value must never be stored. +// +// The rule is a substring match on the key rather than an inspection of the value, +// deliberately: a token is not recognisable by looking at it, and the one thing a caller +// reliably gets right is what they called the field. +var secretish = []string{ + "token", "secret", "password", "apikey", "api_key", "credential", + "webhook", "url", "authorization", +} + +// Redact is the last thing between a notification and the audit trail. +// +// Callers are already expected not to put a credential in a Notification — Target is a +// destination's name and never its address — and this is what makes that a property of the +// package rather than of every caller's diligence. +func Redact(n Notification) Notification { + if len(n.Metadata) == 0 { + return n + } + cleaned := make(map[string]any, len(n.Metadata)) + for key, value := range n.Metadata { + if isSecretKey(key) { + continue + } + cleaned[key] = value + } + n.Metadata = cleaned + return n +} + +func isSecretKey(key string) bool { + lowered := strings.ToLower(key) + for _, needle := range secretish { + if strings.Contains(lowered, needle) { + return true + } + } + return false +} diff --git a/server/internal/notify/notify_test.go b/server/internal/notify/notify_test.go new file mode 100644 index 0000000..ac5a475 --- /dev/null +++ b/server/internal/notify/notify_test.go @@ -0,0 +1,248 @@ +package notify + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "testing" + "time" +) + +func quiet() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +type capture struct { + records []Record + err error +} + +func (c *capture) RecordNotification(_ context.Context, record Record) error { + c.records = append(c.records, record) + return c.err +} + +func service(t *testing.T, recorder Recorder, fn func(context.Context, Notification) Outcome) *Service { + t.Helper() + s := New(recorder, quiet()) + if fn != nil { + s.Register(DelivererFunc{Name: ChannelInApp, Fn: fn}) + } + return s +} + +func TestSendDeliversThenRecords(t *testing.T) { + recorder := &capture{} + delivered := false + s := service(t, recorder, func(context.Context, Notification) Outcome { + // The record must not exist yet: the whole ordering claim is that a notification is + // delivered first and described afterwards. + if len(recorder.records) != 0 { + t.Fatal("the audit trail was written before the notification was delivered") + } + delivered = true + return Sent() + }) + + outcome := s.Send(context.Background(), Notification{ + Channel: ChannelInApp, Kind: "show-return", Source: "test", + UserID: "u1", Username: "Ada", Title: "New episode coming", + }) + + if !delivered { + t.Fatal("the notification was never delivered") + } + if outcome.Status != StatusSent { + t.Fatalf("status = %q, want sent", outcome.Status) + } + if len(recorder.records) != 1 { + t.Fatalf("recorded %d rows, want 1", len(recorder.records)) + } + record := recorder.records[0] + if record.UserID != "u1" || record.Kind != "show-return" || record.Status != StatusSent { + t.Fatalf("record = %+v", record) + } + if record.OccurredAt.IsZero() { + t.Fatal("the record carries no timestamp") + } +} + +// A history that could suppress a notification would be worse than no history, so a +// recorder that will not write must not change what the caller is told. +func TestRecorderFailureDoesNotAffectDelivery(t *testing.T) { + recorder := &capture{err: errors.New("postgres is down")} + s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) + + outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"}) + + if outcome.Status != StatusSent { + t.Fatalf("status = %q, want sent despite the failed write", outcome.Status) + } +} + +// The producers here are deliberately re-run — the digest job fires hourly and re-sends the +// same weekly key all evening — so a cancelled caller must not be able to lose the record +// of the one pass that actually delivered. +func TestRecordSurvivesACancelledCaller(t *testing.T) { + recorder := &capture{} + s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.Send(ctx, Notification{Channel: ChannelInApp, UserID: "u1"}) + + if len(recorder.records) != 1 { + t.Fatalf("recorded %d rows, want 1 from a cancelled caller", len(recorder.records)) + } +} + +func TestSkippedIsRecordedWithItsReason(t *testing.T) { + recorder := &capture{} + s := service(t, recorder, func(context.Context, Notification) Outcome { + return Skipped("this viewer has summaries switched off") + }) + + outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp, UserID: "u1"}) + + if outcome.Status != StatusSkipped { + t.Fatalf("status = %q, want skipped", outcome.Status) + } + if got := recorder.records[0].Detail; got != "this viewer has summaries switched off" { + t.Fatalf("detail = %q; a skip with no reason is the row this page exists to avoid", got) + } +} + +// A channel with no provider is a configuration fault, and it is worth a row: silence would +// look exactly like a household in which nothing happened. +func TestMissingProviderIsRecordedAsAFailure(t *testing.T) { + recorder := &capture{} + s := service(t, recorder, nil) + + outcome := s.Send(context.Background(), Notification{Channel: ChannelBroadcast, Kind: "x"}) + + if outcome.Status != StatusFailed { + t.Fatalf("status = %q, want failed", outcome.Status) + } + if len(recorder.records) != 1 || recorder.records[0].Detail == "" { + t.Fatalf("records = %+v, want one row naming the missing provider", recorder.records) + } +} + +// A nil service is what every unit test of a producing feature holds. It must answer rather +// than panic, or every call site grows a nil check — which is how a notification comes to be +// silently dropped. +func TestNilServiceStillAnswers(t *testing.T) { + var s *Service + if outcome := s.Send(context.Background(), Notification{Channel: ChannelInApp}); outcome.Status != StatusSkipped { + t.Fatalf("status = %q, want skipped from a nil service", outcome.Status) + } + s.Log(context.Background(), Notification{}, Sent(), 0) + s.Register(DelivererFunc{Name: ChannelInApp}) +} + +// A service with no recorder still delivers. This is the shape a gateway built without a +// database has, and the shape most feature tests want. +func TestNoRecorderStillDelivers(t *testing.T) { + delivered := false + s := New(nil, quiet()) + s.Register(DelivererFunc{Name: ChannelInApp, Fn: func(context.Context, Notification) Outcome { + delivered = true + return Sent() + }}) + + if s.Send(context.Background(), Notification{Channel: ChannelInApp}).Status != StatusSent { + t.Fatal("delivery reported something other than sent") + } + if !delivered { + t.Fatal("the notification was not delivered without a recorder") + } +} + +func TestLogRecordsWithoutDelivering(t *testing.T) { + recorder := &capture{} + called := false + s := service(t, recorder, func(context.Context, Notification) Outcome { + called = true + return Sent() + }) + + s.Log(context.Background(), Notification{ + Channel: ChannelWebhook, Kind: "login.failed", Target: "Family Discord", + }, Delivered("HTTP 204"), 120*time.Millisecond) + + if called { + t.Fatal("Log delivered the notification; it must only record one somebody else sent") + } + record := recorder.records[0] + if record.Status != StatusDelivered || record.Detail != "HTTP 204" { + t.Fatalf("record = %+v", record) + } + if record.DurationMS != 120 { + t.Fatalf("durationMs = %d, want 120", record.DurationMS) + } +} + +// The audit trail is rendered in the console, so anything that looks like a credential must +// never reach it — regardless of how careful the caller was. +func TestRedactDropsCredentialShapedMetadata(t *testing.T) { + cleaned := Redact(Notification{Metadata: map[string]any{ + "integrationId": "disc-1", + "webhookUrl": "https://discord.com/api/webhooks/123/s3cr3t", + "apiKey": "abcd", + "embyToken": "live-session", + "Authorization": "Bearer x", + "statusCode": 204, + }}) + + for _, banned := range []string{"webhookUrl", "apiKey", "embyToken", "Authorization"} { + if _, present := cleaned.Metadata[banned]; present { + t.Errorf("%q survived redaction", banned) + } + } + if cleaned.Metadata["integrationId"] != "disc-1" || cleaned.Metadata["statusCode"] != 204 { + t.Fatalf("redaction dropped ordinary context: %+v", cleaned.Metadata) + } +} + +func TestSendRedactsBeforeRecording(t *testing.T) { + recorder := &capture{} + s := service(t, recorder, func(context.Context, Notification) Outcome { return Sent() }) + + s.Send(context.Background(), Notification{ + Channel: ChannelInApp, + UserID: "u1", + Metadata: map[string]any{"series": "The Bear", "webhookUrl": "https://example.test/hook"}, + }) + + var stored map[string]any + if err := json.Unmarshal(recorder.records[0].Metadata, &stored); err != nil { + t.Fatalf("metadata did not round-trip: %v", err) + } + if _, present := stored["webhookUrl"]; present { + t.Fatal("a credential-shaped key reached the audit trail through Send") + } + if stored["series"] != "The Bear" { + t.Fatalf("stored metadata = %+v", stored) + } +} + +// The provider still receives the notification it was handed; redaction is about what is +// stored, not about what is delivered. +func TestRedactionDoesNotChangeWhatIsDelivered(t *testing.T) { + var seen Notification + s := service(t, &capture{}, func(_ context.Context, n Notification) Outcome { + seen = n + return Sent() + }) + + s.Send(context.Background(), Notification{ + Channel: ChannelInApp, UserID: "u1", Title: "Your week in Memby", + Body: "You watched 4 hours.", + }) + + if seen.Title != "Your week in Memby" || seen.Body != "You watched 4 hours." { + t.Fatalf("the provider received %+v", seen) + } +} diff --git a/server/internal/radarr/client.go b/server/internal/radarr/client.go index dec5845..3ae680d 100644 --- a/server/internal/radarr/client.go +++ b/server/internal/radarr/client.go @@ -44,7 +44,15 @@ type Movie struct { InCinemas *time.Time `json:"inCinemas"` // Radarr's own lifecycle word for the title: tba, announced, inCinemas, released, // deleted. It is what the schedule card's lifecycle tag says. - Status string `json:"status"` + Status string `json:"status"` + // Metadata Radarr carries for a film the household does not hold yet, and which + // therefore has no Emby record to read it from. It is the whole substance of the + // Radarr-only detail page; the schedule card itself uses none of it. + OriginalTitle string `json:"originalTitle,omitempty"` + Studio string `json:"studio,omitempty"` + Certification string `json:"certification,omitempty"` + YouTubeTrailerID string `json:"youTubeTrailerId,omitempty"` + IMDBID string `json:"imdbId,omitempty"` HasFile bool `json:"hasFile"` Monitored bool `json:"monitored"` MovieFile *MovieFile `json:"movieFile"` @@ -139,6 +147,21 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, e return movies, nil } +// Movie is one tracked film, for the case the cached catalogue cannot answer: a title +// added to Radarr since the catalogue was last read. The catalogue is still tried first — +// this is the fallback, not the ordinary path, because a detail page opening must not cost +// a round trip Radarr has already answered once for the whole household. +func (c *Client) Movie(ctx context.Context, movieID int) (Movie, error) { + if movieID <= 0 { + return Movie{}, fmt.Errorf("radarr: invalid movie id") + } + var movie Movie + if err := c.get(ctx, "/api/v3/movie/"+strconv.Itoa(movieID), &movie); err != nil { + return Movie{}, err + } + return movie, nil +} + func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) { req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}}) if err != nil { diff --git a/server/internal/store/my_shows.go b/server/internal/store/my_shows.go index f53fd53..fdafbb1 100644 --- a/server/internal/store/my_shows.go +++ b/server/internal/store/my_shows.go @@ -281,16 +281,26 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti return result, rows.Err() } +// UpsertNotification writes one notification into a viewer's own list. +// +// It reports whether a row was actually inserted, which is what separates the two answers +// the source key produces: a genuine delivery, and a repeat of one already sitting in +// somebody's list. Both are ordinary — the digest job runs hourly and re-sends the same +// weekly key all evening on purpose — but the notification log has to be able to tell them +// apart, or every catch-up run would read as a second summary nobody received. func (s *Store) UpsertNotification( ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time, -) error { - _, err := s.pool.Exec(ctx, ` +) (bool, error) { + tag, err := s.pool.Exec(ctx, ` INSERT INTO user_notifications (emby_user_id, source_key, kind, item_id, title, message, event_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (emby_user_id, source_key) DO NOTHING`, userID, sourceKey, kind, itemID, title, message, eventAt) - return err + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil } func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) { @@ -325,6 +335,19 @@ func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int6 return err } +// MarkNotificationUnread puts a notification back to new. +// +// The counterpart to MarkNotificationRead, and deliberately a plain assignment rather than +// that one's COALESCE: read is sticky because it is set by merely looking at a row, so a +// second glance must not move the timestamp, while unread is only ever the viewer saying so +// and means exactly one thing. +func (s *Store) MarkNotificationUnread(ctx context.Context, userID string, id int64) error { + _, err := s.pool.Exec(ctx, ` + UPDATE user_notifications SET read_at = NULL + WHERE id = $1 AND emby_user_id = $2`, id, userID) + return err +} + func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error { _, err := s.pool.Exec(ctx, ` UPDATE user_notifications SET dismissed_at = now() diff --git a/server/internal/store/notifications.go b/server/internal/store/notifications.go new file mode 100644 index 0000000..e3c14cb --- /dev/null +++ b/server/internal/store/notifications.go @@ -0,0 +1,378 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/notify" +) + +// The notification log: what Memby sent, to whom, over which channel, and what became of +// it. Written by internal/notify — nothing else writes this table, which is the whole +// point of it — and read only by the console. +// +// It is deliberately a separate table from user_notifications rather than a set of extra +// columns on it. That table is *state*: one viewer's undismissed list, which they empty. +// This is *history*: it keeps a row for a notification that was dismissed, for one that +// was never delivered, and for a broadcast that belongs to no viewer at all — none of +// which the other table can represent. + +// NotificationRetention is how far back the log goes. Ninety days is long enough that a +// question about "the summary I never got last month" is still answerable, and short +// enough that the table cannot outgrow the database on a household gateway. The +// housekeeping task prunes to it; the console derives its widest window from it, so the +// page can never offer a range the data does not cover. +const NotificationRetention = 90 * 24 * time.Hour + +// notificationTextLimit bounds a stored string. A notification body is a sentence or two +// by construction, and this is only here so a bug in a producer cannot write a megabyte +// per row into the audit trail. +const notificationTextLimit = 2000 + +// NotificationLogEntry is one delivered — or refused — notification as the console reads +// it. +type NotificationLogEntry struct { + ID int64 `json:"id"` + OccurredAt time.Time `json:"occurredAt"` + Channel string `json:"channel"` + Kind string `json:"kind"` + Source string `json:"source"` + UserID string `json:"userId,omitempty"` + Username string `json:"username,omitempty"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + ItemID string `json:"itemId,omitempty"` + Target string `json:"target,omitempty"` + SourceKey string `json:"sourceKey,omitempty"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + DurationMS int64 `json:"durationMs"` + EventAt *time.Time `json:"eventAt,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +// NotificationLogFilter is the console's question. Every field is optional and they +// combine with AND, which is what makes the filter bar above the table read the way it +// behaves. +type NotificationLogFilter struct { + UserID string + Kinds []string + Channels []string + Statuses []string + Sources []string + // Query searches the title, the body, the failure detail and the recipient's name. One + // box rather than four, because an operator arriving here is looking for a *thing* they + // half remember and does not yet know which column it is in. + Query string + From time.Time + To time.Time + Limit int + Offset int +} + +// NotificationLogPage is a window on the log plus the counts the page heads itself with. +type NotificationLogPage struct { + Entries []NotificationLogEntry `json:"entries"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// NotificationLogTotals summarises the filtered window. Counted by its own query rather +// than tallied from the page, for the reason SearchTotals is: the page is capped, so +// adding it up would report the first hundred rows' totals as the window's. +type NotificationLogTotals struct { + Total int `json:"total"` + Sent int `json:"sent"` + Delivered int `json:"delivered"` + Failed int `json:"failed"` + Pending int `json:"pending"` + Skipped int `json:"skipped"` + Users int `json:"users"` +} + +// NotificationFacet is one value of a filterable column and how many rows carry it. The +// console builds its dropdowns from these rather than from a list of constants, so the +// filter can neither offer a type that matches nothing nor miss one a feature added after +// the page was written — the stance the activity feed's type filter takes. +type NotificationFacet struct { + Value string `json:"value"` + Count int `json:"count"` +} + +// NotificationFacets is every dropdown on the page. +type NotificationFacets struct { + Kinds []NotificationFacet `json:"kinds"` + Channels []NotificationFacet `json:"channels"` + Statuses []NotificationFacet `json:"statuses"` + Sources []NotificationFacet `json:"sources"` +} + +// RecordNotification writes one row of the audit trail. +// +// It implements notify.Recorder, which is the only thing that calls it. Text is clamped +// here rather than at the caller so one careless producer cannot be the reason the console +// takes a second to draw. +func (s *Store) RecordNotification(ctx context.Context, record notify.Record) error { + if s == nil || s.pool == nil { + return nil + } + occurred := record.OccurredAt + if occurred.IsZero() { + occurred = time.Now().UTC() + } + var metadata any + if len(record.Metadata) > 0 { + metadata = []byte(record.Metadata) + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_log + (occurred_at, channel, kind, source, emby_user_id, username, title, body, + item_id, target, source_key, status, detail, duration_ms, event_at, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + occurred, string(record.Channel), record.Kind, record.Source, + record.UserID, record.Username, + clampText(record.Title), clampText(record.Body), + record.ItemID, record.Target, record.SourceKey, + string(record.Status), clampText(record.Detail), + record.DurationMS, record.EventAt, metadata) + if err != nil { + return fmt.Errorf("store: record notification: %w", err) + } + return nil +} + +func clampText(value string) string { + runes := []rune(value) + if len(runes) <= notificationTextLimit { + return value + } + return string(runes[:notificationTextLimit]) + "…" +} + +// notificationWhere builds the shared predicate. The log, the totals and the facets all +// answer for the *same* filtered window, so they must be filtered identically — writing +// the clause three times is how a page comes to show a total that disagrees with its own +// table. +func notificationWhere(filter NotificationLogFilter) (string, []any) { + clauses := []string{"TRUE"} + args := []any{} + add := func(clause string, value any) { + args = append(args, value) + clauses = append(clauses, fmt.Sprintf(clause, len(args))) + } + if filter.UserID != "" { + add("emby_user_id = $%d", filter.UserID) + } + if len(filter.Kinds) > 0 { + add("kind = ANY($%d)", filter.Kinds) + } + if len(filter.Channels) > 0 { + add("channel = ANY($%d)", filter.Channels) + } + if len(filter.Statuses) > 0 { + add("status = ANY($%d)", filter.Statuses) + } + if len(filter.Sources) > 0 { + add("source = ANY($%d)", filter.Sources) + } + if !filter.From.IsZero() { + add("occurred_at >= $%d", filter.From) + } + if !filter.To.IsZero() { + add("occurred_at < $%d", filter.To) + } + if query := strings.TrimSpace(filter.Query); query != "" { + // ILIKE over four columns rather than a tsvector: this table is a few tens of + // thousands of rows on a household gateway, always read with a date bound, and the + // operator is looking for a substring of a title or an error message — which is + // exactly what full-text search is worst at. + add("(title ILIKE $%[1]d OR body ILIKE $%[1]d OR detail ILIKE $%[1]d OR username ILIKE $%[1]d)", + "%"+query+"%") + } + return strings.Join(clauses, " AND "), args +} + +// NotificationLog reads the filtered window, newest first. +func (s *Store) NotificationLog( + ctx context.Context, filter NotificationLogFilter, +) (NotificationLogPage, error) { + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + where, args := notificationWhere(filter) + + page := NotificationLogPage{Entries: []NotificationLogEntry{}, Limit: limit, Offset: offset} + if err := s.pool.QueryRow(ctx, + `SELECT count(*) FROM notification_log WHERE `+where, args..., + ).Scan(&page.Total); err != nil { + return page, fmt.Errorf("store: count notification log: %w", err) + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, occurred_at, channel, kind, source, emby_user_id, username, title, body, + item_id, target, source_key, status, detail, duration_ms, event_at, metadata + FROM notification_log + WHERE `+where+` + ORDER BY occurred_at DESC, id DESC + LIMIT $`+fmt.Sprint(len(args)+1)+` OFFSET $`+fmt.Sprint(len(args)+2), + append(args, limit, offset)...) + if err != nil { + return page, fmt.Errorf("store: read notification log: %w", err) + } + defer rows.Close() + for rows.Next() { + var entry NotificationLogEntry + var metadata []byte + if err := rows.Scan( + &entry.ID, &entry.OccurredAt, &entry.Channel, &entry.Kind, &entry.Source, + &entry.UserID, &entry.Username, &entry.Title, &entry.Body, + &entry.ItemID, &entry.Target, &entry.SourceKey, &entry.Status, &entry.Detail, + &entry.DurationMS, &entry.EventAt, &metadata, + ); err != nil { + return page, fmt.Errorf("store: scan notification log: %w", err) + } + if len(metadata) > 0 { + entry.Metadata = json.RawMessage(metadata) + } + page.Entries = append(page.Entries, entry) + } + if err := rows.Err(); err != nil { + return page, fmt.Errorf("store: read notification log: %w", err) + } + return page, nil +} + +// NotificationLogTotals counts the same window the log is read with. +func (s *Store) NotificationLogTotals( + ctx context.Context, filter NotificationLogFilter, +) (NotificationLogTotals, error) { + where, args := notificationWhere(filter) + var totals NotificationLogTotals + err := s.pool.QueryRow(ctx, ` + SELECT count(*), + count(*) FILTER (WHERE status = 'sent'), + count(*) FILTER (WHERE status = 'delivered'), + count(*) FILTER (WHERE status = 'failed'), + count(*) FILTER (WHERE status = 'pending'), + count(*) FILTER (WHERE status = 'skipped'), + count(DISTINCT emby_user_id) FILTER (WHERE emby_user_id <> '') + FROM notification_log + WHERE `+where, args...).Scan( + &totals.Total, &totals.Sent, &totals.Delivered, &totals.Failed, + &totals.Pending, &totals.Skipped, &totals.Users) + if err != nil { + return totals, fmt.Errorf("store: notification totals: %w", err) + } + return totals, nil +} + +// NotificationLogFacets lists what the filters may offer. +// +// Deliberately computed over the retention window rather than over the operator's current +// filter: a dropdown whose options disappear as you narrow the table is one you cannot use +// to widen the question again. +func (s *Store) NotificationLogFacets( + ctx context.Context, since time.Time, +) (NotificationFacets, error) { + facets := NotificationFacets{ + Kinds: []NotificationFacet{}, Channels: []NotificationFacet{}, + Statuses: []NotificationFacet{}, Sources: []NotificationFacet{}, + } + rows, err := s.pool.Query(ctx, ` + SELECT 'kind', kind, count(*) FROM notification_log + WHERE occurred_at >= $1 AND kind <> '' GROUP BY kind + UNION ALL + SELECT 'channel', channel, count(*) FROM notification_log + WHERE occurred_at >= $1 AND channel <> '' GROUP BY channel + UNION ALL + SELECT 'status', status, count(*) FROM notification_log + WHERE occurred_at >= $1 AND status <> '' GROUP BY status + UNION ALL + SELECT 'source', source, count(*) FROM notification_log + WHERE occurred_at >= $1 AND source <> '' GROUP BY source + ORDER BY 3 DESC, 2`, since) + if err != nil { + return facets, fmt.Errorf("store: notification facets: %w", err) + } + defer rows.Close() + for rows.Next() { + var group string + var facet NotificationFacet + if err := rows.Scan(&group, &facet.Value, &facet.Count); err != nil { + return facets, fmt.Errorf("store: scan notification facet: %w", err) + } + switch group { + case "kind": + facets.Kinds = append(facets.Kinds, facet) + case "channel": + facets.Channels = append(facets.Channels, facet) + case "status": + facets.Statuses = append(facets.Statuses, facet) + case "source": + facets.Sources = append(facets.Sources, facet) + } + } + return facets, rows.Err() +} + +// NotificationLogDays is the daily shape of the filtered window, for the chart above the +// table. Grouped in the database's own timezone, the stance the sign-in history takes, so +// an evening notification stays on the day it happened. +type NotificationLogDay struct { + Day string `json:"day"` + Sent int `json:"sent"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Delivered int `json:"delivered"` +} + +func (s *Store) NotificationLogDays( + ctx context.Context, filter NotificationLogFilter, +) ([]NotificationLogDay, error) { + where, args := notificationWhere(filter) + rows, err := s.pool.Query(ctx, ` + SELECT to_char(date_trunc('day', occurred_at), 'YYYY-MM-DD'), + count(*) FILTER (WHERE status = 'sent'), + count(*) FILTER (WHERE status = 'failed'), + count(*) FILTER (WHERE status = 'skipped'), + count(*) FILTER (WHERE status = 'delivered') + FROM notification_log + WHERE `+where+` + GROUP BY 1 ORDER BY 1`, args...) + if err != nil { + return nil, fmt.Errorf("store: notification days: %w", err) + } + defer rows.Close() + days := []NotificationLogDay{} + for rows.Next() { + var day NotificationLogDay + if err := rows.Scan(&day.Day, &day.Sent, &day.Failed, &day.Skipped, &day.Delivered); err != nil { + return nil, fmt.Errorf("store: scan notification day: %w", err) + } + days = append(days, day) + } + return days, rows.Err() +} + +// PruneNotificationLog is the retention policy, run by the housekeeping scheduler. +func (s *Store) PruneNotificationLog(ctx context.Context, older time.Duration) (int64, error) { + if older <= 0 { + return 0, nil + } + tag, err := s.pool.Exec(ctx, + `DELETE FROM notification_log WHERE occurred_at < now() - $1::interval`, + older.String()) + if err != nil { + return 0, fmt.Errorf("store: prune notification log: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/server/internal/store/notifications_test.go b/server/internal/store/notifications_test.go new file mode 100644 index 0000000..001b6f0 --- /dev/null +++ b/server/internal/store/notifications_test.go @@ -0,0 +1,129 @@ +package store + +import ( + "strings" + "testing" + "time" +) + +// notificationWhere is the one predicate the log, its totals, its daily chart and the page +// count all read with. It is worth pinning hard for two reasons: a placeholder numbered +// wrong is a query that either fails or, worse, filters on the wrong argument, and a clause +// that drifts between the four readers is a page whose total disagrees with its own table. + +func TestNotificationWhereIsEmptyByDefault(t *testing.T) { + where, args := notificationWhere(NotificationLogFilter{}) + if where != "TRUE" { + t.Fatalf("where = %q, want an unfiltered predicate", where) + } + if len(args) != 0 { + t.Fatalf("args = %v, want none", args) + } +} + +func TestNotificationWhereNumbersPlaceholdersInOrder(t *testing.T) { + from := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC) + where, args := notificationWhere(NotificationLogFilter{ + UserID: "u1", + Kinds: []string{"show-return", "watch-time-week"}, + Channels: []string{"in-app"}, + Statuses: []string{"failed", "skipped"}, + Sources: []string{"watch-time-digest"}, + Query: "bear", + From: from, + To: to, + }) + + // One placeholder per argument, in the order the arguments are appended. The search + // clause reuses its placeholder across four columns, which is why the count of distinct + // placeholders is what matters rather than the count of "$". + for i := range args { + marker := "$" + itoa(i+1) + if !strings.Contains(where, marker) { + t.Fatalf("clause %q never uses %s; the arguments and the placeholders disagree", where, marker) + } + } + if len(args) != 8 { + t.Fatalf("args = %d, want 8", len(args)) + } + if args[0] != "u1" { + t.Fatalf("args[0] = %v, want the user id first", args[0]) + } + if args[len(args)-1] != "%bear%" { + t.Fatalf("args[last] = %v, want the wrapped search term", args[len(args)-1]) + } + if args[6] != to { + t.Fatalf("args[6] = %v, want the upper bound", args[6]) + } +} + +// Every filter combines with AND. A page whose controls quietly ORed together would answer +// a different question from the one the filter bar describes. +func TestNotificationWhereCombinesWithAnd(t *testing.T) { + where, _ := notificationWhere(NotificationLogFilter{ + UserID: "u1", Statuses: []string{"failed"}, + }) + if strings.Contains(where, " OR emby_user_id") { + t.Fatalf("clause %q ORs its filters together", where) + } + if strings.Count(where, " AND ") != 2 { + t.Fatalf("clause %q does not AND both filters", where) + } +} + +// An empty list is not a filter. Sending `ANY('{}')` would match nothing, so a page whose +// dropdown is on "any" would show an empty table. +func TestNotificationWhereIgnoresEmptyLists(t *testing.T) { + where, args := notificationWhere(NotificationLogFilter{ + Kinds: []string{}, Channels: nil, Statuses: []string{}, Query: " ", + }) + if where != "TRUE" || len(args) != 0 { + t.Fatalf("where = %q args = %v; an unset filter must not narrow anything", where, args) + } +} + +// The search box covers the four columns an operator half-remembers something from, and it +// must use one placeholder for all of them — repeating the argument four times would put +// the later filters' placeholders out of step. +func TestNotificationWhereSearchesFourColumnsWithOneArgument(t *testing.T) { + where, args := notificationWhere(NotificationLogFilter{Query: "timeout"}) + for _, column := range []string{"title ILIKE", "body ILIKE", "detail ILIKE", "username ILIKE"} { + if !strings.Contains(where, column) { + t.Errorf("search does not cover %s", column) + } + } + if len(args) != 1 { + t.Fatalf("args = %d, want one shared search argument", len(args)) + } + if strings.Count(where, "$1") != 4 { + t.Fatalf("clause %q does not reuse $1 across all four columns", where) + } +} + +// The retention constant is what the console derives its widest window from, so a change to +// one that is not a change to the other would offer a range the prune has already emptied. +func TestNotificationRetentionIsWholeDays(t *testing.T) { + if NotificationRetention%(24*time.Hour) != 0 { + t.Fatalf("retention %v is not a whole number of days", NotificationRetention) + } + if days := int(NotificationRetention / (24 * time.Hour)); days != 90 { + t.Fatalf("retention = %d days, want 90", days) + } +} + +func TestClampTextBoundsAStoredString(t *testing.T) { + if got := clampText("short"); got != "short" { + t.Fatalf("clampText shortened an ordinary string to %q", got) + } + long := strings.Repeat("é", notificationTextLimit+50) + got := clampText(long) + // Counted in runes, not bytes: a body in Japanese must not be cut at a third of an + // English one's length, and never mid-character. + if runes := []rune(got); len(runes) != notificationTextLimit+1 { + t.Fatalf("clamped to %d runes, want %d plus the ellipsis", len(runes), notificationTextLimit) + } + if !strings.HasSuffix(got, "…") { + t.Fatal("a clamped string does not say that it was clamped") + } +} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index fdc3193..d666e0a 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -831,3 +831,42 @@ CREATE INDEX IF NOT EXISTS library_ingest_pending_idx WHERE state = 'pending'; CREATE INDEX IF NOT EXISTS library_ingest_recent_idx ON library_ingest_queue (updated_at DESC); + +-- The outbound notification log: what Memby sent, to whom, over which channel, and what +-- became of it. Written only by internal/notify, which every producer now goes through, +-- so this is one audit trail rather than a per-feature guess. +-- +-- Deliberately separate from user_notifications. That table is one viewer's undismissed +-- list — state they empty — where this is history: it keeps the row for a notification +-- that was dismissed, for one that was deliberately skipped, and for a broadcast that +-- belongs to no viewer at all, none of which the other table can represent. +-- +-- emby_user_id is '' rather than NULL for a household broadcast, so every filter is an +-- equality test and no query needs a NULL case. +CREATE TABLE IF NOT EXISTS notification_log ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + channel TEXT NOT NULL, -- in-app | broadcast | webhook + kind TEXT NOT NULL DEFAULT '', -- show-return, watch-time-week, … + source TEXT NOT NULL DEFAULT '', -- the service that decided to send + emby_user_id TEXT NOT NULL DEFAULT '', -- '' is the whole household + username TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + item_id TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT '', -- a destination's NAME, never its address + source_key TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, -- sent | delivered | failed | pending | skipped + detail TEXT NOT NULL DEFAULT '', -- the failure, or why it was skipped + duration_ms BIGINT NOT NULL DEFAULT 0, + event_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb +); + +-- The page's default read is the whole log newest-first, and every filtered read still +-- bounds on the date; the remaining three cover the columns the filter bar offers. +CREATE INDEX IF NOT EXISTS notification_log_time_idx ON notification_log (occurred_at DESC); +CREATE INDEX IF NOT EXISTS notification_log_user_idx + ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> ''; +CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC); +CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);