This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
+1
View File
@@ -39,3 +39,4 @@ local.properties
# The installed APK a deploy script pulls off a television to compare against. # The installed APK a deploy script pulls off a television to compare against.
/.tmp-memby-installed-base.apk /.tmp-memby-installed-base.apk
.tmp-go-cache
+14 -1
View File
@@ -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 ## 0.2.75 - 2026-08-18
- Chore: Upgrade player dependencies. - Improved: Upgrade player version.
- Fixed: Backend server fixes.
## 0.2.74 — 2026-08-17 ## 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. - 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.
+237 -4
View File
@@ -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 - **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 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. 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 - **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 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. worth opening. **Nothing moves under the remote by being looked at**: focus used to mark a
"Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and row read, which was harmless while the list was one list and would now empty the Inbox
empties the list optimistically, or a row lingers under a thumb that will press it again. 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 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 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.** - **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 `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 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 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. 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 **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" `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 — — 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 same work which is. Moving the core past 1.9 means finding a matching extension first, or
deciding to do without DTS. 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 **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 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. 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/sonarr-preroll/`, `build/screenshots/playback-identity/` and
`build/screenshots/playback-loading/`. `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 **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 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 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 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. 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 **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/`) 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 answers "what is on this month, and when does it come back", which is a question no shelf
-216
View File
@@ -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 (250420dp) 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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon" 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" 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"
/> />
<script type="module" crossorigin src="/admin/assets/index-BmnCg8np.js"></script> <script type="module" crossorigin src="/admin/assets/index-KrrVPZvy.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js"> <link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-C5rUVO6U.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2
View File
@@ -35,6 +35,7 @@ import { EngagementPage } from './pages/Engagement';
import { SearchesPage } from './pages/Searches'; import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views'; import { ViewsPage } from './pages/Views';
import { MediaReportsPage } from './pages/MediaReports'; import { MediaReportsPage } from './pages/MediaReports';
import { NotificationsPage } from './pages/Notifications';
import { CreditsPage } from './pages/Credits'; import { CreditsPage } from './pages/Credits';
/* The console's routing table. /* The console's routing table.
@@ -97,6 +98,7 @@ export function App() {
<Route path="engagement" element={<EngagementPage />} /> <Route path="engagement" element={<EngagementPage />} />
<Route path="searches" element={<SearchesPage />} /> <Route path="searches" element={<SearchesPage />} />
<Route path="media-reports" element={<MediaReportsPage />} /> <Route path="media-reports" element={<MediaReportsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
{/* The old console redirected /admin/ to /admin/overview. Anything that {/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */} still links there lands on the overview rather than on a 404. */}
+79
View File
@@ -601,3 +601,82 @@ export interface IngestResponse {
counts: { pending: number; done: number; failed: number }; counts: { pending: number; done: number; failed: number };
recent: IngestJob[]; 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<string, unknown>;
}
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;
}
+1
View File
@@ -33,6 +33,7 @@ export const icons = {
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14', play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10', list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5', 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', 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', 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', 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',
+2 -2
View File
@@ -117,8 +117,8 @@ export function OmniSearch() {
ref={input} ref={input}
type="search" type="search"
value={query} value={query}
placeholder="Search pages, users and devices…" placeholder="Search"
aria-label="Search pages, users and devices" aria-label="Search"
aria-expanded={open} aria-expanded={open}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onChange={(event) => { onChange={(event) => {
+23 -4
View File
@@ -32,6 +32,15 @@ export interface Shaped {
context: string; context: string;
/** The one line that explains a failure, printed under the row rather than hidden. */ /** The one line that explains a failure, printed under the row rather than hidden. */
detail: string; 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; result: { label: string; short: string; tone: LogTone } | null;
durationMs: number | null; durationMs: number | null;
method: string; method: string;
@@ -433,6 +442,15 @@ function derive(event: LogEvent): Shaped {
const context = contextFor(attributes); const context = contextFor(attributes);
const occurred = new Date(event.occurredAt); 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 fields = Object.entries(attributes);
const shaped: Shaped = { const shaped: Shaped = {
serviceKey, serviceKey,
@@ -442,6 +460,9 @@ function derive(event: LogEvent): Shaped {
summary, summary,
context, context,
detail, detail,
secondary,
secondaryTone,
trail,
result: resultFor(attributes, event.level, status), result: resultFor(attributes, event.level, status),
durationMs, durationMs,
method, method,
@@ -451,10 +472,8 @@ function derive(event: LogEvent): Shaped {
time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`, time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`,
day: dayFormat.format(occurred), day: dayFormat.format(occurred),
dayKey: occurred.toDateString(), dayKey: occurred.toDateString(),
// An error explains itself on a second line; so does an application event carrying a // The height follows what is printed, never a second guess at it.
// person or a position. Ordinary request traffic — which is most of a log — stays on tall: Boolean(secondary),
// one, because density is the whole reason this page is worth watching.
tall: Boolean(detail) || (Boolean(context) && !isRequest),
haystack: [ haystack: [
message, service, component, summary, context, detail, message, service, component, summary, context, detail,
...fields.flat().map(text), ...fields.flat().map(text),
+15 -4
View File
@@ -64,7 +64,7 @@ export const nav: NavGroup[] = [
id: 'accounts', id: 'accounts',
path: '/admin/accounts', path: '/admin/accounts',
label: 'Users', label: 'Users',
title: 'Memby users', title: 'Users',
intro: 'Who uses Memby, and the devices they are signed in on.', intro: 'Who uses Memby, and the devices they are signed in on.',
icon: 'people', icon: 'people',
}, },
@@ -76,6 +76,17 @@ export const nav: NavGroup[] = [
intro: 'Who can ask for something the library does not have.', intro: 'Who can ask for something the library does not have.',
icon: 'inbox', 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', id: 'media-reports',
path: '/admin/media-reports', path: '/admin/media-reports',
@@ -95,8 +106,8 @@ export const nav: NavGroup[] = [
{ {
id: 'logs', id: 'logs',
path: '/admin/logs', path: '/admin/logs',
label: 'Server logs', label: 'Logs',
title: 'Server logs', title: 'Logs',
intro: 'Structured gateway events as they happen.', intro: 'Structured gateway events as they happen.',
icon: 'list', icon: 'list',
}, },
@@ -309,7 +320,7 @@ export const nav: NavGroup[] = [
path: '/admin/searches', path: '/admin/searches',
label: 'Searches', label: 'Searches',
title: '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', icon: 'search',
}, },
{ {
+19 -4
View File
@@ -40,6 +40,12 @@ interface PreferenceDefinition {
numbers?: number[]; numbers?: number[];
unit?: string; unit?: string;
maxLength?: number; 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; adminOnly?: boolean;
} }
@@ -101,6 +107,7 @@ interface AccountDetail {
id: string; id: string;
username: string; username: string;
initials: string; initials: string;
shortName: string;
lastSeen: string; lastSeen: string;
devices: AccountDevice[] | null; devices: AccountDevice[] | null;
themes: string[] | null; themes: string[] | null;
@@ -229,7 +236,9 @@ export function AccountPage() {
<> <>
<PageHead <PageHead
title={account.username || 'Unnamed user'} title={account.username || 'Unnamed user'}
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`} /* The short name is stated here as well as being editable below: it is what the
launcher calls this person, and the settings editor is a long way down the page. */
intro={`Memby user · ${account.shortName ? `greeted as ${account.shortName} · ` : ''}${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
crumbs={<Link to="/admin/accounts"> All users</Link>} crumbs={<Link to="/admin/accounts"> All users</Link>}
actions={ actions={
<> <>
@@ -361,7 +370,7 @@ export function AccountPage() {
{account.watchTime?.matched ? ( {account.watchTime?.matched ? (
<Card <Card
title="Watch time" 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." 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" icon="pulse"
tone="data" tone="data"
actions={ actions={
@@ -845,8 +854,14 @@ function SettingControl({
type="text" type="text"
value={String(value ?? '')} value={String(value ?? '')}
maxLength={definition.maxLength} maxLength={definition.maxLength}
placeholder="Generated from their name" placeholder={definition.placeholder}
onChange={(event) => onChange(event.target.value.toLocaleUpperCase('en-NZ'))} onChange={(event) =>
onChange(
definition.uppercase
? event.target.value.toLocaleUpperCase('en-NZ')
: event.target.value,
)
}
/> />
</Field> </Field>
); );
+15 -3
View File
@@ -37,6 +37,7 @@ interface Account {
id: string; id: string;
username: string; username: string;
initials: string; initials: string;
shortName: string;
lastSeen: string; lastSeen: string;
devices: KnownClient[] | null; devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean }; recommendations?: { prompted?: boolean; completed?: boolean };
@@ -100,7 +101,7 @@ export function AccountsPage() {
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' }, { label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' }, { 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), value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse', icon: 'pulse',
tone: 'ok', tone: 'ok',
@@ -110,7 +111,7 @@ export function AccountsPage() {
...(tracked.length ...(tracked.length
? [ ? [
{ {
label: 'watched by the household this week', label: 'watch time this week',
value: watchTime(weekMs), value: watchTime(weekMs),
icon: 'pulse' as const, icon: 'pulse' as const,
tone: 'data' as const, tone: 'data' as const,
@@ -126,6 +127,7 @@ export function AccountsPage() {
<thead> <thead>
<tr> <tr>
<th>Person</th> <th>Person</th>
<th>Short name</th>
<th className="num">Devices</th> <th className="num">Devices</th>
<th className="num">This week</th> <th className="num">This week</th>
<th className="num">This month</th> <th className="num">This month</th>
@@ -135,7 +137,7 @@ export function AccountsPage() {
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyRow columns={6}> <EmptyRow columns={7}>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here. No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</EmptyRow> </EmptyRow>
) : ( ) : (
@@ -163,6 +165,16 @@ export function AccountsPage() {
</Link> </Link>
</span> </span>
</td> </td>
{/* 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. */}
<td>
{account.shortName || (
<span className="muted" title="Memby greets them by their account name">
account name
</span>
)}
</td>
<td className="num"> <td className="num">
{num(list.length)} {num(list.length)}
{/* Only where there is something to say. A sub-line under every {/* Only where there is something to say. A sub-line under every
+1 -1
View File
@@ -122,7 +122,7 @@ export function CreditsPage() {
<Card <Card
title="Waiting candidates" title="Waiting candidates"
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes." intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as viewing changes."
icon="list" icon="list"
tone="info" tone="info"
> >
+1 -1
View File
@@ -239,7 +239,7 @@ function LogView({
<Grid cols="wide"> <Grid cols="wide">
<Card <Card
title="Attempts per day" title="Attempts per day"
intro="Grouped in the household's own timezone, so an evening sign-in stays on the day it happened." intro="Grouped in the server's own timezone, so an evening sign-in stays on the day it happened."
icon="chart" icon="chart"
tone="info" tone="info"
> >
+29 -7
View File
@@ -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 * 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 * 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. * 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 * - **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 * 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 * 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 RETAIN = 20_000;
const POLL_MS = 5_000; const POLL_MS = 5_000;
const ROW_COMPACT = 30; /* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is
const ROW_TALL = 48; * 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 DAY_HEIGHT = 26;
const HEADER_HEIGHT = 31; const HEADER_HEIGHT = 31;
const OVERSCAN = 10; const OVERSCAN = 10;
@@ -175,7 +194,7 @@ const LogRow = memo(function LogRow({
<button <button
type="button" type="button"
className="logrow-summary" className="logrow-summary"
title={view.detail || view.summary} title={[view.summary, view.trail, view.secondary].filter(Boolean).join(' — ')}
onClick={() => onInspect(event.sequence)} onClick={() => onInspect(event.sequence)}
> >
<span className="logrow-line"> <span className="logrow-line">
@@ -185,11 +204,14 @@ const LogRow = memo(function LogRow({
</b> </b>
) : null} ) : null}
<span className="logrow-text">{view.summary}</span> <span className="logrow-text">{view.summary}</span>
{view.trail ? <span className="logrow-trail">{view.trail}</span> : null}
</span> </span>
{view.detail ? ( {/* Printed if and only if the row was measured for it see `secondary` in
<span className="logrow-error"> {view.detail}</span> lib/logmodel. */}
) : view.context ? ( {view.secondary ? (
<span className="logrow-context">{view.context}</span> <span className="logrow-second" data-tone={view.secondaryTone}>
{view.secondaryTone === 'error' ? `${view.secondary}` : view.secondary}
</span>
) : null} ) : null}
</button> </button>
+2 -2
View File
@@ -107,7 +107,7 @@ export function MaintenancePage() {
{!loading ? ( {!loading ? (
<Card <Card
title="Quiet time" title="Quiet time"
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.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.`} intro={`Pause new television requests and server background work every day in ${status?.quietTime?.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" icon="clock"
tone={status?.quietTime?.active ? 'warn' : 'info'} tone={status?.quietTime?.active ? 'warn' : 'info'}
actions={status?.quietTime?.active ? <Tag tone="warn">active now</Tag> : quietEnabled ? <Tag tone="ok">scheduled</Tag> : <Tag>off</Tag>} actions={status?.quietTime?.active ? <Tag tone="warn">active now</Tag> : quietEnabled ? <Tag tone="ok">scheduled</Tag> : <Tag>off</Tag>}
@@ -123,7 +123,7 @@ export function MaintenancePage() {
onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }} onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }}
/> />
<div className="fields"> <div className="fields">
<Field label="Starts" hint="Uses the household's 24-hour clock."> <Field label="Starts" hint="Uses the server's 24-hour clock.">
<input type="time" value={quietStart} onChange={(event) => { setQuietStart(event.target.value); setQuietTouched(true); }} /> <input type="time" value={quietStart} onChange={(event) => { setQuietStart(event.target.value); setQuietTouched(true); }} />
</Field> </Field>
<Field label="Ends" hint="May be on the following day, for example 23:00 to 07:00."> <Field label="Ends" hint="May be on the following day, for example 23:00 to 07:00.">
+532
View File
@@ -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<string, Tone> = {
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<string, Tone> = {
'in-app': 'note',
broadcast: 'info',
webhook: 'data',
};
const CHANNEL_LABEL: Record<string, string> = {
'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) => (
<option key={facet.value} value={facet.value}>
{label(facet.value)} ({facet.count})
</option>
));
}
export function NotificationsPage() {
const [days, setDays] = useState<number>(7);
const [filters, setFilters] = useState<Filters>(EMPTY);
const [page, setPage] = useState(0);
const [openId, setOpenId] = useState<number | null>(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<NotificationLogResponse>(`/admin/api/notification-log${params}`);
const data = log.data;
const update = (patch: Partial<Filters>) => {
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 (
<>
<PageHead
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."
/>
<Banner message={log.error} />
{totals ? (
<Tiles
tiles={[
{ label: 'Sent', value: num(totals.sent), icon: 'send', tone: 'ok' },
{
label: 'Confirmed',
value: num(totals.delivered),
icon: 'check',
tone: 'ok',
},
{
label: 'Failed',
value: num(totals.failed),
icon: 'alert',
tone: totals.failed > 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}
<div className="filters">
<Field label="Window">
<Segments
value={filters.from ? -1 : days}
options={WINDOWS.map((entry) => ({ value: entry.value as number, label: entry.label }))}
onChange={(next) => {
setDays(next);
update({ from: '', to: '' });
}}
/>
</Field>
<Field label="Person">
<select value={filters.user} onChange={(event) => update({ user: event.target.value })}>
<option value="">Anyone</option>
{(data?.users ?? []).map((user) => (
<option key={user.id} value={user.id}>
{user.username || user.id}
</option>
))}
</select>
</Field>
<Field label="Type">
<select value={filters.kind} onChange={(event) => update({ kind: event.target.value })}>
<option value="">Any type</option>
{options(data?.facets?.kinds, readable)}
</select>
</Field>
<Field label="Channel">
<select
value={filters.channel}
onChange={(event) => update({ channel: event.target.value })}
>
<option value="">Any channel</option>
{options(data?.facets?.channels, (value) => CHANNEL_LABEL[value] ?? readable(value))}
</select>
</Field>
<Field label="Status">
<select value={filters.status} onChange={(event) => update({ status: event.target.value })}>
<option value="">Any status</option>
{options(data?.facets?.statuses, readable)}
</select>
</Field>
<Field label="Source">
<select value={filters.source} onChange={(event) => update({ source: event.target.value })}>
<option value="">Any service</option>
{options(data?.facets?.sources, readable)}
</select>
</Field>
<Field label="From">
<input
type="date"
value={filters.from}
onChange={(event) => update({ from: event.target.value })}
/>
</Field>
<Field label="To">
<input
type="date"
value={filters.to}
onChange={(event) => update({ to: event.target.value })}
/>
</Field>
<Field label="Search" grow>
<input
type="search"
value={filters.q}
placeholder="Title, message, error or person"
onChange={(event) => update({ q: event.target.value })}
/>
</Field>
<div className="filter-actions">
{active ? (
<Button
variant="quiet"
size="sm"
onClick={() => {
setFilters(EMPTY);
setPage(0);
setOpenId(null);
}}
>
Clear
</Button>
) : null}
</div>
</div>
{log.loading && !data ? <Loading /> : null}
{data && data.days.length > 1 ? (
<Card
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"
>
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => 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`
}
/>
</Card>
) : null}
{data ? (
<Card
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={
<span className="filter-summary">
{data.total === 0
? 'nothing matches'
: `${num(from)}${num(from + shown - 1)} of ${num(data.total)}`}
</span>
}
footer={
data.total > LIMIT ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * LIMIT >= data.total}
onClick={() => setPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Recipient</th>
<th>Type</th>
<th>Title</th>
<th>Channel</th>
<th>Source</th>
<th>Status</th>
<th aria-label="Details" />
</tr>
</thead>
<tbody>
{data.entries.length === 0 ? (
<EmptyRow columns={8}>No notifications match these filters.</EmptyRow>
) : (
data.entries.map((entry) => (
<Fragment key={entry.id}>
<Row
entry={entry}
open={openId === entry.id}
onToggle={() => setOpenId(openId === entry.id ? null : entry.id)}
/>
{openId === entry.id ? <DetailRow entry={entry} /> : null}
</Fragment>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
) : null}
</>
);
}
function Row({
entry,
open,
onToggle,
}: {
entry: NotificationLogEntry;
open: boolean;
onToggle: () => void;
}) {
return (
<tr data-selected={open || undefined}>
<td className="nowrap muted">{when(entry.occurredAt)}</td>
<td>
{entry.userId ? (
<Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(entry.userId)}`}>
{entry.username || entry.userId}
</Link>
) : 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. */
<span className="muted">{entry.target}</span>
) : (
/* No recipient is a real answer rather than a missing one: a service alert is
the whole household being told something. */
<span className="quiet">Everyone</span>
)}
</td>
<td className="mono">{entry.kind || '—'}</td>
<td>{entry.title || <span className="quiet"></span>}</td>
<td className="nowrap">
<Tag tone={CHANNEL_TONE[entry.channel]}>
{CHANNEL_LABEL[entry.channel] ?? entry.channel}
</Tag>
</td>
<td className="muted">{readable(entry.source)}</td>
<td className="nowrap">
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
</td>
<td className="nowrap">
<Button size="sm" variant="quiet" onClick={onToggle} icon={open ? 'close' : 'list'}>
{open ? 'Close' : 'Details'}
</Button>
</td>
</tr>
);
}
/* 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 (
<tr className="detail-row">
<td colSpan={8}>
<section className="logdrawer" aria-label={`Notification ${entry.id}`}>
<header className="logdrawer-head">
<div>
<p className="logdrawer-place">
<span>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</span>
<span className="logrow-sep" aria-hidden="true">
</span>
{readable(entry.source)}
</p>
<b>{entry.title || readable(entry.kind)}</b>
{/* 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 ? <p className="logdrawer-error">{entry.detail}</p> : null}
</div>
<div className="logdrawer-actions">
<Tag tone={STATUS_TONE[entry.status] ?? 'idle'}>{entry.status}</Tag>
</div>
</header>
<div className="logdrawer-grid">
<div className="logdrawer-section">
<h4>Delivery</h4>
<dl>
<dt>Sent</dt>
<dd>{when(entry.occurredAt)}</dd>
{entry.eventAt ? (
<>
<dt>Event</dt>
<dd>{when(entry.eventAt)}</dd>
</>
) : null}
<dt>Channel</dt>
<dd>{CHANNEL_LABEL[entry.channel] ?? entry.channel}</dd>
<dt>Type</dt>
<dd>{entry.kind || '—'}</dd>
<dt>Service</dt>
<dd>{readable(entry.source)}</dd>
<dt>Took</dt>
<dd>{entry.durationMs}ms</dd>
</dl>
</div>
<div className="logdrawer-section">
<h4>Recipient</h4>
<dl>
<dt>Person</dt>
<dd>
{entry.userId ? entry.username || entry.userId : 'the whole household'}
</dd>
{entry.target ? (
<>
<dt>Destination</dt>
<dd>{entry.target}</dd>
</>
) : null}
{entry.itemId ? (
<>
<dt>Title id</dt>
<dd>{entry.itemId}</dd>
</>
) : 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. */}
<dt>Source key</dt>
<dd>{entry.sourceKey}</dd>
</>
) : null}
</dl>
</div>
{metadata.length ? (
<div className="logdrawer-section">
<h4>Context</h4>
<dl>
{metadata.map(([key, value]) => (
<Fragment key={key}>
<dt>{readable(key)}</dt>
<dd>{String(value)}</dd>
</Fragment>
))}
</dl>
</div>
) : null}
</div>
{entry.body ? (
<div className="logdrawer-raw">
<h4>Message</h4>
<p className="notification-body">{entry.body}</p>
</div>
) : null}
</section>
</td>
</tr>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function SearchesPage() {
<> <>
<PageHead <PageHead
title="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."
/> />
<Banner message={error} /> <Banner message={error} />
+2 -2
View File
@@ -147,7 +147,7 @@ export function SettingsPage() {
> >
<KeyValue <KeyValue
rows={[ rows={[
{ label: 'Household timezone', value: effective.timezone || 'not set' }, { label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel }, { label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') }, { label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') }, { label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
@@ -176,7 +176,7 @@ export function SettingsPage() {
> >
<div className="fields"> <div className="fields">
<Field <Field
label="Household timezone" label="Server timezone"
hint={`Deployed: ${deployed.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.`} hint={`Deployed: ${deployed.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.`}
> >
<input <input
+2 -2
View File
@@ -25,12 +25,12 @@ export function ViewsPage() {
{ label: change(data?.today.viewers ?? 0, data?.lastWeek.viewers ?? 0), value: num(data?.today.viewers), icon: 'people', tone: 'note' }, { label: change(data?.today.viewers ?? 0, data?.lastWeek.viewers ?? 0), value: num(data?.today.viewers), icon: 'people', tone: 'note' },
{ label: 'busiest time today', value: data?.busiestHour || '—', small: true, icon: 'clock', tone: 'info' }, { label: 'busiest time today', value: data?.busiestHour || '—', small: true, icon: 'clock', tone: 'info' },
]} /> ]} />
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct household profiles." icon="chart" tone="data"> <Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct signed-in profiles." icon="chart" tone="data">
<TableWrap><table><thead><tr><th>Day</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody> <TableWrap><table><thead><tr><th>Day</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{daily.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet.</EmptyRow> : daily.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)} {daily.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet.</EmptyRow> : daily.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap> </tbody></table></TableWrap>
</Card> </Card>
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when the household is opening Memby." icon="clock" tone="info"> <Card title="Today by hour" intro="Local New Zealand time. Use this to see when viewers are opening Memby." icon="clock" tone="info">
<TableWrap><table><thead><tr><th>Hour</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody> <TableWrap><table><thead><tr><th>Hour</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{hourly.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet today.</EmptyRow> : hourly.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)} {hourly.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet today.</EmptyRow> : hourly.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap> </tbody></table></TableWrap>
+94 -25
View File
@@ -1881,12 +1881,19 @@ select {
font: 12px/1.5 var(--sans); font: 12px/1.5 var(--sans);
contain: layout paint style; 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, .loghead,
.logrow { .logrow {
display: grid; display: grid;
grid-template-columns: grid-template-columns: 92px 52px 190px minmax(240px, 1fr) 150px 68px;
92px 52px minmax(150px, 190px) minmax(240px, 1fr)
minmax(96px, 150px) 68px;
gap: 12px; gap: 12px;
padding: 0 12px; padding: 0 12px;
} }
@@ -1942,15 +1949,26 @@ select {
background: var(--line-soft); 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 { .logrow {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
left: 0; left: 0;
align-items: center; align-items: start;
padding-top: 7px;
padding-bottom: 7px;
border-bottom: 1px solid rgba(255, 255, 255, .03); border-bottom: 1px solid rgba(255, 255, 255, .03);
contain: strict; contain: strict;
} }
.logrow > *,
.logrow-line {
line-height: 16px;
}
.logrow:hover { .logrow:hover {
background: rgba(255, 255, 255, .035); background: rgba(255, 255, 255, .035);
} }
@@ -1971,7 +1989,7 @@ select {
.logrow-time { .logrow-time {
color: var(--quiet); color: var(--quiet);
font: 11px/1 var(--mono); font: 11px/16px var(--mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -2003,7 +2021,7 @@ select {
.logrow-level { .logrow-level {
color: var(--quiet); color: var(--quiet);
font: 700 10px/1 var(--sans); font: 700 10px/16px var(--sans);
letter-spacing: .06em; letter-spacing: .06em;
} }
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); } .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. */ on every other page and must not start meaning "playback" on this one. */
.logrow-place { .logrow-place {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 5px; gap: 5px;
min-width: 0; min-width: 0;
height: 16px;
overflow: hidden;
} }
.logrow-service { .logrow-service {
flex: 0 0 auto; flex: 0 0 auto;
max-width: 96px; max-width: 96px;
overflow: hidden; overflow: hidden;
color: var(--quiet); color: var(--quiet);
font: 700 10px/1.4 var(--sans); font: 700 10px/16px var(--sans);
letter-spacing: .07em; letter-spacing: .07em;
text-overflow: ellipsis; text-overflow: ellipsis;
text-transform: uppercase; text-transform: uppercase;
@@ -2047,6 +2067,7 @@ select {
flex: 1 1 auto; flex: 1 1 auto;
color: var(--quiet); color: var(--quiet);
font-size: 11.5px; font-size: 11.5px;
line-height: 16px;
} }
/* The summary is the row. It is a button because selecting the row is what opens the /* The summary is the row. It is a button because selecting the row is what opens the
@@ -2055,9 +2076,9 @@ select {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
justify-content: center; align-items: stretch;
justify-content: flex-start;
width: 100%; width: 100%;
height: 100%;
min-width: 0; min-width: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
@@ -2079,11 +2100,12 @@ select {
align-items: baseline; align-items: baseline;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
height: 16px;
} }
.logrow-action { .logrow-action {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--muted); color: var(--muted);
font: 600 10.5px/1.4 var(--mono); font: 600 10.5px/16px var(--mono);
letter-spacing: .04em; letter-spacing: .04em;
} }
.logrow-action[data-method="POST"], .logrow-action[data-method="POST"],
@@ -2098,15 +2120,13 @@ select {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* The reason a failure needs no drawer. */ /* Identity on a request row, kept on the summary's own line. It gives way before the path
.logrow-error { does knowing which route was called matters more than which television called it, and
overflow: hidden; the drawer holds both either way. */
color: var(--danger-ink); .logrow-trail {
font-size: 11px; flex: 0 1 auto;
text-overflow: ellipsis; min-width: 0;
white-space: nowrap; max-width: 38%;
}
.logrow-context {
overflow: hidden; overflow: hidden;
color: var(--quiet); color: var(--quiet);
font-size: 11px; font-size: 11px;
@@ -2114,21 +2134,42 @@ select {
white-space: nowrap; 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 /* 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 which is the correct amount of attention for the four hundredth 200 in a row and only
a failure is given a fill. */ a failure is given a fill. */
.logrow-result { .logrow-result {
min-width: 0; min-width: 0;
height: 16px;
overflow: hidden;
} }
.logrow-verdict { .logrow-verdict {
display: inline-block; display: inline-block;
max-width: 100%; max-width: 100%;
color: var(--quiet); 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="ok"] { color: var(--muted); }
.logrow-verdict[data-tone="info"] { color: var(--info-ink); } .logrow-verdict[data-tone="info"] { color: var(--info-ink); }
.logrow-verdict[data-tone="data"] { color: var(--data-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="warn"],
.logrow-verdict[data-tone="bad"] { .logrow-verdict[data-tone="bad"] {
padding: 1px 6px; padding: 1px 6px;
@@ -2146,7 +2187,7 @@ select {
.logrow-duration { .logrow-duration {
color: var(--quiet); color: var(--quiet);
font: 11px/1 var(--mono); font: 11px/16px var(--mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: right; text-align: right;
} }
@@ -2283,7 +2324,7 @@ select {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.loghead, .loghead,
.logrow { .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; gap: 10px;
} }
.loghead > :last-child, .loghead > :last-child,
@@ -2292,13 +2333,13 @@ select {
} }
.loghead, .loghead,
.logbody { .logbody {
min-width: 640px; min-width: 680px;
} }
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.loghead, .loghead,
.logrow { .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; gap: 8px;
} }
.loghead > :first-child, .loghead > :first-child,
@@ -3513,3 +3554,31 @@ details summary {
background: inherit; 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;
}
+1 -1
View File
@@ -63,7 +63,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.76" val defaultVersionName = "0.2.77"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -20,6 +20,7 @@ import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport 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.PlaybackInfoRequest
import com.ponzischeme89.memby.data.model.MediaSourceInfo import com.ponzischeme89.memby.data.model.MediaSourceInfo
import com.ponzischeme89.memby.data.model.h264TranscodeFallback import com.ponzischeme89.memby.data.model.h264TranscodeFallback
@@ -286,6 +287,32 @@ class EmbyRepository internal constructor(
val showTitleLogo: Boolean get() = snapshot.showTitleLogo val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1) private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow() 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<PlaybackPosition>(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<String, LocalResume>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, LocalResume>?): Boolean =
size > LOCAL_RESUME_CACHE_SIZE
}
/** Emby session reports must arrive in order; an older progress request cannot follow Stop. */ /** Emby session reports must arrive in order; an older progress request cannot follow Stop. */
private val playbackReportMutex = Mutex() private val playbackReportMutex = Mutex()
private val playableMutex = Mutex() private val playableMutex = Mutex()
@@ -334,6 +361,16 @@ class EmbyRepository internal constructor(
private val extrasCache = private val extrasCache =
LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true) LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true)
private val extrasInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>() private val extrasInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
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<String, RadarrMovieDetail>(RADARR_MOVIE_CACHE_SIZE, 0.75f, true)
private val radarrMovieInFlight = mutableMapOf<String, Deferred<RadarrMovieDetail?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot) fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -433,6 +470,7 @@ class EmbyRepository internal constructor(
): String { ): String {
clearPlayableCache() clearPlayableCache()
clearSeriesEpisodeCache() clearSeriesEpisodeCache()
clearLocalResume()
settings.ensureDeviceId() settings.ensureDeviceId()
observedSettings = settings.snapshot() // pick up the freshly-generated device id observedSettings = settings.snapshot() // pick up the freshly-generated device id
@@ -524,6 +562,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache() clearPlayableCache()
clearSeriesEpisodeCache() clearSeriesEpisodeCache()
clearLocalResume()
} }
suspend fun signOut() { suspend fun signOut() {
@@ -538,6 +577,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache() clearPlayableCache()
clearSeriesEpisodeCache() clearSeriesEpisodeCache()
clearLocalResume()
} }
suspend fun switchProfile(profile: EmbyProfile) { suspend fun switchProfile(profile: EmbyProfile) {
@@ -547,6 +587,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache() clearPlayableCache()
clearSeriesEpisodeCache() clearSeriesEpisodeCache()
clearLocalResume()
} }
suspend fun removeProfile(profile: EmbyProfile) { suspend fun removeProfile(profile: EmbyProfile) {
@@ -563,6 +604,7 @@ class EmbyRepository internal constructor(
cachedBaseUrl = null cachedBaseUrl = null
clearPlayableCache() clearPlayableCache()
clearSeriesEpisodeCache() clearSeriesEpisodeCache()
clearLocalResume()
} }
} }
@@ -1122,6 +1164,17 @@ class EmbyRepository internal constructor(
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read") 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) { suspend fun dismissNotification(id: Long) {
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss") 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. */ /** Sets watched state explicitly and returns the value confirmed by Emby. */
suspend fun setPlayed(itemId: String, played: Boolean): Boolean { 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) { if (ServerConfig.isGateway) {
return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played 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. */ /** Removes a title from Continue Watching without changing its watched state. */
suspend fun removeFromContinueWatching(itemId: String) { 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) { if (ServerConfig.isGateway) {
requireGateway().hideFromResume(itemId) requireGateway().hideFromResume(itemId)
return return
@@ -1735,6 +1793,56 @@ class EmbyRepository internal constructor(
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull()) 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<RadarrMovieDetail?> {
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. * A title's extras: trailers, featurettes, deleted scenes, behind-the-scenes material.
* *
@@ -1910,7 +2018,14 @@ class EmbyRepository internal constructor(
else -> null else -> null
}, },
isSeries = item.isSeries, 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), logoUrl = logoUrl(item),
overview = item.overview, overview = item.overview,
episodeCode = episodeCode(item), episodeCode = episodeCode(item),
@@ -1937,6 +2052,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs( resumePositionMs = launchResumePositionMs(
resolvedPositionMs = entry.playable.resumePositionMs, resolvedPositionMs = entry.playable.resumePositionMs,
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
), ),
) )
} finally { } finally {
@@ -1970,6 +2086,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs( resumePositionMs = launchResumePositionMs(
resolvedPositionMs = cached.resumePositionMs, resolvedPositionMs = cached.resumePositionMs,
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
), ),
) )
} }
@@ -1984,6 +2101,7 @@ class EmbyRepository internal constructor(
resumePositionMs = launchResumePositionMs( resumePositionMs = launchResumePositionMs(
resolvedPositionMs = resolved.resumePositionMs, resolvedPositionMs = resolved.resumePositionMs,
requestedPositionMs = request.resumePositionMs, requestedPositionMs = request.resumePositionMs,
localPositionMs = localResumePositionMs(request.itemId),
), ),
).also { ).also {
playableMutex.withLock { playableCache.remove(request.itemId) } 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. */ /** Drop negotiated playback state when a catalogue refresh can change episode selection. */
suspend fun invalidatePlaybackPrefetch() = clearPlayableCache() 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() { private suspend fun clearPlayableCache() {
playableMutex.withLock { playableMutex.withLock {
playableCache.clear() playableCache.clear()
@@ -2313,6 +2436,62 @@ class EmbyRepository internal constructor(
extrasInFlight.values.forEach { it.cancel() } extrasInFlight.values.forEach { it.cancel() }
extrasInFlight.clear() 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) { suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
@@ -2332,6 +2511,10 @@ class EmbyRepository internal constructor(
eventName: String, eventName: String,
durationMs: Long = 0L, durationMs: Long = 0L,
): String? = playbackReportMutex.withLock { ): 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) { if (ServerConfig.isGateway) {
requireGateway().report( requireGateway().report(
"progress", "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 { try {
playbackReportMutex.withLock { playbackReportMutex.withLock {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user, // 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. // 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 { } else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null)) 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( fun enqueuePlaybackStopped(
session: PlaybackSession, session: PlaybackSession,
positionMs: Long, positionMs: Long,
durationMs: Long = 0L,
onSuccess: () -> Unit = {}, 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 { scope.launch {
runCatching { reportPlaybackStopped(session, positionMs) } runCatching { reportPlaybackStopped(session, positionMs, durationMs) }
.onSuccess { onSuccess() } .onSuccess { onSuccess() }
} }
} }
@@ -3066,6 +3278,22 @@ data class PlaybackSession(
val playMethod: String = "DirectPlay", 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( private data class PlaybackDiscovery(
val subtitles: List<PlayableSubtitle> = emptyList(), val subtitles: List<PlayableSubtitle> = emptyList(),
val mediaSourceId: String = "", val mediaSourceId: String = "",
@@ -3205,6 +3433,9 @@ private const val CONTINUE_PLAY_LOOKBACK = 120
*/ */
private const val TRICKPLAY_CACHE_SIZE = 12 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. // A season's worth, so working through one show in an evening never asks twice.
private const val INTRO_CACHE_SIZE = 24 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 TRAILER_CACHE_SIZE = 64
private const val EXTRAS_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 private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks, // 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 = internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
/** A positive position on the pressed card outranks an older prefetched zero. */ /** Emby's own rule for a finished title; a stop past it resets the position rather than saving it. */
internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long = internal const val PLAYBACK_COMPLETION_FRACTION = 0.9
if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L)
/** 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 private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
@@ -304,6 +304,8 @@ data class Settings(
val username: String? = null, val username: String? = null,
/** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */ /** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */
val profileInitials: String = "", val profileInitials: String = "",
/** Admin-defined friendly name for the launcher's greeting; blank uses [username]. */
val shortName: String = "",
val deviceId: String = "", val deviceId: String = "",
val deviceName: String = "", val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS, val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
@@ -479,6 +481,7 @@ data class EmbyProfile(
val userId: String, val userId: String,
val username: String, val username: String,
val profileInitials: String = "", val profileInitials: String = "",
val shortName: String = "",
val serverId: String? = null, val serverId: String? = null,
val homeCacheJson: String? = null, val homeCacheJson: String? = null,
val forYouMinutes: Int = 0, val forYouMinutes: Int = 0,
@@ -574,6 +577,7 @@ class SettingsStore(private val context: Context) {
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows") val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style") val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
val PROFILE_INITIALS = stringPreferencesKey("profile_initials") val PROFILE_INITIALS = stringPreferencesKey("profile_initials")
val SHORT_NAME = stringPreferencesKey("short_name")
val THEME_ID = stringPreferencesKey("theme_id") val THEME_ID = stringPreferencesKey("theme_id")
val THEME_PALETTE = stringPreferencesKey("theme_palette") val THEME_PALETTE = stringPreferencesKey("theme_palette")
val THEME_ICON_SET = stringPreferencesKey("theme_icon_set") val THEME_ICON_SET = stringPreferencesKey("theme_icon_set")
@@ -806,6 +810,7 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { store -> context.dataStore.edit { store ->
store[Keys.HOME_SECTIONS] = sections store[Keys.HOME_SECTIONS] = sections
store[Keys.PROFILE_INITIALS] = preferences.profileInitials store[Keys.PROFILE_INITIALS] = preferences.profileInitials
store[Keys.SHORT_NAME] = preferences.shortName
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
@@ -830,6 +835,7 @@ class SettingsStore(private val context: Context) {
updateActiveProfile(store) { updateActiveProfile(store) {
it.copy( it.copy(
profileInitials = preferences.profileInitials, profileInitials = preferences.profileInitials,
shortName = preferences.shortName,
homeSections = sections, homeSections = sections,
homeCardDensity = preferences.homeCardDensity, homeCardDensity = preferences.homeCardDensity,
homeArtworkStyle = preferences.homeArtworkStyle, 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 -> context.dataStore.edit { preferences ->
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) { 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, userId = userId,
username = username, username = username,
profileInitials = previous?.profileInitials.orEmpty(), profileInitials = previous?.profileInitials.orEmpty(),
shortName = previous?.shortName.orEmpty(),
serverId = serverId, serverId = serverId,
homeCacheJson = previous?.homeCacheJson, homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0, 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. // this TV believe it had already synced settings it has never seen.
preferences.remove(Keys.PREFERENCES_REVISION) preferences.remove(Keys.PREFERENCES_REVISION)
preferences.remove(Keys.PROFILE_INITIALS) preferences.remove(Keys.PROFILE_INITIALS)
preferences.remove(Keys.SHORT_NAME)
preferences.remove(Keys.USERNAME) preferences.remove(Keys.USERNAME)
} }
@@ -1376,6 +1392,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.USER_ID] = profile.userId preferences[Keys.USER_ID] = profile.userId
preferences[Keys.USERNAME] = profile.username preferences[Keys.USERNAME] = profile.username
preferences[Keys.PROFILE_INITIALS] = profile.profileInitials preferences[Keys.PROFILE_INITIALS] = profile.profileInitials
preferences[Keys.SHORT_NAME] = profile.shortName
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID) if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
else preferences[Keys.SERVER_ID] = profile.serverId else preferences[Keys.SERVER_ID] = profile.serverId
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the // 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, userId = userId,
username = username, username = username,
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(), profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
serverId = preferences[Keys.SERVER_ID], serverId = preferences[Keys.SERVER_ID],
homeCacheJson = activeHomeCache(preferences), homeCacheJson = activeHomeCache(preferences),
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0, forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
@@ -1491,6 +1509,7 @@ class SettingsStore(private val context: Context) {
serverId = preferences[Keys.SERVER_ID], serverId = preferences[Keys.SERVER_ID],
username = preferences[Keys.USERNAME], username = preferences[Keys.USERNAME],
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(), profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
deviceId = preferences[Keys.DEVICE_ID].orEmpty(), deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(), deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS, rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
@@ -25,6 +25,12 @@ import kotlinx.serialization.json.putJsonArray
data class UserPreferences( data class UserPreferences(
/** Admin-defined avatar text; blank keeps the name-derived fallback. */ /** Admin-defined avatar text; blank keeps the name-derived fallback. */
val profileInitials: String = "", 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<String> = DEFAULT_SECTIONS, val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY, val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE, val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
@@ -78,6 +84,7 @@ data class UserPreferences(
*/ */
fun Settings.toUserPreferences(): UserPreferences = UserPreferences( fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
profileInitials = profileInitials, profileInitials = profileInitials,
shortName = shortName,
homeSections = homeSections.decodeCommaList(), homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity, homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle, homeArtworkStyle = homeArtworkStyle,
@@ -120,6 +127,7 @@ fun decodeUserPreferences(
fallback: UserPreferences = UserPreferences(), fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences( ): UserPreferences = UserPreferences(
profileInitials = json.string("profileInitials", fallback.profileInitials), profileInitials = json.string("profileInitials", fallback.profileInitials),
shortName = json.string("shortName", fallback.shortName),
homeSections = json.stringList("homeSections", fallback.homeSections) homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections }, .ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity), homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
@@ -155,6 +163,7 @@ fun decodeUserPreferences(
/** The document as the gateway expects it. The server normalises whatever arrives. */ /** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject { fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("profileInitials", profileInitials) put("profileInitials", profileInitials)
put("shortName", shortName)
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } } putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity) put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle) put("homeArtworkStyle", homeArtworkStyle)
@@ -457,6 +457,10 @@ data class BaseItem(
// The Emby series a schedule card stands for, when the library holds it. Absent for a // 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. // show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null, @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. // Derived by the TV from the weekly schedule row and retained in the local home cache.
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false, @SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
// Explainability supplied only by the gateway's dedicated For You endpoint. // 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 isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule 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 * 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 * one, since it knows about a season announced but not yet imported; Emby's own
@@ -417,6 +417,59 @@ data class GatewayActiveHero(
val rows: List<HomeRow> = emptyList(), val rows: List<HomeRow> = 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<String> = 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<RadarrReleaseDate> = 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<MediaRating> = 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. */ /** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable @Serializable
data class MediaRating( data class MediaRating(
@@ -220,6 +220,18 @@ interface GatewayApi {
@GET("v1/people/{id}/filmography") @GET("v1/people/{id}/filmography")
suspend fun personFilmography(@Path("id") personId: String): GatewayItems 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. */ /** Optional, server-filtered external movie ratings. Empty is always a valid result. */
@GET("v1/items/{id}/ratings") @GET("v1/items/{id}/ratings")
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
@@ -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 @Composable
fun MediaQuickActionsOverlay( fun MediaQuickActionsOverlay(
item: BaseItem, item: BaseItem,
@@ -1034,6 +1049,13 @@ fun MediaQuickActionsOverlay(
onSetFavorite: (BaseItem, Boolean) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)? = null, 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, rowTitle: String? = null,
rowPinned: Boolean = false, rowPinned: Boolean = false,
onToggleRowPinned: (() -> Unit)? = null, onToggleRowPinned: (() -> Unit)? = null,
@@ -1045,10 +1067,56 @@ fun MediaQuickActionsOverlay(
onToggleRowPinned != null && onToggleRowPinned != null &&
onHideRow != null && onHideRow != null &&
onMoveRow != null onMoveRow != null
val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0 // Library state belongs to an Emby item. A card standing for a film the household does
val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) + // not hold has none, and a "Mark watched" that answers 404 is worse than no entry.
(if (hasRowActions) 4 else 0) val hasLibraryActions = !item.isRadarrOnly
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } } 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) } var focusedIndex by remember(item.id) { mutableStateOf(0) }
// The menu can appear while OK is still physically held. Until that opening press // 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. // is released, consume all activation events so it cannot trigger the first action.
@@ -1127,118 +1195,53 @@ fun MediaQuickActionsOverlay(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp), modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
) )
QuickActionMenuItem( actions.forEachIndexed { index, action ->
label = "View details", // The row actions are about the shelf rather than about the title, and
icon = MembyIcon.Info.mark, // Close is about neither, so each is introduced by its own rule.
modifier = Modifier when {
.focusRequester(focusRequesters[0]) rowActions.isNotEmpty() && index == itemActions.size -> {
.onFocusChanged { if (it.isFocused) focusedIndex = 0 }, Spacer(Modifier.height(6.dp))
onClick = { onOpenDetails(item) }, QuickActionDivider()
) Text(
Spacer(Modifier.height(2.dp)) rowTitle.orEmpty(),
QuickActionMenuItem( color = QuietText,
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites", fontSize = 11.sp,
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark, maxLines = 1,
modifier = Modifier overflow = TextOverflow.Ellipsis,
.focusRequester(focusRequesters[1]) modifier = Modifier
.onFocusChanged { if (it.isFocused) focusedIndex = 1 }, .padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
onClick = { )
onSetFavorite(item, !item.isFavorite) }
onClose() index == actionCount - 1 -> {
}, Spacer(Modifier.height(6.dp))
) QuickActionDivider()
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(6.dp))
QuickActionMenuItem( }
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched", index > 0 -> Spacer(Modifier.height(2.dp))
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))
QuickActionMenuItem( QuickActionMenuItem(
label = "Remove from Continue Watching", label = action.label,
icon = MembyIcon.PlaylistRemove.mark, icon = action.icon,
modifier = Modifier modifier = Modifier
.focusRequester(focusRequesters[3]) .focusRequester(focusRequesters[index])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 }, .onFocusChanged { if (it.isFocused) focusedIndex = index },
onClick = onRemoveFromContinueWatching, 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 @Composable
private fun QuickActionMenuItem( private fun QuickActionMenuItem(
label: String, label: String,
@@ -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 * It still goes away further down the launcher: by then somebody is looking for something
* to watch rather than being welcomed. * 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( internal fun shouldShowHomeGreeting(
hasHero: Boolean, hasHero: Boolean,
focusedRowId: String?, focusedRowId: String?,
@@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.HomeCache import com.ponzischeme89.memby.data.HomeCache
import com.ponzischeme89.memby.data.HomeSnapshot 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.RowAnalytics
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
import com.ponzischeme89.memby.data.analytics.JourneySink import com.ponzischeme89.memby.data.analytics.JourneySink
import com.ponzischeme89.memby.data.analytics.JourneyTracker import com.ponzischeme89.memby.data.analytics.JourneyTracker
import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError 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.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
@@ -181,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init { init {
refreshAll() 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 { viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() } repository.playbackStops.collect { refreshWatching() }
} }
@@ -423,6 +431,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
* of the request. * of the request.
*/ */
private suspend fun warmDetailPage(item: BaseItem) { 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 if (item.isSchedule) return
// Focus settling on a playable card is the best warning of a Play press this app // 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 // 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() { private suspend fun refreshWatching() {
refreshMutex.withLock { refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) } _state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
@@ -140,6 +140,7 @@ import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor 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.detail.scheduleSeriesStub
import com.ponzischeme89.memby.ui.calendar.CalendarScreen import com.ponzischeme89.memby.ui.calendar.CalendarScreen
import com.ponzischeme89.memby.ui.requests.RequestsScreen 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. // same series reached from Favourites or a search never claims a schedule.
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) } var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) } var quickMenuItem by remember { mutableStateOf<BaseItem?>(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<String?>(null) } var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) } var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var sectionHeroRows by remember(settings.userId) { var sectionHeroRows by remember(settings.userId) {
@@ -3216,13 +3221,22 @@ private fun HomeScreen(
// the viewer asked for is the show — carrying the air // the viewer asked for is the show — carrying the air
// time across, since that is why they pressed it. // time across, since that is why they pressed it.
val seriesStub = scheduleSeriesStub(item) 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) { if (seriesStub != null) {
detailsAiringNotice = airingNoticeFor(item) detailsAiringNotice = airingNoticeFor(item)
homeViewModel.focusItem(seriesStub) homeViewModel.focusItem(seriesStub)
detailsItem = seriesStub detailsItem = seriesStub
} else if (movieStub != null) {
detailsAiringNotice = null
homeViewModel.focusItem(movieStub)
detailsItem = movieStub
} else { } else {
homeViewModel.focusItem(item) homeViewModel.focusItem(item)
if (item.membyPlayable) { if (item.membyPlayable || item.isRadarrOnly) {
detailsAiringNotice = null detailsAiringNotice = null
detailsItem = item detailsItem = item
} }
@@ -3233,7 +3247,13 @@ private fun HomeScreen(
returnRowKind = row.kind.name returnRowKind = row.kind.name
returnItemId = item.id returnItemId = item.id
homeViewModel.focusItem(item) 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 quickMenuRowId = row.id
quickMenuItem = item quickMenuItem = item
} }
@@ -3269,6 +3289,7 @@ private fun HomeScreen(
HomeClock( HomeClock(
showGreeting = showHomeGreeting, showGreeting = showHomeGreeting,
username = settings.username, username = settings.username,
shortName = settings.shortName,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp), .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. // and should not: it was news, and it has been read.
detailsAiringNotice = null 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 = { onPlay = {
// Kept, not discarded: this is what the viewer comes back to when the // Kept, not discarded: this is what the viewer comes back to when the
// film ends or they press Back out of the player. // film ends or they press Back out of the player.
@@ -3780,61 +3809,50 @@ private fun HomeScreen(
notificationsLoading = false notificationsLoading = false
} }
}, },
onToggleEnabled = { // The seen toggle, and the only thing that moves a row between Inbox and
if (notificationsMutationBusy) return@MyAlertsPage // Seen — nothing is marked read merely by being looked at any more, because
notificationsMutationBusy = true // with the two halves split that would empty the Inbox under the remote.
scope.launch { //
val updated = notificationState.preferences.copy( // Optimistic and reversed on failure, like the dismissal below: the flag is
enabled = !notificationState.preferences.enabled, // 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
runCatching { repo.setNotificationPreferences(updated) } // has no server row to post, so its flag is written to this television's own
.onSuccess { notificationState = it } // settings instead — it is a fact about the APK on this set.
.onFailure { notificationsError = friendlyEmbyError(it) } onToggleSeen = onToggleSeen@{ notification ->
notificationsMutationBusy = false val markingSeen = notification.unread
}
},
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 ->
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) { if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.markUpdateAlertRead() } scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) }
return@onRead return@onToggleSeen
} }
val previousReadAt = notification.readAt val previousReadAt = notification.readAt
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.map { 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 { scope.launch {
runCatching { repo.markNotificationRead(notification.id) } runCatching {
.onFailure { failure -> if (markingSeen) {
notificationState = notificationState.copy( repo.markNotificationRead(notification.id)
notifications = notificationState.notifications.map { } else {
if (it.id == notification.id) { repo.markNotificationUnread(notification.id)
it.copy(readAt = previousReadAt)
} else {
it
}
},
)
notificationsError = friendlyEmbyError(failure)
} }
}.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 // 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, // 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 // and a row that lingered while its request was in flight would be pressed
// a second time. // a second time.
onDismissAll = { onDismissAll = { pending ->
if (notificationsMutationBusy) return@MyAlertsPage if (notificationsMutationBusy) return@MyAlertsPage
notificationsMutationBusy = true notificationsMutationBusy = true
val previous = notificationState val previous = notificationState
val pending = notificationState.notifications.map(UserNotification::id) // Only the half on screen. The page dismisses what it is showing, so
val dismissLocalUpdate = settings.updateAlertVersion != null // emptying Seen must not also throw away an Inbox the viewer has not
notificationState = notificationState.copy(notifications = emptyList()) // 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 { scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert() if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
val failed = pending.filter { id -> // The local update notice has no server row, so asking the gateway to
runCatching { repo.dismissNotification(id) }.isFailure // 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() } runCatching { repo.getNotifications() }
.onSuccess { notificationState = it } .onSuccess { notificationState = it }
@@ -3896,6 +3925,10 @@ private fun HomeScreen(
) )
} }
quickMenuItem?.let { selected -> quickMenuItem?.let { selected ->
LaunchedEffect(selected.id) {
quickMenuTrailerAvailable = selected.isRadarrOnly &&
repo.getRadarrMovie(selected.id)?.trailerAvailable == true
}
val closeQuickActions: (Boolean) -> Unit = { originWillDisappear -> val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
quickMenuItem = null quickMenuItem = null
quickMenuRowId = null quickMenuRowId = null
@@ -3925,6 +3958,18 @@ private fun HomeScreen(
}, },
onSetFavorite = homeViewModel::setFavorite, onSetFavorite = homeViewModel::setFavorite,
onSetPlayed = homeViewModel::setPlayed, 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 ( onRemoveFromContinueWatching = if (
rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE
) { ) {
@@ -4021,6 +4066,7 @@ private fun HomeScreen(
!settings.hasOpenedForYou && !settings.hasOpenedForYou &&
liveMaintenance == null, liveMaintenance == null,
username = settings.username, username = settings.username,
shortName = settings.shortName,
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
) )
// Emby has stopped answering. Persistent, unlike the news bar below it, because // Emby has stopped answering. Persistent, unlike the news bar below it, because
@@ -4170,6 +4216,7 @@ private fun RecentSearchesRow(
private fun HomeClock( private fun HomeClock(
showGreeting: Boolean, showGreeting: Boolean,
username: String?, username: String?,
shortName: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -4189,7 +4236,7 @@ private fun HomeClock(
val period = homeGreetingPeriod( val period = homeGreetingPeriod(
Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY), Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY),
) )
val name = friendlyProfileName(username) val name = greetingName(shortName, username)
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -4353,11 +4400,27 @@ private fun FocusedDetailsOverlay(
onTogglePlayed: (BaseItem, Boolean) -> Unit, onTogglePlayed: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
onOpenItem: (BaseItem) -> 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, airingNotice: AiringNotice? = null,
) { ) {
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
val item = focusedItem?.takeIf { it.id == selected.id } ?: selected 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( SeriesDetailsOverlay(
item = item, item = item,
onPlay = onPlay, onPlay = onPlay,
@@ -4405,6 +4468,7 @@ private fun FocusedQuickActionsOverlay(
onSetFavorite: (BaseItem, Boolean) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)?, onRemoveFromContinueWatching: (() -> Unit)?,
onPlayTrailer: (() -> Unit)?,
rowTitle: String?, rowTitle: String?,
rowPinned: Boolean, rowPinned: Boolean,
onToggleRowPinned: (() -> Unit)?, onToggleRowPinned: (() -> Unit)?,
@@ -4419,6 +4483,7 @@ private fun FocusedQuickActionsOverlay(
onSetFavorite = onSetFavorite, onSetFavorite = onSetFavorite,
onSetPlayed = onSetPlayed, onSetPlayed = onSetPlayed,
onRemoveFromContinueWatching = onRemoveFromContinueWatching, onRemoveFromContinueWatching = onRemoveFromContinueWatching,
onPlayTrailer = onPlayTrailer,
rowTitle = rowTitle, rowTitle = rowTitle,
rowPinned = rowPinned, rowPinned = rowPinned,
onToggleRowPinned = onToggleRowPinned, onToggleRowPinned = onToggleRowPinned,
@@ -4777,9 +4842,12 @@ private fun ForYouTimeBudget(
private fun ForYouNudgeBanner( private fun ForYouNudgeBanner(
visible: Boolean, visible: Boolean,
username: String?, username: String?,
shortName: String?,
modifier: Modifier = Modifier, 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( androidx.compose.animation.AnimatedVisibility(
visible = visible, visible = visible,
enter = androidx.compose.animation.fadeIn(tween(220)), enter = androidx.compose.animation.fadeIn(tween(220)),
@@ -38,6 +38,7 @@ import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOutline
/** /**
* One green Play button, in two sizes. * 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 * 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 * on the selected chip rather than a character in the label, so the chip does not change
@@ -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<RadarrMovieDetail?>(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<String>::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<String> =
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)
}
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.alerts 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 * 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. * 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" "$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<UserNotification>,
): List<UserNotification> = 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()
}
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -61,10 +62,10 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.formatMyShowDate import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner 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.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText 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 * drawn on Home, and cost a focus target on every set in the house whether or not there was
* anything behind it. * anything behind it.
* *
* It reads like Settings on purpose black canvas, flat rows on a shared 16dp inset with * It is laid out as My Requests is the marked heading, a tab strip under it, one pane at a
* hairlines between them, and the row under focus the only lit surface on the page. * 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 * 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, * 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 * 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 * opening. And **nothing moves under the remote by being looked at** focus used to mark a
* has to be pressed to clear the "new" flag on it. * 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 * Stateless by design: the caller owns the list and the requests, so this can be previewed
* and screenshotted with no server. * and screenshotted with no server.
@@ -100,48 +113,74 @@ fun MyAlertsPage(
loading: Boolean = false, loading: Boolean = false,
errorMessage: String? = null, errorMessage: String? = null,
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onToggleEnabled: () -> Unit, onToggleSeen: (UserNotification) -> Unit = {},
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismiss: (UserNotification) -> Unit, onDismiss: (UserNotification) -> Unit,
onDismissAll: () -> Unit, onDismissAll: (List<UserNotification>) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val actionsFocusRequester = remember { FocusRequester() } var tab by remember { mutableStateOf(AlertsTab.INBOX) }
val notificationIds = notifications.map(UserNotification::id) var page by remember { mutableStateOf(0) }
val rowFocusRequesters = remember(notificationIds) { val inbox = remember(notifications) { alertsForTab(AlertsTab.INBOX, notifications) }
List(notificationIds.size) { FocusRequester() } 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() val listState = rememberLazyListState()
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) } var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
val hasAlerts = notifications.isNotEmpty() var pendingFocusToggle by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { var pendingFocusPage by remember { mutableStateOf(0) }
// One frame for the list to place its first row; an empty page has nothing below val hasAlerts = tabNotifications.isNotEmpty()
// the actions to land on, so the chips take the remote instead. 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) delay(16)
runCatching { runCatching {
if (hasAlerts) rowFocusRequesters.first().requestFocus() else actionsFocusRequester.requestFocus() val first = rowFocusRequesters.firstOrNull()
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus()
} }
} }
LaunchedEffect(notificationIds) { LaunchedEffect(pageIds) {
if (notifications.isEmpty()) { if (!hasAlerts) {
pendingFocusIndex = null pendingFocusIndex = null
delay(16) delay(16)
runCatching { actionsFocusRequester.requestFocus() } runCatching { tabsFocusRequester.requestFocus() }
return@LaunchedEffect return@LaunchedEffect
} }
val requestedIndex = pendingFocusIndex ?: 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 // 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 — // 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 // 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. // arriving on its own: focus would jump for a press made minutes ago.
pendingFocusIndex = null pendingFocusIndex = null
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size) val targetIndex = if (safePage != pendingFocusPage) {
?: return@LaunchedEffect // 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) } runCatching { listState.scrollToItem(targetIndex) }
delay(16) 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)) { Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
@@ -149,69 +188,98 @@ fun MyAlertsPage(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 56.dp) .padding(horizontal = 56.dp)
.padding(top = 40.dp, bottom = 28.dp), .padding(top = 32.dp, bottom = 24.dp),
) { ) {
AlertsHeader( AlertsHeader(total = notifications.size, unread = inbox.size)
total = notifications.size, Spacer(Modifier.height(16.dp))
unread = notifications.count(UserNotification::unread),
)
Spacer(Modifier.height(20.dp))
Row( Row(
modifier = Modifier.fillMaxWidth().focusGroup(), modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MembyChoiceChip( AlertsTab.entries.forEach { entry ->
label = if (preferences.enabled) "Notifications on" else "Notifications off", AlertsTabChip(
selected = preferences.enabled, tab = entry,
onClick = onToggleEnabled, selected = entry == tab,
modifier = Modifier.focusRequester(actionsFocusRequester), count = if (entry == AlertsTab.INBOX) inbox.size else seen.size,
) // Both anchors hang off the selected tab rather than off a fixed
MembyChoiceChip( // index: it is where the page opens and where Back returns to.
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off", focusRequester = tabsFocusRequester.takeIf { entry == tab },
selected = preferences.enabled && preferences.showReturnAlerts, // Only ever pointed at a row that is actually placed this frame.
onClick = { if (preferences.enabled) onToggleShowReturns() }, paneFocusRequester = rowFocusRequesters.firstOrNull(),
) onClick = {
Spacer(Modifier.width(1.dp)) if (entry != tab) {
page = 0
pendingFocusIndex = null
tab = entry
}
},
)
}
Spacer(Modifier.weight(1f))
if (hasAlerts) { if (hasAlerts) {
MembyChoiceChip( AlertsPillButton(
label = "Dismiss all", label = "Dismiss all",
selected = false, icon = MembyIcon.PlaylistRemove.mark,
onClick = onDismissAll, // 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) { 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)) AlertsPillButton(
MembyChoiceChip(label = "Close", selected = false, onClick = onClose) 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)) Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (loading && !hasAlerts) { if (loading && notifications.isEmpty()) {
AlertsNotice("Loading notifications…") AlertsNotice("Loading notifications…")
} else if (errorMessage != null && !hasAlerts) { } else if (errorMessage != null && notifications.isEmpty()) {
AlertsNotice(errorMessage) AlertsNotice(errorMessage)
} else if (!hasAlerts) { } else if (!hasAlerts) {
AlertsEmptyState(enabled = preferences.enabled) AlertsEmptyState(tab = tab, listening = preferences.enabled)
} else { } else {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp), contentPadding = PaddingValues(vertical = 6.dp),
) { ) {
itemsIndexed(notifications, key = { _, notification -> notification.id }) { itemsIndexed(pageNotifications, key = { _, notification -> notification.id }) {
index, notification -> index, notification ->
AlertRow( AlertRow(
notification = notification, notification = notification,
modifier = Modifier.focusRequester(rowFocusRequesters[index]), focusRequester = rowFocusRequesters[index],
onFocused = { if (notification.unread) onRead(notification) }, toggleFocusRequester = toggleFocusRequesters[index],
onClick = { // 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 pendingFocusIndex = index
pendingFocusToggle = true
pendingFocusPage = safePage
onToggleSeen(notification)
},
onDismiss = {
pendingFocusIndex = index
pendingFocusToggle = false
pendingFocusPage = safePage
onDismiss(notification) onDismiss(notification)
}, },
) )
if (notification.id != notifications.last().id) { if (notification.id != pageNotifications.last().id) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .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 @Composable
private fun AlertsNotice(message: String) { private fun AlertsNotice(message: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -238,33 +483,68 @@ private fun AlertsNotice(message: String) {
internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? = internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1) 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 @Composable
private fun AlertsHeader(total: Int, unread: Int) { private fun AlertsHeader(total: Int, unread: Int) {
Column( Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.padding(start = 16.dp), Box(
verticalArrangement = Arrangement.spacedBy(4.dp), Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
) { contentAlignment = Alignment.Center,
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold) ) {
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp) 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 @Composable
private fun AlertsEmptyState(enabled: Boolean) { private fun AlertsEmptyState(tab: AlertsTab, listening: Boolean) {
val inbox = tab == AlertsTab.INBOX
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
AlertsEmptyMark(listening = enabled) AlertsEmptyMark(listening = listening && inbox)
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) Text(
if (inbox) "Youre all caught up." else "Nothing marked as seen.",
color = MembyMutedText,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
if (enabled) { when {
"Notifications about the shows you follow will show up here." !inbox -> "Alerts you mark as seen wait here until you dismiss them."
} else { listening -> "Notifications about the shows you follow will show up here."
"Notifications are switched off, so nothing new will arrive here." else -> "Notifications are switched off for this profile, so nothing new will arrive here."
}, },
color = MembyQuietText, color = MembyQuietText,
fontSize = 14.sp, 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() 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 @Composable
private fun AlertRow( private fun AlertRow(
notification: UserNotification, notification: UserNotification,
onFocused: () -> Unit, focusRequester: FocusRequester,
onClick: () -> Unit, toggleFocusRequester: FocusRequester,
upFocusRequester: FocusRequester?,
onToggleSeen: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, 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) val shape = RoundedCornerShape(MembyCardCorner)
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { // Observer before the group it observes: `onFocusChanged` reports the state of
focused = it.isFocused // the focus target that follows it in the chain, so the two the other way round
if (it.isFocused) onFocused() // leave the row's own lit surface permanently dark.
} .onFocusChanged { rowHasFocus = it.hasFocus }
.focusProperties { if (upFocusRequester != null) up = upFocusRequester }
.focusGroup()
.clip(shape) .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( .border(
width = if (focused) 2.dp else 1.dp, width = if (rowHasFocus) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent, color = if (rowHasFocus) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = shape, shape = shape,
) )
.clickable(onClick = onClick) .padding(horizontal = 10.dp),
.semantics {
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Box( Row(
Modifier.size(38.dp).clip(CircleShape).background( modifier = Modifier
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f), .weight(1f)
), .focusRequester(focusRequester)
contentAlignment = Alignment.Center, .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( Box(
alertIcon(notification.kind), Modifier.size(34.dp).clip(CircleShape).background(
contentDescription = null, if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
tint = if (notification.unread) MembyAccent else MembyQuietText, ),
modifier = Modifier.size(19.dp), contentAlignment = Alignment.Center,
) ) {
} Icon(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { alertIcon(notification.kind),
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) { 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( Text(
notification.title, notification.message,
color = Color.White, color = MembyMutedText,
fontSize = 17.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis, 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( Text(
"NEW", "OK to dismiss",
color = MembyAccent, color = Color.White,
fontSize = 9.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.sp, maxLines = 1,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent.copy(alpha = 0.14f))
.padding(horizontal = 5.dp, vertical = 2.dp),
) )
} }
} }
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,
)
} }
} }
@@ -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 <T> alertPageItems(
items: List<T>,
page: Int,
pageSize: Int = AlertsPageSize,
): List<T> {
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"
@@ -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 { 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 // The episode is already on the server, so "airing" would send someone to wait for
// something they could watch now. // something they could watch now.
@@ -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
}
@@ -41,6 +41,7 @@ class PlaybackStopWorker(
ServiceLocator.repository.reportPlaybackStopped( ServiceLocator.repository.reportPlaybackStopped(
session, session,
inputData.getLong(POSITION_MS, 0L), inputData.getLong(POSITION_MS, 0L),
inputData.getLong(DURATION_MS, 0L),
) )
}.fold( }.fold(
onSuccess = { Result.success() }, onSuccess = { Result.success() },
@@ -54,19 +55,32 @@ class PlaybackStopWorker(
private const val PLAY_SESSION_ID = "play_session_id" private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method" private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms" 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 ENQUEUED_AT_MS = "enqueued_at_ms"
private const val MAX_RETRIES = 5 private const val MAX_RETRIES = 5
private fun workName(session: PlaybackSession): String = private fun workName(session: PlaybackSession): String =
"emby-playback-stop-${session.playSessionId.ifBlank { session.itemId }}" "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() val data = Data.Builder()
.putString(ITEM_ID, session.itemId) .putString(ITEM_ID, session.itemId)
.putString(MEDIA_SOURCE_ID, session.mediaSourceId) .putString(MEDIA_SOURCE_ID, session.mediaSourceId)
.putString(PLAY_SESSION_ID, session.playSessionId) .putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod) .putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L)) .putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.putLong(DURATION_MS, durationMs.coerceAtLeast(0L))
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis()) .putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
.build() .build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>() val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
@@ -81,7 +95,7 @@ class PlaybackStopWorker(
// WorkManager is the process-death fallback, not the ordinary delivery path. // WorkManager is the process-death fallback, not the ordinary delivery path.
// Send now from the repository's process scope, which survives Activity // Send now from the repository's process scope, which survives Activity
// destruction, then cancel this exact fallback request once Emby accepts it. // 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) workManager.cancelWorkById(request.id)
} }
} }
@@ -129,21 +129,6 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
else -> "${preference.codecs.size} formats" 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 * Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button * 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 nowPlayingGroup: View? = null
private var playbackIdentityView: View? = null private var playbackIdentityView: View? = null
private var playbackIdentityHideJob: Job? = 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 bufferingStartedAtMs: Long? = null
private var totalBufferingMs = 0L private var totalBufferingMs = 0L
private var bufferingCount = 0 private var bufferingCount = 0
@@ -641,6 +638,15 @@ class PlayerActivity : ComponentActivity() {
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
} }
applySubtitleAppearance(view) 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 playerView = view
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener { view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
showSubtitleOverlay() showSubtitleOverlay()
@@ -873,6 +879,7 @@ class PlayerActivity : ComponentActivity() {
this@PlayerActivity, this@PlayerActivity,
playbackSession(completedId), playbackSession(completedId),
previewResumeDurationMs, previewResumeDurationMs,
previewResumeDurationMs,
) )
} }
} }
@@ -1047,6 +1054,7 @@ class PlayerActivity : ComponentActivity() {
playMethod = playable.playMethod playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" } playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl) setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true) 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( private fun setUpPlaybackIdentity(
title: String, title: String,
seriesName: String?, seriesName: String?,
@@ -2262,8 +2279,9 @@ class PlayerActivity : ComponentActivity() {
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" } text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
} }
findViewById<TextView>(R.id.player_playback_identity_episode).apply { findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty() val label = playbackIdentityEpisodeLabel(title, seriesName, episodeCode)
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE text = label.orEmpty()
visibility = if (label.isNullOrBlank()) View.GONE else View.VISIBLE
} }
if (logoUrl.isNullOrBlank()) { if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter() logo.clearColorFilter()
@@ -2272,6 +2290,7 @@ class PlayerActivity : ComponentActivity() {
fallback.visibility = View.VISIBLE fallback.visibility = View.VISIBLE
return return
} }
fallback.visibility = View.GONE
logo.load(logoUrl) { logo.load(logoUrl) {
crossfade(false) crossfade(false)
listener( 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() { private fun showPlaybackIdentity() {
if (playbackIdentityShown) return
val identity = playbackIdentityView ?: 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() playbackIdentityHideJob?.cancel()
identity.animate().cancel() identity.animate().cancel()
identity.alpha = 0f identity.alpha = 0f
@@ -2300,14 +2363,75 @@ class PlayerActivity : ComponentActivity() {
.alpha(1f) .alpha(1f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS) .setDuration(PLAYBACK_IDENTITY_FADE_MS)
.start() .start()
MembyDiagnostics.info(
"station_ident_shown",
"playback" to playSessionId,
"item" to itemId,
"visible_ms" to PLAYBACK_IDENTITY_VISIBLE_MS,
)
playbackIdentityHideJob = lifecycleScope.launch { playbackIdentityHideJob = lifecycleScope.launch {
delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS) 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) .alpha(0f)
.setDuration(PLAYBACK_IDENTITY_FADE_MS) .setDuration(PLAYBACK_IDENTITY_FADE_MS)
.withEndAction { identity.visibility = View.GONE } .withEndAction { visibility = View.GONE }
.start() .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) { private fun updatePlaybackTiming(playback: Player) {
@@ -3428,6 +3552,7 @@ class PlayerActivity : ComponentActivity() {
pausePosterUrl = next.imageUrl pausePosterUrl = next.imageUrl
pauseOverview = next.overview pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl) setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
renderedFirstFrame = false renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview") showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
@@ -3469,6 +3594,7 @@ class PlayerActivity : ComponentActivity() {
playbackStarted = previewResumePlaybackStarted playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
resetPlaybackIdentity()
setUpPlaybackIdentity( setUpPlaybackIdentity(
playbackTitle, playbackTitle,
playbackSeriesName, playbackSeriesName,
@@ -3958,6 +4084,7 @@ class PlayerActivity : ComponentActivity() {
this, this,
playbackSession(completedItemId), playbackSession(completedItemId),
playback?.currentPosition ?: 0L, playback?.currentPosition ?: 0L,
knownDurationMs(playback),
) )
} }
finish() finish()
@@ -4065,6 +4192,7 @@ class PlayerActivity : ComponentActivity() {
this, this,
playbackSession(previousId), playbackSession(previousId),
playback?.currentPosition ?: 0L, playback?.currentPosition ?: 0L,
knownDurationMs(playback),
) )
} }
@@ -4100,13 +4228,9 @@ class PlayerActivity : ComponentActivity() {
itemName = nextTitle(next), itemName = nextTitle(next),
itemType = "Episode", itemType = "Episode",
) )
playbackIdentityShown = false // The incoming episode gets its own ident; nothing of the outgoing one's is left
playbackIdentityHideJob?.cancel() // armed, showing, or counted as already spent.
playbackIdentityView?.apply { resetPlaybackIdentity()
animate().cancel()
alpha = 0f
visibility = View.GONE
}
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L) initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
renderedFirstFrame = false renderedFirstFrame = false
automaticRetryAttempt = 0 automaticRetryAttempt = 0
@@ -4198,6 +4322,9 @@ class PlayerActivity : ComponentActivity() {
private fun bindPauseOverlay(view: PlayerView) { private fun bindPauseOverlay(view: PlayerView) {
pauseOverlay = view.findViewById(R.id.player_pause_overlay) pauseOverlay = view.findViewById(R.id.player_pause_overlay)
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group) 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<TextView>(R.id.player_pause_title)?.text = playbackTitle pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply { pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) } text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
@@ -4217,7 +4344,11 @@ class PlayerActivity : ComponentActivity() {
val paused = playbackStarted && !prerollActive && val paused = playbackStarted && !prerollActive &&
playback.playbackState == Player.STATE_READY && !playback.isPlaying playback.playbackState == Player.STATE_READY && !playback.isPlaying
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE 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() if (paused) playerView?.showController()
} }
@@ -5271,7 +5402,12 @@ class PlayerActivity : ComponentActivity() {
stopReported = true stopReported = true
stoppedInBackground = true stoppedInBackground = true
itemId?.takeIf(String::isNotBlank)?.let { id -> 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, this,
playbackSession(itemId!!), playbackSession(itemId!!),
playback?.currentPosition ?: 0L, playback?.currentPosition ?: 0L,
knownDurationMs(playback),
) )
} }
playerView?.player = null playerView?.player = null
@@ -5431,7 +5568,7 @@ class PlayerActivity : ComponentActivity() {
) )
if (changed && playbackStarted && oldSession != null) { if (changed && playbackStarted && oldSession != null) {
stopProgressUploading() stopProgressUploading()
PlaybackStopWorker.enqueue(this, oldSession, positionMs) PlaybackStopWorker.enqueue(this, oldSession, positionMs, knownDurationMs())
playbackStarted = false playbackStarted = false
stopReported = false stopReported = false
stoppedInBackground = false stoppedInBackground = false
@@ -5442,6 +5579,15 @@ class PlayerActivity : ComponentActivity() {
playMethod = newPlayMethod.ifBlank { "DirectPlay" } 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( private fun playbackSession(id: String) = PlaybackSession(
itemId = id, itemId = id,
mediaSourceId = mediaSourceId.ifBlank { id }, mediaSourceId = mediaSourceId.ifBlank { id },
@@ -34,6 +34,7 @@ import com.composables.icons.fontawesome.solid.Heart
import com.composables.icons.fontawesome.solid.Home import com.composables.icons.fontawesome.solid.Home
import com.composables.icons.fontawesome.solid.Image import com.composables.icons.fontawesome.solid.Image
import com.composables.icons.fontawesome.solid.Inbox 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.InfoCircle
import com.composables.icons.fontawesome.solid.Magic import com.composables.icons.fontawesome.solid.Magic
import com.composables.icons.fontawesome.solid.Medal import com.composables.icons.fontawesome.solid.Medal
@@ -98,6 +99,7 @@ internal val fontAwesomeIconPack = MembyIconPack(
MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble }, MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble },
MembyIcon.Add to { FontAwesome.Solid.Plus }, MembyIcon.Add to { FontAwesome.Solid.Plus },
MembyIcon.Close to { FontAwesome.Solid.Times }, MembyIcon.Close to { FontAwesome.Solid.Times },
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft }, MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight }, MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown }, MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
@@ -31,6 +31,7 @@ import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.House import com.composables.icons.lucide.House
import com.composables.icons.lucide.ImageOff import com.composables.icons.lucide.ImageOff
import com.composables.icons.lucide.Inbox import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Info import com.composables.icons.lucide.Info
import com.composables.icons.lucide.LayoutGrid import com.composables.icons.lucide.LayoutGrid
import com.composables.icons.lucide.LibraryBig import com.composables.icons.lucide.LibraryBig
@@ -98,6 +99,7 @@ internal val lucideIconPack = MembyIconPack(
MembyIcon.Check to { Lucide.Check }, MembyIcon.Check to { Lucide.Check },
MembyIcon.Add to { Lucide.Plus }, MembyIcon.Add to { Lucide.Plus },
MembyIcon.Close to { Lucide.X }, MembyIcon.Close to { Lucide.X },
MembyIcon.Refresh to { Lucide.RefreshCw },
MembyIcon.ChevronLeft to { Lucide.ChevronLeft }, MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
MembyIcon.ChevronRight to { Lucide.ChevronRight }, MembyIcon.ChevronRight to { Lucide.ChevronRight },
MembyIcon.ChevronDown to { Lucide.ChevronDown }, MembyIcon.ChevronDown to { Lucide.ChevronDown },
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Inbox 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.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Landscape import androidx.compose.material.icons.filled.Landscape
@@ -116,6 +117,7 @@ object MaterialIconPack {
MembyIcon.CheckAll to { Icons.Default.DoneAll }, MembyIcon.CheckAll to { Icons.Default.DoneAll },
MembyIcon.Add to { Icons.Default.Add }, MembyIcon.Add to { Icons.Default.Add },
MembyIcon.Close to { Icons.Default.Close }, MembyIcon.Close to { Icons.Default.Close },
MembyIcon.Refresh to { Icons.Default.Refresh },
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft }, MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
MembyIcon.ChevronRight to { Icons.Default.ChevronRight }, MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown }, MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
@@ -51,6 +51,7 @@ enum class MembyIcon {
CheckAll, CheckAll,
Add, Add,
Close, Close,
Refresh,
// Movement // Movement
ChevronLeft, ChevronLeft,
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The plate behind the episode line of the station ident. Deliberately quiet: the series
logo above it is the thing being announced, and this only has to stay readable over
whatever frame the programme opens on. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#A6000000" />
<corners android:radius="7dp" />
</shape>
@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- A short, non-focusable station ident shown over the first five seconds of content. <!-- The station ident: a short, non-focusable identity treatment over the opening seconds
For television, the programme logo leads and the episode sits directly beneath it. --> of a programme. The series (or film) logo leads; for television a compact episode line
sits directly beneath it, subordinate to the logo and readable over any frame.
The logo occupies a fixed box so the episode line sits at the same place whatever the
artwork's proportions are, and so a title with no logo at all does not shift it either.
It shares the top-start corner with the transport's own now-playing block, which is why
only one of the two is ever on screen — see PlaybackIdentity.kt. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_identity" android:id="@+id/player_playback_identity"
android:layout_width="560dp" android:layout_width="560dp"
@@ -14,43 +21,54 @@
android:orientation="vertical" android:orientation="vertical"
android:visibility="gone"> android:visibility="gone">
<ImageView <FrameLayout
android:id="@+id/player_playback_identity_logo" android:id="@+id/player_playback_identity_mark"
android:layout_width="300dp"
android:layout_height="82dp"
android:adjustViewBounds="true"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="gone" />
<TextView
android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="82dp">
android:ellipsize="end"
android:maxLines="1" <ImageView
android:maxWidth="540dp" android:id="@+id/player_playback_identity_logo"
android:shadowColor="#E0000000" android:layout_width="300dp"
android:shadowDx="0" android:layout_height="match_parent"
android:shadowDy="2" android:layout_gravity="start|bottom"
android:shadowRadius="5" android:adjustViewBounds="true"
android:textColor="#FFFFFFFF" android:contentDescription="@string/player_title_logo"
android:textSize="26sp" android:scaleType="fitStart"
android:textStyle="bold" /> android:visibility="gone" />
<TextView
android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start|bottom"
android:ellipsize="end"
android:maxLines="1"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold"
android:visibility="gone" />
</FrameLayout>
<TextView <TextView
android:id="@+id/player_playback_identity_episode" android:id="@+id/player_playback_identity_episode"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="7dp" android:layout_marginTop="10dp"
android:background="@drawable/player_identity_episode_background"
android:ellipsize="end" android:ellipsize="end"
android:letterSpacing="0.02"
android:maxLines="1" android:maxLines="1"
android:maxWidth="540dp" android:maxWidth="500dp"
android:shadowColor="#E0000000" android:paddingStart="11dp"
android:shadowDx="0" android:paddingTop="5dp"
android:shadowDy="2" android:paddingEnd="11dp"
android:shadowRadius="5" android:paddingBottom="6dp"
android:textColor="#E6FFFFFF" android:textColor="#FFFFFFFF"
android:textSize="19sp" android:textSize="16sp"
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>
@@ -18,6 +18,7 @@ import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
import com.ponzischeme89.memby.data.model.RecommendationOnboarding import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -453,6 +454,29 @@ class GatewayPayloadTest {
assertEquals("incinemas", item.membyLifecycle) assertEquals("incinemas", item.membyLifecycle)
assertEquals("IN CINEMAS", item.membyLifecycleText) assertEquals("IN CINEMAS", item.membyLifecycleText)
assertEquals(false, item.membyPlayable) 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<BaseItem>(payload)
assertEquals("emby-4821", item.membyMovieItemId)
assertTrue(item.isMovieSchedule)
assertFalse(item.isRadarrOnly)
} }
@Test @Test
@@ -19,6 +19,7 @@ class UserPreferencesTest {
fun `encoding and decoding is a fixed point`() { fun `encoding and decoding is a fixed point`() {
val original = UserPreferences( val original = UserPreferences(
profileInitials = "MC", profileInitials = "MC",
shortName = "Matt",
homeSections = listOf("latest", "continue"), homeSections = listOf("latest", "continue"),
homeCardDensity = "large", homeCardDensity = "large",
homeArtworkStyle = "poster", homeArtworkStyle = "poster",
@@ -1,10 +1,15 @@
package com.ponzischeme89.memby.ui 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.launchResumePositionMs
import com.ponzischeme89.memby.data.playbackCompletesItem
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.primaryActionLabel import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ContinueWatchingResumeTest { class ContinueWatchingResumeTest {
@@ -41,4 +46,81 @@ class ContinueWatchingResumeTest {
launchResumePositionMs(resolvedPositionMs = 42_000L, requestedPositionMs = 0L), 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))
}
} }
@@ -2,10 +2,25 @@ package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class HomeGreetingTest { 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 @Test
fun `time of day selects the expected greeting`() { fun `time of day selects the expected greeting`() {
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4)) assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4))
@@ -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 = "%"),
),
)
}
@@ -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<String>(), 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<RadarrMovieDetail>(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)
}
}
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.ui.alerts package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.data.model.UserNotification
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
@@ -38,4 +39,66 @@ class AlertsFormatTest {
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2)) assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1)) 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<Long>(), 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,
)
} }
@@ -1,7 +1,9 @@
package com.ponzischeme89.memby.ui.alerts package com.ponzischeme89.memby.ui.alerts
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.NotificationPreferences import com.ponzischeme89.memby.data.model.NotificationPreferences
@@ -40,18 +42,79 @@ class AlertsPageScreenshotTest {
capture("my-alerts-populated", sampleAlerts) 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 @Test
fun `everything already read`() { fun `everything already read`() {
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") }) 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 @Test
fun `nothing waiting`() { fun `nothing waiting`() {
capture("my-alerts-empty", emptyList()) 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 @Test
fun `alerts switched off`() { fun `alerts switched off`() {
capture( capture(
@@ -125,24 +188,37 @@ class AlertsPageScreenshotTest {
name: String, name: String,
notifications: List<UserNotification>, notifications: List<UserNotification>,
preferences: NotificationPreferences = NotificationPreferences(), preferences: NotificationPreferences = NotificationPreferences(),
act: () -> Unit = {},
) { ) {
compose.setContent { compose.setContent {
MembyTheme { MembyTheme {
MyAlertsPage( MyAlertsPage(
notifications = notifications, notifications = notifications,
preferences = preferences, preferences = preferences,
onToggleEnabled = {},
onToggleShowReturns = {},
onRead = {},
onDismiss = {}, onDismiss = {},
onDismissAll = {}, onDismissAll = {},
onClose = {}, onClose = {},
) )
} }
} }
act()
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png") 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<UserNotification>
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( private val sampleAlerts = listOf(
UserNotification( UserNotification(
id = 1, id = 1,
@@ -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<Int>(), alertPageItems(listOf(1, 2), 5, 4))
assertEquals(emptyList<Int>(), alertPageItems(emptyList<Int>(), 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)
}
}
@@ -88,7 +88,7 @@ class PlaybackIdentityScreenshotTest {
} }
identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE
identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply { identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = "S02E04 · The Other You" text = "S02E04 The Other You"
visibility = View.VISIBLE visibility = View.VISIBLE
} }
root.addView(identity) 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<ImageView>(R.id.player_playback_identity_logo).apply {
setImageBitmap(colourLogo())
visibility = View.VISIBLE
}
identity.findViewById<TextView>(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<TextView>(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<ImageView>(R.id.player_playback_identity_logo).visibility = View.GONE
identity.findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = "Friends"
visibility = View.VISIBLE
}
identity.findViewById<TextView>(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 @Test
fun `loading keeps the selected backdrop visible`() { fun `loading keeps the selected backdrop visible`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -120,7 +174,7 @@ class PlaybackIdentityScreenshotTest {
@Test @Test
fun `episode ident separates the series from the episode`() { fun `episode ident separates the series from the episode`() {
assertEquals( assertEquals(
"S02E04 · The Other You", "S02E04 The Other You",
playbackIdentityEpisodeLabel( playbackIdentityEpisodeLabel(
title = "Dark Matter The Other You", title = "Dark Matter The Other You",
seriesName = "Dark Matter", seriesName = "Dark Matter",
@@ -130,6 +184,95 @@ class PlaybackIdentityScreenshotTest {
assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null)) 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 { private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(82, 181, 75)) eraseColor(Color.rgb(82, 181, 75))
} }
+8 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/mdblist" "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/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/scheduler" "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 // 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. // every call site, which is exactly how an event comes to be silently dropped.
adminBus := adminevents.New(st, log) 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) adminBus.AddSink(dispatcher)
sched := scheduler.New(st, log, adminBus) sched := scheduler.New(st, log, adminBus)
@@ -286,6 +292,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
AdminEvents: adminBus, AdminEvents: adminBus,
Scheduler: sched, Scheduler: sched,
Integrations: dispatcher, Integrations: dispatcher,
Notify: notifier,
LogLevel: logLevel, LogLevel: logLevel,
}) })
if err := server.LoadQuietTime(ctx); err != nil { if err := server.LoadQuietTime(ctx); err != nil {
+5
View File
@@ -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", s.adminAuth(s.handleAdminLoginDevices))
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail)) 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. // The administrative feed behind the notification bell.
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications)) mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead)) mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
+10 -5
View File
@@ -32,9 +32,13 @@ type adminOnboardingPreferences struct {
} }
type adminMembyAccount struct { type adminMembyAccount struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Initials string `json:"initials"` 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"` CreatedAt time.Time `json:"createdAt"`
LastSeen time.Time `json:"lastSeen"` LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"` Devices []store.MembyDevice `json:"devices"`
@@ -153,8 +157,9 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
result = append(result, adminMembyAccount{ result = append(result, adminMembyAccount{
WatchTime: summariseWatchTime(watched, matchedWatchTime), WatchTime: summariseWatchTime(watched, matchedWatchTime),
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"), Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings, ShortName: stringPreference(accountSettings.Preferences, "shortName"),
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]), Themes: nonNilStrings(themes[account.ID]),
Notifications: notificationPrefs, Notifications: notificationPrefs,
Recommendations: adminOnboardingPreferences{ Recommendations: adminOnboardingPreferences{
@@ -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)
}
+20 -4
View File
@@ -99,9 +99,25 @@ type storedAlert struct {
// Failures are logged and swallowed. A missed banner is not worth failing the thing that // 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 // produced it — an import, a library sync, a health probe — none of which the viewer
// would want retried for the sake of a notice. // 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) { 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 { 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 // 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 // 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) stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now)
body, err := json.Marshal(stored) body, err := json.Marshal(stored)
if err != nil { if err != nil {
s.loggerFor(ctx).Warn("alert encode failed", "error", err) return fmt.Errorf("alert encode failed: %w", err)
return
} }
// The key's own TTL is a floor sweep for a gateway that stops producing events; the // 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. // per-entry expiry is what actually decides what a client sees.
if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil { 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 // appendAlert is the pure half of publishing: prune what has expired, replace any earlier
+12 -12
View File
@@ -34,18 +34,18 @@ type analyticsRequest struct {
} }
type journeyEventPayload struct { type journeyEventPayload struct {
UserID string `json:"userId"` UserID string `json:"userId"`
JourneyID string `json:"journeyId"` JourneyID string `json:"journeyId"`
Sequence int `json:"sequence"` Sequence int `json:"sequence"`
Category string `json:"category"` Category string `json:"category"`
Action string `json:"action"` Action string `json:"action"`
Screen string `json:"screen"` Screen string `json:"screen"`
Feature string `json:"feature"` Feature string `json:"feature"`
Source string `json:"source"` Source string `json:"source"`
Target string `json:"target"` Target string `json:"target"`
ItemID string `json:"itemId"` ItemID string `json:"itemId"`
ItemName string `json:"itemName"` ItemName string `json:"itemName"`
ItemType string `json:"itemType"` ItemType string `json:"itemType"`
// The Emby play session a playback step belongs to. Validated like every other // 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 // 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. // cannot read is dropped whole, so the television sanitises it before sending.
+19 -2
View File
@@ -33,6 +33,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/integrations" "github.com/ponzischeme89/memby/server/internal/integrations"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging" serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/mdblist" "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/opensubtitles"
"github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/recommend"
@@ -71,7 +72,11 @@ type Server struct {
adminEvents *adminevents.Bus adminEvents *adminevents.Bus
scheduler *scheduler.Scheduler scheduler *scheduler.Scheduler
integrations *integrations.Dispatcher 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 // 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. // to My Shows never waits behind a launcher rebuilding the schedule row.
sonarrSeriesMu sync.Mutex sonarrSeriesMu sync.Mutex
@@ -143,6 +148,10 @@ type Deps struct {
AdminEvents *adminevents.Bus AdminEvents *adminevents.Bus
Scheduler *scheduler.Scheduler Scheduler *scheduler.Scheduler
Integrations *integrations.Dispatcher 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 // 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 // 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. // 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 { func New(cfg config.Config, deps Deps) *Server {
return &Server{ server := &Server{
cfg: cfg, cfg: cfg,
emby: deps.Emby, emby: deps.Emby,
store: deps.Store, store: deps.Store,
@@ -172,9 +181,16 @@ func New(cfg config.Config, deps Deps) *Server {
scheduler: deps.Scheduler, scheduler: deps.Scheduler,
integrations: deps.Integrations, integrations: deps.Integrations,
notify: deps.Notify,
logLevel: deps.LogLevel, logLevel: deps.LogLevel,
deployedLogLevel: deployedLevel(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 { 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 // token arrives in the query string, the way artwork's does, because a media player
// fetching a sidecar sends none of Memby's headers. // fetching a sidecar sends none of Memby's headers.
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle)) 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}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers)) v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer)) v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
+1 -1
View File
@@ -142,7 +142,7 @@ var featureCatalogue = []featureDefinition{
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications", Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
Description: "Tell each viewer how long they watched this week and this month, on " + 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 " + "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, DefaultEnabled: true, MinimumProtocol: 1,
Recovery: "Server-enforced; takes effect before the next summary is due.", Recovery: "Server-enforced; takes effect before the next summary is due.",
}, },
+16
View File
@@ -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{ sched.Register(scheduler.Task{
ID: "device-activity-cleanup", ID: "device-activity-cleanup",
Name: "Device activity cleanup", Name: "Device activity cleanup",
+8 -4
View File
@@ -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. // 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) { func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
window := s.radarrAlertWindow() window := s.radarrAlertWindow()
title := strings.TrimSpace(result.Name) title := strings.TrimSpace(result.Name)
if window <= 0 || title == "" || result.ItemID == "" { if title == "" || result.ItemID == "" {
return return
} }
now := time.Now().UTC() now := time.Now().UTC()
@@ -68,7 +72,7 @@ func (s *Server) announceImportedMovie(ctx context.Context, result library.Inges
if result.Year > 0 { if result.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, result.Year) 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 // 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. // a film deleted and re-imported is news again. Clients dedupe on this id forever.
ID: "ingest:movie:" + result.ItemID, 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. // as it does the "aired, coming soon" one, without touching films.
window := s.sonarrAlertWindow() window := s.sonarrAlertWindow()
series := strings.TrimSpace(result.SeriesName) series := strings.TrimSpace(result.SeriesName)
if window <= 0 || series == "" || result.ItemID == "" { if series == "" || result.ItemID == "" {
return return
} }
now := time.Now().UTC() now := time.Now().UTC()
@@ -98,7 +102,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
seasonRunKey(series, result.Season), result.ItemID, seasonRunKey(series, result.Season), result.ItemID,
episodeSummary(result), now, ingestRunWindow, 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 // 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 // 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 // episode, arriving after the window has closed, starts a run of its own and is
+1 -1
View File
@@ -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 { func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem {
t.Helper() t.Helper()
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC) row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC, nil)
if err != nil { if err != nil {
t.Fatalf("buildRadarrRow: %v", err) t.Fatalf("buildRadarrRow: %v", err)
} }
+19 -4
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -213,10 +214,18 @@ func (s *Server) syncReturnNotifications(
message = show.Title + " returns next week." message = show.Title + " returns next week."
} }
sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02") sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02")
_ = s.store.UpsertNotification( s.notifyUser(r.Context(), notify.Notification{
r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID, Kind: "show-return",
"New episode coming", message, series.NextAiring, 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") { switch r.PathValue("action") {
case "read": case "read":
err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id) 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": case "dismiss":
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id) err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
default: default:
+163
View File
@@ -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)
}
+24 -8
View File
@@ -12,6 +12,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/emby"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging" serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/store" "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) s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
return "" return ""
} }
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) { notification := notify.Notification{
_ = s.store.UpsertNotification( Kind: "auto-follow",
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow", Source: notifySourceAutoFollow,
episode.SeriesID, "Added to My Shows", UserID: sess.EmbyUserID,
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil, Username: sess.Username,
) Title: "Added to My Shows",
return seriesItem.Name 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 { func max64(v, floor int64) int64 {
+29 -3
View File
@@ -61,8 +61,16 @@ type preferenceDefinition struct {
// number reads as minutes, which is what the first one to exist happened to be. // number reads as minutes, which is what the first one to exist happened to be.
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
MaxLength int `json:"maxLength,omitempty"` MaxLength int `json:"maxLength,omitempty"`
AdminOnly bool `json:"adminOnly,omitempty"` // Uppercase folds a text value to capitals. It belongs to the definition rather than
Default any `json:"default"` // 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 { func option(value, label string) preferenceOption {
@@ -74,6 +82,17 @@ var preferenceCatalogue = []preferenceDefinition{
Key: "profileInitials", Name: "Profile initials", Area: "Profile", 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.", 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, 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", 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 // 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. // list long enough to matter is already a bug on that end.
const maxListEntries = 200 const maxListEntries = 200
@@ -334,7 +357,10 @@ func normalizePreference(definition preferenceDefinition, value any) any {
trimmed := strings.TrimSpace(typed) trimmed := strings.TrimSpace(typed)
if !strings.ContainsAny(trimmed, "\n\r") && if !strings.ContainsAny(trimmed, "\n\r") &&
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) { (definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
return strings.ToUpper(trimmed) if definition.Uppercase {
return strings.ToUpper(trimmed)
}
return trimmed
} }
} }
} }
+32
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"reflect" "reflect"
"strings"
"testing" "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) { func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"})) stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
if err != nil { if err != nil {
+42 -3
View File
@@ -56,6 +56,13 @@ type radarrScheduleItem struct {
MembyLifecycle string `json:"MembyLifecycle,omitempty"` MembyLifecycle string `json:"MembyLifecycle,omitempty"`
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"` MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
MembyPlayable bool `json:"MembyPlayable"` 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) { 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 { if err != nil {
return nil, err return nil, err
} }
row, err := buildRadarrRow(movies, now, location) row, err := buildRadarrRow(movies, now, location, s.embyMovieIndex(ctx, movies))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -114,7 +121,37 @@ func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row
return &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 { sort.SliceStable(movies, func(i, j int) bool {
left, leftOK := effectiveRadarrRelease(movies[i]) left, leftOK := effectiveRadarrRelease(movies[i])
right, rightOK := effectiveRadarrRelease(movies[j]) 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) { if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) {
continue 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 { if err != nil {
return nil, err return nil, err
} }
+306
View File
@@ -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
}
+192
View File
@@ -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)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindo
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true}, {ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true}, {ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true}, {ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
}, now, location) }, now, location, map[int]string{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+4 -4
View File
@@ -57,7 +57,7 @@ const (
// Nothing here waits for the deployment to finish. The gateway that publishes this is the // 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. // one being retired; the one that comes back has no memory of having said it.
func (s *Server) AnnounceDeployment(ctx context.Context) { 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 { func deploymentAlert(now time.Time) clientAlert {
@@ -84,7 +84,7 @@ func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result)
return return
} }
now := time.Now().UTC() 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 // Keyed on the minute the sync finished: two runs are two pieces of news, but a
// retried publish of the same run is not. // retried publish of the same run is not.
ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()), ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()),
@@ -153,13 +153,13 @@ func (s *Server) WatchEmbyReachability(ctx context.Context) {
reachable = false reachable = false
s.log.Warn("emby unreachable, announcing", s.log.Warn("emby unreachable, announcing",
"component", "emby-health", "failures", failures, "error", err) "component", "emby-health", "failures", failures, "error", err)
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow) s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(false), reachabilityAlertWindow)
} }
continue continue
} }
if !reachable { if !reachable {
s.log.Info("emby reachable again, announcing", "component", "emby-health") 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 reachable = true
failures = 0 failures = 0
+51 -22
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/sonarr" "github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -95,21 +96,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
} }
preferences[user.ID] = prefs preferences[user.ID] = prefs
} }
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
continue
}
eventAt := change.Current.ObservedAt eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID) sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " was added to Sonarr." notification := notify.Notification{
if err := s.store.UpsertNotification( Kind: "show-added",
ctx, user.ID, sourceKey, "show-added", "", Source: notifySourceSonarrLifecycle,
"Show added", message, &eventAt, UserID: user.ID,
); err != nil { Username: user.Username,
s.log.Warn("Sonarr addition notification failed", Title: "Show added",
"user", user.ID, "show", change.Current.Title, "error", err) 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 continue
} }
notifications++ if s.notifyUser(ctx, notification) {
notifications++
}
} }
} }
for _, change := range cancellations { for _, change := range cancellations {
@@ -125,21 +131,26 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
} }
preferences[user.ID] = prefs preferences[user.ID] = prefs
} }
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
continue
}
eventAt := change.Current.ObservedAt eventAt := change.Current.ObservedAt
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID) sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
message := change.Current.Title + " is now listed as cancelled in Sonarr." notification := notify.Notification{
if err := s.store.UpsertNotification( Kind: "show-cancelled",
ctx, user.ID, sourceKey, "show-cancelled", "", Source: notifySourceSonarrLifecycle,
"Show cancelled", message, &eventAt, UserID: user.ID,
); err != nil { Username: user.Username,
s.log.Warn("Sonarr cancellation notification failed", Title: "Show cancelled",
"user", user.ID, "show", change.Current.Title, "error", err) 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 continue
} }
notifications++ if s.notifyUser(ctx, notification) {
notifications++
}
} }
} }
s.log.Info("Sonarr lifecycle scan complete", s.log.Info("Sonarr lifecycle scan complete",
@@ -176,3 +187,21 @@ func sonarrBecameCancelled(previous, current string) bool {
current == "cancelled" || current == "canceled" current == "cancelled" || current == "canceled"
return active && cancelled 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"
}
}
+7
View File
@@ -247,6 +247,13 @@ func (s *Server) resolveLocalTrailer(
} }
func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) { 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) key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID)
if s.cache != nil { if s.cache != nil {
if raw, err := s.cache.Get(ctx, key); err == nil { if raw, err := s.cache.Get(ctx, key); err == nil {
+37 -17
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/scheduler" "github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -138,19 +139,31 @@ func (s *Server) sendWeeklyWatchTime(
if total < watchTimeDigestFloor { if total < watchTimeDigestFloor {
continue continue
} }
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username) monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
message := weeklyDigestMessage( message := weeklyDigestMessage(
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle) total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
if err := s.store.UpsertNotification( notification := notify.Notification{
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt, Kind: watchTimeWeeklyKind,
); err != nil { Source: notifySourceWatchTime,
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err) 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 continue
} }
sent++ if s.notifyUser(ctx, notification) {
sent++
}
} }
if sent > 0 { if sent > 0 {
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location)) 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 { if total < watchTimeDigestFloor {
continue continue
} }
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
message := monthlyDigestMessage(total, monthName, watched.TopTitle) message := monthlyDigestMessage(total, monthName, watched.TopTitle)
if err := s.store.UpsertNotification( notification := notify.Notification{
ctx, account.ID, key, watchTimeMonthlyKind, Kind: watchTimeMonthlyKind,
"", monthName+" in Memby", message, &eventAt, Source: notifySourceWatchTime,
); err != nil { UserID: account.ID,
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err) 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 continue
} }
sent++ if s.notifyUser(ctx, notification) {
sent++
}
} }
if sent > 0 { if sent > 0 {
s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID) s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID)
+1 -1
View File
@@ -1 +1 @@
0.1.55 0.1.57
+56 -2
View File
@@ -20,6 +20,7 @@ import (
"time" "time"
"github.com/ponzischeme89/memby/server/internal/adminevents" "github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/notify"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -62,6 +63,11 @@ type Dispatcher struct {
log *slog.Logger log *slog.Logger
client *http.Client client *http.Client
events *adminevents.Bus 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 transports map[string]Transport
queue chan job queue chan job
@@ -80,9 +86,12 @@ type Dispatcher struct {
// SetPaused installs the server-wide quiet-time gate before Start is called. // SetPaused installs the server-wide quiet-time gate before Start is called.
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused } 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{ dispatcher := &Dispatcher{
store: st, log: log.With("component", "integrations"), events: events, store: st, log: log.With("component", "integrations"), events: events,
notify: notifier,
client: &http.Client{Timeout: requestTimeout}, client: &http.Client{Timeout: requestTimeout},
transports: map[string]Transport{}, transports: map[string]Transport{},
queue: make(chan job, queueDepth), queue: make(chan job, queueDepth),
@@ -211,11 +220,12 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev
if err != nil { if err != nil {
message = err.Error() message = err.Error()
} }
took := time.Since(started)
if d.store != nil { if d.store != nil {
record := store.IntegrationDelivery{ record := store.IntegrationDelivery{
IntegrationID: integration.ID, EventType: event.Type, IntegrationID: integration.ID, EventType: event.Type,
Success: err == nil, StatusCode: status, Success: err == nil, StatusCode: status,
DurationMS: time.Since(started).Milliseconds(), Error: message, DurationMS: took.Milliseconds(), Error: message,
} }
if writeErr := d.store.RecordIntegrationDelivery( if writeErr := d.store.RecordIntegrationDelivery(
context.WithoutCancel(ctx), record, 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) 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 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. // 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 // It publishes a *different* type from the event that failed, and integration.failed is
+343
View File
@@ -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
}
+248
View File
@@ -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)
}
}
+24 -1
View File
@@ -44,7 +44,15 @@ type Movie struct {
InCinemas *time.Time `json:"inCinemas"` InCinemas *time.Time `json:"inCinemas"`
// Radarr's own lifecycle word for the title: tba, announced, inCinemas, released, // Radarr's own lifecycle word for the title: tba, announced, inCinemas, released,
// deleted. It is what the schedule card's lifecycle tag says. // 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"` HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"` Monitored bool `json:"monitored"`
MovieFile *MovieFile `json:"movieFile"` MovieFile *MovieFile `json:"movieFile"`
@@ -139,6 +147,21 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, e
return movies, nil 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) { func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}}) req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}})
if err != nil { if err != nil {
+26 -3
View File
@@ -281,16 +281,26 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
return result, rows.Err() 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( func (s *Store) UpsertNotification(
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time, ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
) error { ) (bool, error) {
_, err := s.pool.Exec(ctx, ` tag, err := s.pool.Exec(ctx, `
INSERT INTO user_notifications INSERT INTO user_notifications
(emby_user_id, source_key, kind, item_id, title, message, event_at) (emby_user_id, source_key, kind, item_id, title, message, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, source_key) DO NOTHING`, ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
userID, sourceKey, kind, itemID, title, message, eventAt) 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) { 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 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 { func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET dismissed_at = now() UPDATE user_notifications SET dismissed_at = now()
+378
View File
@@ -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
}
+129
View File
@@ -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")
}
}
+39
View File
@@ -831,3 +831,42 @@ CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
WHERE state = 'pending'; WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
ON library_ingest_queue (updated_at DESC); 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);