0.2.77
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -1,216 +0,0 @@
|
|||||||
# Design fixes — home screen and detail pages
|
|
||||||
|
|
||||||
Audit of 2026-08-01. Findings only; no product code was changed. The three items under
|
|
||||||
"Confirmed layout bugs" were reproduced by rendering the real composables at TV 1080p
|
|
||||||
(`w960dp-h540dp-television-xhdpi`) through the existing Roborazzi harness — everything
|
|
||||||
else is read from the source.
|
|
||||||
|
|
||||||
**All of it is implemented.** The findings below are left as written — they are the
|
|
||||||
diagnosis, and each one says why the fix is shaped the way it is. What was done:
|
|
||||||
|
|
||||||
| # | Fixed in |
|
|
||||||
|---|----------|
|
|
||||||
| 1 | `HomeMovieHero.kt` — column padding 28→22dp, and a wrapped title stands the synopsis down (`onTextLayout` line count) so Play is never what gets cut. Captured as `df_home-movie-hero-long-title.png`. |
|
|
||||||
| 2 | `DetailPageComponents.detailPaneHeight()` — the slot is derived from the viewport (250–420dp) instead of a hard 250dp. Every technical spec now renders; `df_detail-pane-cast-details.png`. Studio, which both columns claimed, is dropped from the technical column. |
|
|
||||||
| 3 | `DetailFoldPeek` (34dp) holds the tab strip off the bottom edge. |
|
|
||||||
| 4 | The hero's private `heroFacts`/runtime formatter is gone; it calls `detail/DetailFacts.kt`. `HomeComponents.formatTvRuntime` too — one formatter left in the app. |
|
|
||||||
| 5 | `MembyScore` token, and every rating goes through `ratingLabel` (`Locale.US`). The home metadata panel renders the score as its own run of text so it can carry the same gold. |
|
|
||||||
| 6 | "No favourite shows yet" / "Mark a series as a favourite…". |
|
|
||||||
| 7 | `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. |
|
|
||||||
| 8 | `ui/theme/DesignTokens.kt`; `HomeComponents` and `DetailPageComponents` colours are aliases of it, and `Theme.kt` uses the same near-blacks. |
|
|
||||||
| 9 | Same — the detail page picked up the raised TV neutrals. |
|
|
||||||
| 10 | Three radii: `MembyChipCorner` 8, `MembyCardCorner` 10, `MembyPanelCorner` 14. |
|
|
||||||
| 11 | `ui/MembyButtons.kt` — `MembyPlayButton`, `MembyPlayChip`, `MembyChoiceChip`. The hero chip, the detail Play button, the metadata panel's "▶ Resume" and the For You time budget all use them. |
|
|
||||||
| 12 | `DetailHero` honours `Settings.showTitleLogo` (new `EmbyRepository.showTitleLogo`) and shares `useTextTitleForLogo` with the screensaver (`ui/TitleLogo.kt`). |
|
|
||||||
| 13 | One `UHD_MIN_WIDTH` (3800) for the badge and the `(4K)` suffix. Unit-tested. |
|
|
||||||
| 14 | `mediaBadges` reads `dynamicRangeLabel`, so HDR10+ stays HDR10+. Unit-tested. |
|
|
||||||
| 15 | One `FocusRequester` per pane in both overlays; none is attached to two live nodes. |
|
|
||||||
| 16 | A series passes `mediaBadges(item)`, and `DetailFactRow` takes 4 badges so the airing badge is not squeezed out. |
|
|
||||||
| 17 | `HomeRowHeaderIcon` / `HomeRowHeaderIconGap` / `HomeRowHeaderSpacing`, used by `MediaRow`, `MyShowsStrip` and `RecentSearchesRow` — which also gained the vertical padding its focus-scaled chips needed. |
|
|
||||||
| 18 | `HomeHeroPick` carries the row a title was drawn from; the caption is no longer the card's slot. Unit-tested. |
|
|
||||||
| 19 | Deleted (≈260 lines: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData`, `HomeRowSkeleton` and the two runtime formatters only they used). |
|
|
||||||
| 20 | The peek under the strip plus a chevron at its end. |
|
|
||||||
| Docs | `CLAUDE.md`'s detail-page section rewritten to describe this code, with the token, button and header conventions above it. |
|
|
||||||
|
|
||||||
Screenshots of the result are `app/build/screenshots/df_*.png`
|
|
||||||
(`.\gradlew.bat :app:testDebugUnitTest --tests "*ScreenshotTest"`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Confirmed layout bugs
|
|
||||||
|
|
||||||
These clip real content on a real TV. Fix these first.
|
|
||||||
|
|
||||||
### 1. The featured home hero drops its Play button when the title wraps to two lines
|
|
||||||
|
|
||||||
`ui/HomeMovieHero.kt:171-236`, `ui/MainActivity.kt:137` (`homeHeaderHeight`)
|
|
||||||
|
|
||||||
The card's content column measures ~229dp with a one-line title and ~261dp with two.
|
|
||||||
`homeHeaderHeight(540dp, showHero = true)` yields 248dp, minus the hero row's 16dp top and
|
|
||||||
10dp bottom padding, so the card gets **222dp** — and `FocusScaleContainer` clips it to a
|
|
||||||
14dp rounded rect. Rendered with a two-line title, the kicker, title, fact line and
|
|
||||||
synopsis draw and the green Play chip is **gone entirely**. A one-line title is already
|
|
||||||
7dp over budget; it only survives because the part cut off is the chip's shadow.
|
|
||||||
|
|
||||||
Fix direction: the column is `align(Alignment.CenterStart)` inside a fixed-height box, so
|
|
||||||
overflow is split top and bottom and the button is always the first thing lost. Either
|
|
||||||
give the hero a height derived from its content, cap the title at one line, or drop the
|
|
||||||
synopsis when the title wraps.
|
|
||||||
|
|
||||||
### 2. The Cast & Details tab silently discards every technical spec
|
|
||||||
|
|
||||||
`ui/DetailPageComponents.kt:258-278` (the 250dp pane), `:563-580` (`DetailFocusablePane`),
|
|
||||||
`:613-643` (`DetailCastAndDetailsPane`)
|
|
||||||
|
|
||||||
The tab content slot is a hard `.height(250.dp)` and `DetailFocusablePane` applies
|
|
||||||
`.clip(RoundedCornerShape(10.dp))`. `releaseAndTechnical` builds Released / Certificate /
|
|
||||||
Runtime and then `addAll(specs)` — Video, Codec, Audio, Subtitles, Studio. Rendered at the
|
|
||||||
real slot geometry, only the first three rows survive; the entire output of
|
|
||||||
`technicalSpecs()` is clipped below the fold of a pane that cannot scroll. That is the
|
|
||||||
whole reason the tab exists.
|
|
||||||
|
|
||||||
`DetailOverviewPane` shares the ceiling: a five-line synopsis plus the series "Up next"
|
|
||||||
supporting line pushes its credit rows past the same boundary.
|
|
||||||
|
|
||||||
Fix direction: the pane needs a height budget that accounts for its worst case, or the
|
|
||||||
two-column meta block needs to page/scroll. Note the pane deliberately does not scroll
|
|
||||||
(one screen per tab), so the honest fix is probably fewer rows per column, not a scroller.
|
|
||||||
|
|
||||||
### 3. The detail tab strip sits flush against the bottom screen edge
|
|
||||||
|
|
||||||
`ui/DetailPageComponents.kt:209` (`heroHeight = maxHeight - DetailTabHeight`), `:494-550`
|
|
||||||
|
|
||||||
The selection underline is cut in half at y=1080 in both `detail-movie-more-like-this.png`
|
|
||||||
and `detail-series-cast-details.png`. On a TV with overscan the underline and part of the
|
|
||||||
labels are off-screen. This is the only element in the app with zero safe-area inset —
|
|
||||||
gutters are 36-58dp and the home clock keeps 18dp.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Copy and formatting
|
|
||||||
|
|
||||||
### 4. The home hero formats runtime differently from everywhere else
|
|
||||||
|
|
||||||
`ui/HomeMovieHero.kt:351` is a private `heroFacts` shadowing `ui/detail/DetailFacts.kt:62`.
|
|
||||||
Different field order (year · certificate · runtime vs year · runtime · certificate) and
|
|
||||||
`"${it}m"` instead of `formatRuntime`. The checked-in `home-movie-hero.png` shows
|
|
||||||
**"2026 • M • 124m"** in the hero and **"2026 • 2h 4m"** on the card directly beneath it.
|
|
||||||
|
|
||||||
There are three runtime formatters in the app: `DetailFacts.formatRuntime`,
|
|
||||||
`HomeComponents.formatTvRuntime` (private, identical) and this one.
|
|
||||||
|
|
||||||
### 5. The community score changes colour and locale by screen
|
|
||||||
|
|
||||||
Gold `0xFFF5C518` on detail (`DetailPageComponents.kt:484`), grey `MutedText` on home
|
|
||||||
(`HomeComponents.kt:1025`). `HomeMovieHero.kt:355` uses `"★ %.1f".format(it)` with the
|
|
||||||
default locale while every other rating goes through `Locale.US` — a comma decimal in a
|
|
||||||
non-US locale.
|
|
||||||
|
|
||||||
### 6. American spelling in two user-facing strings
|
|
||||||
|
|
||||||
`ui/HomeComponents.kt:1549` "No **favorite** shows yet" and `:1555` "Mark a series as a
|
|
||||||
**favorite** and it'll be waiting here." The rail says Favourites, the quick menu says
|
|
||||||
"Add to favourites", the detail hero action says "Add to Favourites".
|
|
||||||
|
|
||||||
### 7. Four separator styles for the same kind of fact list
|
|
||||||
|
|
||||||
`" • "` (home metadata), `" • "` (detail fact row, schedule metadata, home hero),
|
|
||||||
`" · "` (home genres), `", "` (credit rows).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design-token drift
|
|
||||||
|
|
||||||
### 8. Four near-blacks
|
|
||||||
|
|
||||||
Theme `background 0xFF0B0E11` and `surface 0xFF101418` (`ui/theme/Theme.kt`, effectively
|
|
||||||
unused), home `0xFF090B0D`, detail `0xFF080A0C`. The accent green is duplicated four ways:
|
|
||||||
`EmbyGreen`, `DetailAccent`, and the hero's `0xFF69C762` / `0xFF7BD574`.
|
|
||||||
|
|
||||||
### 9. Secondary-text contrast diverged between the two screens
|
|
||||||
|
|
||||||
`ui/HomeComponents.kt:129-132` carries a comment about raising the neutrals for TV distance
|
|
||||||
(`MutedText 0xFFD0D6DB`, `QuietText 0xFFAEB7BF`). The detail page still uses the pre-fix
|
|
||||||
values (`DetailMutedText 0xFFB6BDC3`, `DetailQuietText 0xFF8C959D`). The two sit side by
|
|
||||||
side the moment a detail page is opened from a row.
|
|
||||||
|
|
||||||
### 10. Corner radii are ad hoc
|
|
||||||
|
|
||||||
9dp home cards, 8dp related posters, 8/10dp cast cards, 14dp featured hero, 11dp mini hero,
|
|
||||||
12dp overlays, 7dp chips, 999dp search chips.
|
|
||||||
|
|
||||||
### 11. Three button languages
|
|
||||||
|
|
||||||
The hand-rolled `DetailPlayButton` (23/12dp padding, 16sp), the hero's hand-rolled play chip
|
|
||||||
(12/7dp, 13sp — same look, different metrics), and raw `androidx.tv.material3.Button` with
|
|
||||||
glyph text in `MediaMetadataPanel` ("▶ Resume") and `ForYouTimeBudget` ("✓ 30 min"), which
|
|
||||||
picks up theme colours nothing else in the app uses.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logic and behaviour
|
|
||||||
|
|
||||||
### 12. `showTitleLogo` is ignored by the detail pages
|
|
||||||
|
|
||||||
The Settings copy promises "shows each title's logo artwork from Emby instead of plain
|
|
||||||
text", but only `ui/screensaver/ScreensaverContent.kt:773` honours it;
|
|
||||||
`ui/DetailPageComponents.kt:322` always fetches the logo. The screensaver also has
|
|
||||||
`useTextTitleForLogo`, a fallback for logos too dark to read — the detail hero has no
|
|
||||||
equivalent, so a dark logo is invisible on the near-black scrim.
|
|
||||||
|
|
||||||
### 13. Two different 4K thresholds
|
|
||||||
|
|
||||||
The badge fires at video width ≥ 3800 (`HomeComponents.kt:1184`); the `(4K)` suffix at
|
|
||||||
≥ 3400 (`DetailFacts.kt:190`). A 3600-wide file is 4K in the spec row and not on the badge.
|
|
||||||
|
|
||||||
### 14. HDR10+ is named in `dynamicRangeLabel` but collapses to plain "HDR" in `mediaBadges`
|
|
||||||
|
|
||||||
### 15. One `FocusRequester` attached to two live nodes
|
|
||||||
|
|
||||||
`informationPane` is attached by the Overview pane, the Cast & Details pane and the Episodes
|
|
||||||
empty states (`ui/MediaDetailsOverlay.kt:202-216`, `ui/SeriesDetailsOverlay.kt:291-329`).
|
|
||||||
`AnimatedContent`'s 80ms fade-out keeps the outgoing pane composed, so a Down press landing
|
|
||||||
in that window can request focus on a pane that is disappearing.
|
|
||||||
|
|
||||||
### 16. A series can never show format badges
|
|
||||||
|
|
||||||
`ui/SeriesDetailsOverlay.kt:248` passes `badges = emptyList()` where movies pass real ones.
|
|
||||||
Related: `DetailFactRow` does `badges.take(3)`, so on a 4K/HDR/HEVC movie the airing badge
|
|
||||||
appended in `ui/MediaDetailsOverlay.kt:147` is silently dropped.
|
|
||||||
|
|
||||||
### 17. Home row headers do not align
|
|
||||||
|
|
||||||
`MediaRow` and `MyShowsStrip` lead with a 28dp icon chip plus 10dp; `RecentSearchesRow`
|
|
||||||
(`ui/MainActivity.kt:2084`) has no chip, so its title starts 38dp further left. It also uses
|
|
||||||
9dp header spacing against everyone else's 6dp and gives its `LazyRow` no vertical padding,
|
|
||||||
so focus-scaled chips have no room to grow.
|
|
||||||
|
|
||||||
### 18. The mini hero labels are positional fiction
|
|
||||||
|
|
||||||
`ui/HomeMovieHero.kt:124`: `listOf("POPULAR", "NEW RELEASE", "TRENDING")[index]`. But
|
|
||||||
`selectHomeHeroMovies` interleaves new releases and popular picks and then falls back to
|
|
||||||
every movie in the response. In the current screenshot a 2025 title is labelled NEW RELEASE
|
|
||||||
and a 2026 one POPULAR.
|
|
||||||
|
|
||||||
### 19. A dead second home implementation
|
|
||||||
|
|
||||||
`ui/MainActivity.kt:2752-2900`: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData` and
|
|
||||||
`HomeRowSkeleton` are unreferenced (only the gateway *model* named `HomeRow` is in use).
|
|
||||||
They carry a competing 48dp gutter and SemiBold header style — a live-looking template for
|
|
||||||
the wrong conventions.
|
|
||||||
|
|
||||||
### 20. Tab content sits entirely below the fold with no affordance
|
|
||||||
|
|
||||||
By design per the code comments, but nothing on screen tells the viewer that Down reveals
|
|
||||||
anything.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
`CLAUDE.md`'s detail-page section no longer describes this code. It documents a poster-left
|
|
||||||
layout with Play hanging off the poster's bottom-right corner, "nothing scrolls vertically",
|
|
||||||
a tab list of "Overview, Episodes, Cast, Details", and a `DetailReasonStrip` of several
|
|
||||||
short phrases. The code is a full-bleed scrolling hero with Overview / Episodes / More Like
|
|
||||||
This / Cast & Details and a single reason line (`ui/DetailPageComponents.kt:375-385`).
|
|
||||||
Worth correcting before it misleads the next change.
|
|
||||||
-11
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -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. */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
|
Text(
|
||||||
|
if (inbox) "You’re all caught up." else "Nothing marked as seen.",
|
||||||
|
color = MembyMutedText,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-2
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
0.1.55
|
0.1.57
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user