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.
|
||||
/.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
|
||||
- Chore: Upgrade player dependencies.
|
||||
- Improved: Upgrade player version.
|
||||
- Fixed: Backend server fixes.
|
||||
|
||||
## 0.2.74 — 2026-08-17
|
||||
- Fixed: Genre shelves were showing films only. A programme whose details came from TMDb carries its genre as one label — "Sci-Fi & Fantasy", "Action & Adventure", "War & Politics" — which the shelves did not recognise, so every show was missing from Sci-Fi & Fantasy, Action & Adventure and War & History. Shows now appear on them beside the films.
|
||||
|
||||
@@ -550,14 +550,61 @@ behind it. Things to preserve:
|
||||
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
|
||||
Alerts row inside the picker. The page is one level in now, so without the mark out on the
|
||||
rail nothing on the launcher would ever say there was news waiting.
|
||||
- **Inbox and Seen, and there is no off switch.** The page is laid out as My Requests is —
|
||||
the marked heading, a tab strip, one pane at a time — because the two answer the same shape
|
||||
of question about a person's own list. `AlertsTab` (pure, tested) is the whole of it:
|
||||
membership is the *read* flag and nothing else, so both panes and both counts derive from
|
||||
the one list the caller already holds and there is no third state to keep in step. The
|
||||
count is on the tab because it is the question a viewer opens the page with, and zero is
|
||||
printed rather than hidden — a count that disappeared when it emptied would read as one
|
||||
that had failed. Turning notifications off used to be a chip on this page, which was the
|
||||
page offering a way to make itself permanently useless; the reason to reach for it was a
|
||||
single list mixing new news with everything already read, which is what the split fixes.
|
||||
The stored preference is still honoured and still says so in the empty state — it is
|
||||
simply no longer the viewer's to switch from here.
|
||||
- **A press dismisses, and the focused row says so.** This is the only page whose whole job
|
||||
is emptying itself; a confirmation press per alert is what made the panel it replaced not
|
||||
worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag.
|
||||
"Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and
|
||||
empties the list optimistically, or a row lingers under a thumb that will press it again.
|
||||
worth opening. **Nothing moves under the remote by being looked at**: focus used to mark a
|
||||
row read, which was harmless while the list was one list and would now empty the Inbox
|
||||
merely by somebody scrolling it, so Seen is a state a viewer puts a row into. "Dismiss
|
||||
all" is the same per-alert call in a loop — the gateway has no bulk route — and takes
|
||||
**only the pane on screen**, or emptying Seen would throw away an Inbox nobody had read;
|
||||
it empties optimistically, or a row lingers under a thumb that will press it again.
|
||||
- **A row holds two focus targets, and that is what makes a seen toggle possible.** A remote
|
||||
has one confirm key and it was already spent on dismissing, so the body keeps that press
|
||||
and Right reaches a toggle beside it (`alertSeenActionLabel`, `POST
|
||||
/v1/notifications/{id}/unread` → `store.MarkNotificationUnread`). Down still moves to the
|
||||
next row from either, so the second target costs nothing to somebody who never wants it.
|
||||
Three things to preserve: the toggle **names the action, not the state** — a button
|
||||
labelled with the state it is in reads as a claim rather than as something to press; the
|
||||
lit surface is the *row's*, driven by `hasFocus` rather than `isFocused`, or a row goes
|
||||
dark the moment the remote steps sideways into its own control; and marking unread is a
|
||||
plain assignment where marking read is a `COALESCE`, because read is set by merely looking
|
||||
at a row and must not move on a second glance, while unread is only ever somebody saying so.
|
||||
- **The toggle is what moves a row between the two halves, so focus comes back to the
|
||||
toggle.** Marking a row seen takes it out of the pane it was in, exactly as a dismissal
|
||||
does, and the page re-aims focus the same way — but onto the *toggle* of the row that took
|
||||
its place rather than onto the body, because a run of "Mark as seen" presses that landed on
|
||||
a body would be a run that ended in a dismissal. That is why a row carries two focus
|
||||
requesters and `pendingFocusToggle` records which of them the change was aimed at.
|
||||
- **The list is paged on the television, not on the wire** (`ui/alerts/AlertsPaging.kt`,
|
||||
pure and tested). The gateway answers with the whole undismissed list and the page cuts it
|
||||
up locally, which is what lets a page flip cost nothing on a weak box, lets the locally
|
||||
held update notice merge into page one without making a server's page boundaries lie about
|
||||
it, and keeps a dismissal optimistic instead of needing the page it left refetched.
|
||||
`AlertsPageSize` is **four**, measured against a 540dp television that spends a third of
|
||||
its height on the heading and the controls — `my-alerts-paged-crowded.png` is the capture
|
||||
that figure is answerable to. Two rules: `alertPageAfterChange` **clamps rather than
|
||||
resets**, because the common change here is the last row of the last page going away and a
|
||||
viewer sent back to page one for it would lose their place every time they finished a
|
||||
page; and an arrow at either end is **not drawn** rather than drawn dead, the stance the TV
|
||||
calendar's month controls take, with both ends keeping a fixed-width slot so the page
|
||||
indicator is not the one thing on the row that moves.
|
||||
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
|
||||
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
|
||||
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
|
||||
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`. What
|
||||
the page does hold is only ever about the remote in the room — which tab is open, which
|
||||
page of it, and which row a dismissal or a seen toggle aimed focus at.
|
||||
- **A cancellation is a transition, not a status read in isolation.**
|
||||
`sonarr_series_status_history` stores the first daily Sonarr reading as a quiet baseline
|
||||
and appends only changes after it; `WatchSonarrLifecycle` creates a notification for every
|
||||
@@ -567,6 +614,50 @@ behind it. Things to preserve:
|
||||
enabling the scanner would announce every show that had already ended as new news, and
|
||||
without durable history a gateway restart could announce the same change again.
|
||||
|
||||
**Every outbound notification leaves through one door.** `server/internal/notify` is that
|
||||
door and `internal/api/notifications.go` is the Memby half of it, so the flow is
|
||||
`feature/event → notify.Service → Deliverer → notification log`. Before it, each feature
|
||||
both decided to notify somebody *and* performed the delivery: the Sonarr lifecycle scanner
|
||||
wrote into `user_notifications`, the ingester pushed a banner into Redis, the integrations
|
||||
dispatcher posted to Discord. Each knew how to deliver and none knew the others existed, so
|
||||
"what did Memby send, to whom, and did it work" could only be answered by reading three
|
||||
subsystems' log lines and hoping each had logged. The console reads the trail at
|
||||
`/admin/notifications` over `admin_notification_log.go` and `store/notifications.go`. Things
|
||||
to preserve:
|
||||
|
||||
- **Logging never blocks delivery, and that is an ordering rather than a promise.** `Send`
|
||||
delivers *first* and records afterwards, on a context detached from the caller's, and a
|
||||
write that fails is logged and swallowed. A history written first would be a claim rather
|
||||
than a record; one written inside the delivery path could fail the delivery.
|
||||
- **A skip is the most useful row on the page.** "Memby never told me" and "Memby decided
|
||||
not to tell you" are the same silence from outside, so a viewer's preferences declining a
|
||||
notification, a source key that had already been used, and an operator's alert window set
|
||||
to zero are all *recorded* with their reason (`declineUser`, `StatusSkipped`) rather than
|
||||
being an early return. That is why `announceImportedMovie` no longer short-circuits on a
|
||||
zero window — `deliverBroadcast` records it instead.
|
||||
- **Sent and Delivered are different answers.** Most channels here are stores, finished the
|
||||
moment they return with nobody to acknowledge them; `Delivered` is reserved for a provider
|
||||
that actually confirmed, which today means a webhook answering 2xx. Collapsing them would
|
||||
have the console claim an acknowledgement nothing gave.
|
||||
- **Nothing secret is ever recorded.** A webhook URL is the credential, so `Notification`
|
||||
carries `Target` — a destination's *name* — and never its address, and `Redact` drops any
|
||||
metadata key that looks like one on the way in. It is a match on the *key*, because a
|
||||
token is not recognisable by looking at it and what a caller reliably gets right is what
|
||||
they called the field.
|
||||
- **The integrations dispatcher reports rather than being driven.** It is the one producer
|
||||
that calls `notify.Log` instead of `Send`: it is a bus subscriber with its own queue,
|
||||
pacing and transport registry, and routing its posts through the audit trail would make
|
||||
the trail the thing deciding what Discord receives.
|
||||
- **`UpsertNotification` reports whether it inserted.** The digest job fires hourly and
|
||||
re-sends the same weekly key all evening on purpose, so without that boolean every
|
||||
catch-up pass would read as a second summary nobody received.
|
||||
- **The facets are built from what has been sent**, not from a list of constants, and over
|
||||
the whole retention window rather than the current filter — a dropdown whose options
|
||||
vanish as you narrow the table is one you cannot use to widen the question again.
|
||||
- **`store.NotificationRetention` (90 days) is the one figure.** The housekeeping task
|
||||
prunes to it and the console derives its widest window from it, so a page can never offer
|
||||
a range the prune has already emptied.
|
||||
|
||||
**Watch time is Tracearr's, and it is never counted twice.** `store.watchedMsExpr` in
|
||||
`internal/store/watch_time.go` is the one definition of "how long was this actually watched"
|
||||
— the greater of Tracearr's `durationMs` and `progressMs`, capped at the title's own length —
|
||||
@@ -1616,6 +1707,40 @@ therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the p
|
||||
same work which is. Moving the core past 1.9 means finding a matching extension first, or
|
||||
deciding to do without DTS.
|
||||
|
||||
**Where a title resumes from is the television's own answer, not the card's.** The launcher
|
||||
hands `PlaybackRequest` the position it read off the card, and *nothing downstream corrects
|
||||
it*: the direct path resolves no position at all, and the gateway takes `resumePositionMs`
|
||||
as a hint (`playbackHint`) and echoes it back rather than paying a second Emby lookup. So a
|
||||
card is the whole answer, and a card is only as fresh as the last home refresh — which is
|
||||
how a short session came to be lost. Somebody watched twenty seconds, left, and pressed Play
|
||||
again inside the second or two the stop report and the refresh take, and was sent back to
|
||||
where they had started. `EmbyRepository.localResume` is what closes that window: the playhead
|
||||
is recorded **before** the report is attempted, from `enqueuePlaybackStopped` synchronously
|
||||
and from every progress report, and `launchResumePositionMs` consults it at
|
||||
`playbackRequest()` — the one funnel every launch passes through, and therefore also what
|
||||
opens the player and what the gateway is given as its hint. Things to preserve:
|
||||
|
||||
- **It is a *greatest*, never a preference**, which is what retires the record with no
|
||||
bookkeeping at all: once a refresh brings the card back carrying that position — or a later
|
||||
one, watched on another set — the card is at least as current and the local record can no
|
||||
longer change the answer. `LOCAL_RESUME_MAX_AGE_MS` is only a backstop for a set that
|
||||
recorded a position and was then left alone.
|
||||
- **A completed title is forgotten rather than remembered.** The server resets a finished
|
||||
title's position, so a record kept past that would drop somebody into the closing minutes of
|
||||
something they had deliberately started again. That is the only reason `durationMs` is
|
||||
carried through `PlaybackStopWorker.enqueue` — zero means the runtime was not known, never
|
||||
that the title is zero long. Marking watched or unwatched, and hiding a title from the
|
||||
shelf, forget it for the same reason.
|
||||
- **The launcher is told before the server is.** `repository.playbackPositions` is emitted as
|
||||
the player exits and `HomeViewModel.applyPlaybackPosition` moves the card's progress bar
|
||||
immediately, ahead of the `playbackStops` refresh that replaces it with the server's own
|
||||
answer — and it never moves a card backwards, because a stop and the ten-second report
|
||||
before it can arrive in either order. It is also what puts the new position into the
|
||||
persisted `HomeCache`, so a cold start does not draw the old one.
|
||||
- **`clearPlayableCache` deliberately does not touch it.** A playback stop clears that cache,
|
||||
and outliving it to answer the launch that follows is the entire point. Session changes call
|
||||
`clearLocalResume` beside it — another viewer's playheads are not this one's.
|
||||
|
||||
**Playback position has one ordered exit path.** Ten-second progress updates, pause/seek
|
||||
updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a
|
||||
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
|
||||
@@ -1641,6 +1766,51 @@ record the pre-roll, the first-frame ident and the backdrop loading state under
|
||||
`build/screenshots/sonarr-preroll/`, `build/screenshots/playback-identity/` and
|
||||
`build/screenshots/playback-loading/`.
|
||||
|
||||
**The station ident is one announcement, and the corner it uses has one owner.** It read as
|
||||
duplicated or overlapping — for films and episodes alike, intermittently — and nothing was
|
||||
ever drawn twice: the ident (`player_playback_identity`, activity-owned, above the
|
||||
`PlayerView`, top-start at 48/34dp) and the transport controller's own
|
||||
`player_now_playing_group` (inside `memby_player_controls.xml`, top-start at 48/30dp) are the
|
||||
same logo and the same title, four density pixels apart, bound by two methods
|
||||
(`setUpPlaybackIdentity` and `bindTitleArtwork`) that had never heard of each other. Whenever
|
||||
the transport happened to be up inside the ident's five seconds — a remote press, a pause,
|
||||
closing the cast or subtitle overlay, media3's own `auto_show` — both drew, which is exactly
|
||||
why it was intermittent. `ui/player/PlaybackIdentity.kt` holds the rule instead. Things to
|
||||
preserve:
|
||||
|
||||
- **`playerIdentitySlot` is the whole of the priority**, pure and tested: paused outranks the
|
||||
transport, the transport outranks the ident. Pause is `NONE` because the pause overlay
|
||||
already carries the poster, the title and the synopsis — a logo above it is the same
|
||||
programme said twice in two type sizes. `applyIdentityRegion` is the only thing that sets
|
||||
`nowPlayingGroup`'s visibility; `updatePauseOverlay` setting it directly is what the defect
|
||||
looked like from the transport's side.
|
||||
- **The transport's arrival *ends* the ident** rather than moving or fading it. Of the three
|
||||
possible rules that is the only one that never has two answers to "what is playing" on
|
||||
screen at once, and it needs no measured choreography between two layouts that know nothing
|
||||
about each other. `PlayerView.ControllerVisibilityListener` is the only honest source of
|
||||
that state — media3 raises the transport for reasons the activity never hears about.
|
||||
- **The ident is a phase, never a boolean** (`PlaybackIdentityPhase`, `shouldRaiseIdent`).
|
||||
"Has not opened yet" and "has already had its turn" are different answers to an arriving
|
||||
playback-started event, and every launch reports one at least once — first frame, pre-roll
|
||||
hand-off, a recovery re-prepare. A withheld ident is *spent*, not deferred, or it would
|
||||
appear seconds into the programme when the controls timed out.
|
||||
- **`setUpPlaybackIdentity` binds and never shows.** `adoptPlayable` re-binds on every gateway
|
||||
launch once the server settles which episode it is, so a bind that could also raise the
|
||||
ident is a second one.
|
||||
- **`resetPlaybackIdentity` is called wherever the *subject* changes inside a player the
|
||||
viewer never left** — an episode advance, a trailer resolving, a next-episode preview and
|
||||
the return from a failed one. Those are the paths that would otherwise carry the outgoing
|
||||
title's spent ident into the incoming title, or leave the outgoing one's fade running over
|
||||
it.
|
||||
- **The logo and its text fallback both start hidden.** The fallback was `visible` in the
|
||||
layout and only hidden when Coil reported success, so a title with a logo showed its name
|
||||
and then its logo — a swap in the same corner that reads as the ident drawing twice.
|
||||
- **The episode line is subordinate and fixed in place.** The logo sits in a fixed 82dp box
|
||||
aligned to its bottom, so `S01E01 — Bob Smith` lands at the same height whatever the
|
||||
artwork's proportions are and whether there is artwork at all; the line itself is a compact
|
||||
white-on-near-black plate, one line, ellipsised, so a long episode title cannot reach the
|
||||
middle of the screen. A film has no line at all — nothing is reserved for one.
|
||||
|
||||
**The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one
|
||||
process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare
|
||||
on the main queue's idle handler, so decoder construction and the local resource read never
|
||||
@@ -2909,6 +3079,69 @@ availability badge above it answers a different question (has the household's co
|
||||
downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead
|
||||
of everything else on a followed show: nothing else on that card matters as much.
|
||||
|
||||
**A film the household does not own has a page of its own.** The "Upcoming Movie releases"
|
||||
row is Radarr's, and its cards were the one thing on the launcher that did nothing at all
|
||||
when pressed: `MembyPlayable` is false, `scheduleSeriesStub` answers only for Sonarr, and
|
||||
there is no Emby item behind them to open. `GET /v1/radarr/movies/{id}`
|
||||
(`server/internal/api/radarr_detail.go` → `ui/RadarrMovieDetailsOverlay.kt`) is what they
|
||||
open instead — the artwork, the description, the genres, the certificate, the studio, the
|
||||
stored review scores, when it is expected, and a trailer. Things to preserve:
|
||||
|
||||
- **Nothing manufactures an Emby item to reuse the movie page.** `radarrMovieDetail` is its
|
||||
own type on both sides, because the ordinary page's whole shape — Play, resume, a progress
|
||||
bar, watched state, tabs of cast and extras — is built around a file that exists, and a
|
||||
page carrying those over a film nobody can watch would be four lies arranged as furniture.
|
||||
The card itself is passed in for artwork and for the title, which is what makes the page
|
||||
appear on the press rather than after the request; it is the row's own card, not a stand-in.
|
||||
- **Which page opens is the gateway's answer, not the television's.** `MembyMovieItemId` is
|
||||
the `MembySeriesItemId` arrangement — resolved in `embyMovieIndex` by **TMDb id**, since
|
||||
Radarr writes one and the library import already asks Emby for `ProviderIds`, so unlike the
|
||||
Sonarr row there is nothing here to match by title. A card carrying one opens the ordinary
|
||||
movie page through `scheduleMovieStub`; a card carrying none is `BaseItem.isRadarrOnly` and
|
||||
opens this one. Neither is inert, which is what the card used to be.
|
||||
- **The detail route resolves the link a second time, and that is not redundancy.** The home
|
||||
row is cached for the day, so a film imported at lunchtime still arrives on a card with no
|
||||
Emby id until midnight. `radarrEmbyStub` reads the live answer and hands the viewer to the
|
||||
ordinary page — which is the whole of "once it is in Emby, the card follows the normal
|
||||
path", with nothing on either side to invalidate.
|
||||
- **The trailer joins the existing chain rather than starting a second one.**
|
||||
`trailerManifest` recognises a `radarr:` id and builds its candidate list from Radarr's
|
||||
`youTubeTrailerId`, so `/v1/items/{id}/trailers`, `/resolve`, `/report`, the client's
|
||||
`hasTrailer` cache and the player's walk through candidates all work unchanged on a subject
|
||||
Emby has never heard of. `trailerAvailable` rides the detail response for the same reason
|
||||
`subtitleDownloadAvailable` rides the playback one: the button is decided before it is
|
||||
drawn, so it can never be one that fails after being pressed.
|
||||
- **Most of the release wording is about refusing to be precise.** `radarrExpectedLabel`
|
||||
prints a published digital date to the day and the schedule row's cinema-plus-a-month
|
||||
estimate only to the month — "Expected November 2026" is true where "Expected 14 November
|
||||
2026" is a date somebody would plan an evening around — and a film with neither is told
|
||||
plainly rather than guessed at. It is the gateway's wording, the stance every schedule
|
||||
label takes, so a phrasing added next month reads correctly on today's build; the three
|
||||
Radarr dates are listed beside it so a viewer can see which one the headline came from.
|
||||
- **`radarrMovieState` answers about the household's copy, where the lifecycle tag answers
|
||||
about the film.** Coming Soon, Not Yet Available, Awaiting Release, Not Tracked and Almost
|
||||
Ready are five different reasons a viewer cannot watch this tonight, and the line under the
|
||||
state must not repeat the date above it — an unannounced film said "Release date not yet
|
||||
announced" twice before it said anything about downloading.
|
||||
- **Ratings come from the household's own store**, keyed by the TMDb or IMDb id Radarr
|
||||
already holds, rather than from Radarr's own ratings block: two different numbers for one
|
||||
film under one provider's name is worse than no strip.
|
||||
- **Radarr is asked for nothing the catalogue can answer.** `radarrMovie` reads the cached
|
||||
household catalogue first — one request already shared by the whole house — and only falls
|
||||
back to `/api/v3/movie/{id}` for a title added since. `HomeViewModel.warmDetailPage` warms
|
||||
it on focus, which is the only warm a schedule card has any use for.
|
||||
- **The long-press menu is built from a list now, not from hand-written indices.** A Radarr
|
||||
card has no Emby record to favourite or mark watched and has a trailer where an ordinary
|
||||
card does not, so what belongs on that menu varies — `QuickAction` derives the focus
|
||||
indices from the entries rather than leaving four pieces of arithmetic to hold in step.
|
||||
- **There is no second implementation on the direct path**, the stance the TV calendar takes
|
||||
and for the same reason: the answer is Radarr's, which a television holds no credential for
|
||||
and Emby knows nothing about. With no gateway the row does not exist either.
|
||||
- Screenshots are `RadarrMovieDetailScreenshotTest` → `build/screenshots/radarr-movie/`.
|
||||
The claim the page makes is that an unwatchable film reads as *deliberately* unavailable
|
||||
rather than as a page whose Play button failed to load, which is not a thing a unit test
|
||||
can check.
|
||||
|
||||
**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is
|
||||
on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`)
|
||||
answers "what is on this month, and when does it come back", which is a question no shelf
|
||||
|
||||
-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"
|
||||
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="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-C5rUVO6U.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { EngagementPage } from './pages/Engagement';
|
||||
import { SearchesPage } from './pages/Searches';
|
||||
import { ViewsPage } from './pages/Views';
|
||||
import { MediaReportsPage } from './pages/MediaReports';
|
||||
import { NotificationsPage } from './pages/Notifications';
|
||||
import { CreditsPage } from './pages/Credits';
|
||||
|
||||
/* The console's routing table.
|
||||
@@ -97,6 +98,7 @@ export function App() {
|
||||
<Route path="engagement" element={<EngagementPage />} />
|
||||
<Route path="searches" element={<SearchesPage />} />
|
||||
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
|
||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||
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 };
|
||||
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',
|
||||
list: 'M4 7h16M4 12h16M4 17h10',
|
||||
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
|
||||
send: 'M21 3 10.5 13.5M21 3l-6.8 18-3.7-7.5L3 10z',
|
||||
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
|
||||
check: 'm5 12.5 4.5 4.5L19 7.5',
|
||||
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
|
||||
|
||||
@@ -117,8 +117,8 @@ export function OmniSearch() {
|
||||
ref={input}
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search pages, users and devices…"
|
||||
aria-label="Search pages, users and devices"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
aria-expanded={open}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -32,6 +32,15 @@ export interface Shaped {
|
||||
context: string;
|
||||
/** The one line that explains a failure, printed under the row rather than hidden. */
|
||||
detail: string;
|
||||
/* What the row actually prints under its summary, and what it prints at the end of the
|
||||
* summary line. They are separate fields rather than a rule the table re-derives,
|
||||
* because `tall` is the row's height and a height that disagrees with what is drawn is
|
||||
* text sliced through the middle — which is exactly what happened while the renderer
|
||||
* printed a context line the height rule had already decided against. */
|
||||
secondary: string;
|
||||
secondaryTone: 'error' | 'context' | '';
|
||||
/** Identity on a request row: the same fact, kept on the one line density depends on. */
|
||||
trail: string;
|
||||
result: { label: string; short: string; tone: LogTone } | null;
|
||||
durationMs: number | null;
|
||||
method: string;
|
||||
@@ -433,6 +442,15 @@ function derive(event: LogEvent): Shaped {
|
||||
const context = contextFor(attributes);
|
||||
const occurred = new Date(event.occurredAt);
|
||||
|
||||
// One decision, read twice. A failure explains itself under the summary; so does an
|
||||
// application event carrying a person or a position. Ordinary request traffic — which is
|
||||
// most of a log — keeps its identity at the end of its own line instead, because density
|
||||
// is the whole reason this page is worth watching and a second line on every request
|
||||
// would halve what an operator can see at once.
|
||||
const secondary = detail || (isRequest ? '' : context);
|
||||
const secondaryTone: Shaped['secondaryTone'] = detail ? 'error' : secondary ? 'context' : '';
|
||||
const trail = !detail && isRequest ? context : '';
|
||||
|
||||
const fields = Object.entries(attributes);
|
||||
const shaped: Shaped = {
|
||||
serviceKey,
|
||||
@@ -442,6 +460,9 @@ function derive(event: LogEvent): Shaped {
|
||||
summary,
|
||||
context,
|
||||
detail,
|
||||
secondary,
|
||||
secondaryTone,
|
||||
trail,
|
||||
result: resultFor(attributes, event.level, status),
|
||||
durationMs,
|
||||
method,
|
||||
@@ -451,10 +472,8 @@ function derive(event: LogEvent): Shaped {
|
||||
time: `${timeFormat.format(occurred)}.${String(occurred.getMilliseconds()).padStart(3, '0')}`,
|
||||
day: dayFormat.format(occurred),
|
||||
dayKey: occurred.toDateString(),
|
||||
// An error explains itself on a second line; so does an application event carrying a
|
||||
// person or a position. Ordinary request traffic — which is most of a log — stays on
|
||||
// one, because density is the whole reason this page is worth watching.
|
||||
tall: Boolean(detail) || (Boolean(context) && !isRequest),
|
||||
// The height follows what is printed, never a second guess at it.
|
||||
tall: Boolean(secondary),
|
||||
haystack: [
|
||||
message, service, component, summary, context, detail,
|
||||
...fields.flat().map(text),
|
||||
|
||||
+15
-4
@@ -64,7 +64,7 @@ export const nav: NavGroup[] = [
|
||||
id: 'accounts',
|
||||
path: '/admin/accounts',
|
||||
label: 'Users',
|
||||
title: 'Memby users',
|
||||
title: 'Users',
|
||||
intro: 'Who uses Memby, and the devices they are signed in on.',
|
||||
icon: 'people',
|
||||
},
|
||||
@@ -76,6 +76,17 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Who can ask for something the library does not have.',
|
||||
icon: 'inbox',
|
||||
},
|
||||
{
|
||||
/* The record of what Memby sent, which is a different question from the activity
|
||||
feed above it: that is the operator's own bell, this is every outbound
|
||||
notification to a viewer or an external service, whichever feature produced it. */
|
||||
id: 'notifications',
|
||||
path: '/admin/notifications',
|
||||
label: 'Notifications',
|
||||
title: 'Notifications',
|
||||
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
|
||||
icon: 'send',
|
||||
},
|
||||
{
|
||||
id: 'media-reports',
|
||||
path: '/admin/media-reports',
|
||||
@@ -95,8 +106,8 @@ export const nav: NavGroup[] = [
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/admin/logs',
|
||||
label: 'Server logs',
|
||||
title: 'Server logs',
|
||||
label: 'Logs',
|
||||
title: 'Logs',
|
||||
intro: 'Structured gateway events as they happen.',
|
||||
icon: 'list',
|
||||
},
|
||||
@@ -309,7 +320,7 @@ export const nav: NavGroup[] = [
|
||||
path: '/admin/searches',
|
||||
label: 'Searches',
|
||||
title: 'Searches',
|
||||
intro: 'What the household has been looking for, and what it searched just now.',
|
||||
intro: 'What viewers have been looking for, and what was searched just now.',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,6 +40,12 @@ interface PreferenceDefinition {
|
||||
numbers?: number[];
|
||||
unit?: string;
|
||||
maxLength?: number;
|
||||
/* Whether a text value is folded to capitals, and what an empty field means. Both come
|
||||
from the catalogue rather than from this page: initials read as capitals and a person's
|
||||
name does not, and a console that decided that for itself would drift from the server
|
||||
the first time a text setting was added. */
|
||||
uppercase?: boolean;
|
||||
placeholder?: string;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -101,6 +107,7 @@ interface AccountDetail {
|
||||
id: string;
|
||||
username: string;
|
||||
initials: string;
|
||||
shortName: string;
|
||||
lastSeen: string;
|
||||
devices: AccountDevice[] | null;
|
||||
themes: string[] | null;
|
||||
@@ -229,7 +236,9 @@ export function AccountPage() {
|
||||
<>
|
||||
<PageHead
|
||||
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>}
|
||||
actions={
|
||||
<>
|
||||
@@ -361,7 +370,7 @@ export function AccountPage() {
|
||||
{account.watchTime?.matched ? (
|
||||
<Card
|
||||
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"
|
||||
tone="data"
|
||||
actions={
|
||||
@@ -845,8 +854,14 @@ function SettingControl({
|
||||
type="text"
|
||||
value={String(value ?? '')}
|
||||
maxLength={definition.maxLength}
|
||||
placeholder="Generated from their name"
|
||||
onChange={(event) => onChange(event.target.value.toLocaleUpperCase('en-NZ'))}
|
||||
placeholder={definition.placeholder}
|
||||
onChange={(event) =>
|
||||
onChange(
|
||||
definition.uppercase
|
||||
? event.target.value.toLocaleUpperCase('en-NZ')
|
||||
: event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
initials: string;
|
||||
shortName: string;
|
||||
lastSeen: string;
|
||||
devices: KnownClient[] | null;
|
||||
recommendations?: { prompted?: boolean; completed?: boolean };
|
||||
@@ -100,7 +101,7 @@ export function AccountsPage() {
|
||||
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
|
||||
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
|
||||
{
|
||||
label: 'active in the last quarter hour',
|
||||
label: 'active in the last 15 mins',
|
||||
value: num(devices.filter((device) => recent(device.lastSeen)).length),
|
||||
icon: 'pulse',
|
||||
tone: 'ok',
|
||||
@@ -110,7 +111,7 @@ export function AccountsPage() {
|
||||
...(tracked.length
|
||||
? [
|
||||
{
|
||||
label: 'watched by the household this week',
|
||||
label: 'watch time this week',
|
||||
value: watchTime(weekMs),
|
||||
icon: 'pulse' as const,
|
||||
tone: 'data' as const,
|
||||
@@ -126,6 +127,7 @@ export function AccountsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Short name</th>
|
||||
<th className="num">Devices</th>
|
||||
<th className="num">This week</th>
|
||||
<th className="num">This month</th>
|
||||
@@ -135,7 +137,7 @@ export function AccountsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
@@ -163,6 +165,16 @@ export function AccountsPage() {
|
||||
</Link>
|
||||
</span>
|
||||
</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">
|
||||
{num(list.length)}
|
||||
{/* Only where there is something to say. A sub-line under every
|
||||
|
||||
@@ -122,7 +122,7 @@ export function CreditsPage() {
|
||||
|
||||
<Card
|
||||
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"
|
||||
tone="info"
|
||||
>
|
||||
|
||||
@@ -239,7 +239,7 @@ function LogView({
|
||||
<Grid cols="wide">
|
||||
<Card
|
||||
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"
|
||||
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
|
||||
* application event earns a second. That means the virtual window is driven by a prefix
|
||||
* sum of row heights rather than by multiplication, computed once per filter change.
|
||||
* Which height a row gets and what it prints are one decision, made once, in
|
||||
* `lib/logmodel`'s `secondary` — the table renders that field and nothing else under
|
||||
* the summary. They were two, and they disagreed: every authenticated request line
|
||||
* carries the viewer and the television, so a context line was drawn on rows the height
|
||||
* rule had already ruled out, centred inside a box too short for it and sliced top and
|
||||
* bottom. A request's identity now sits at the end of its own line instead, which keeps
|
||||
* both the density and the fact.
|
||||
* - **Pause holds the view, not the connection.** Draining continues while paused and the
|
||||
* arrivals are held in a buffer, so the cursor keeps up with the server's ring buffer
|
||||
* and resuming is a flush rather than a stampede — the previous behaviour let the ring
|
||||
@@ -56,8 +63,20 @@ import type { LogEvent, LogResponse } from '../api/types';
|
||||
|
||||
const RETAIN = 20_000;
|
||||
const POLL_MS = 5_000;
|
||||
const ROW_COMPACT = 30;
|
||||
const ROW_TALL = 48;
|
||||
/* Row geometry, and it is arithmetic rather than a pair of round numbers: a row is
|
||||
* absolutely positioned at a height this file decides, so anything the stylesheet draws
|
||||
* that these figures do not account for is text clipped by a rule nobody can see from the
|
||||
* CSS. Every first-line cell in `.logrow` is given exactly ROW_LINE, the secondary line
|
||||
* exactly ROW_SECOND, and the padding and hairline below are the same on both heights —
|
||||
* which is what makes the columns line up whether an event printed one line or two.
|
||||
* Changing any figure here means changing its twin in `styles.css`. */
|
||||
const ROW_PAD = 7;
|
||||
const ROW_LINE = 16;
|
||||
const ROW_SECOND = 15;
|
||||
const ROW_SECOND_GAP = 2;
|
||||
const ROW_RULE = 1;
|
||||
const ROW_COMPACT = ROW_PAD + ROW_LINE + ROW_PAD + ROW_RULE;
|
||||
const ROW_TALL = ROW_COMPACT + ROW_SECOND_GAP + ROW_SECOND;
|
||||
const DAY_HEIGHT = 26;
|
||||
const HEADER_HEIGHT = 31;
|
||||
const OVERSCAN = 10;
|
||||
@@ -175,7 +194,7 @@ const LogRow = memo(function LogRow({
|
||||
<button
|
||||
type="button"
|
||||
className="logrow-summary"
|
||||
title={view.detail || view.summary}
|
||||
title={[view.summary, view.trail, view.secondary].filter(Boolean).join(' — ')}
|
||||
onClick={() => onInspect(event.sequence)}
|
||||
>
|
||||
<span className="logrow-line">
|
||||
@@ -185,11 +204,14 @@ const LogRow = memo(function LogRow({
|
||||
</b>
|
||||
) : null}
|
||||
<span className="logrow-text">{view.summary}</span>
|
||||
{view.trail ? <span className="logrow-trail">{view.trail}</span> : null}
|
||||
</span>
|
||||
{/* Printed if and only if the row was measured for it — see `secondary` in
|
||||
lib/logmodel. */}
|
||||
{view.secondary ? (
|
||||
<span className="logrow-second" data-tone={view.secondaryTone}>
|
||||
{view.secondaryTone === 'error' ? `↳ ${view.secondary}` : view.secondary}
|
||||
</span>
|
||||
{view.detail ? (
|
||||
<span className="logrow-error">↳ {view.detail}</span>
|
||||
) : view.context ? (
|
||||
<span className="logrow-context">{view.context}</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export function MaintenancePage() {
|
||||
{!loading ? (
|
||||
<Card
|
||||
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"
|
||||
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>}
|
||||
@@ -123,7 +123,7 @@ export function MaintenancePage() {
|
||||
onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }}
|
||||
/>
|
||||
<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); }} />
|
||||
</Field>
|
||||
<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
|
||||
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} />
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<KeyValue
|
||||
rows={[
|
||||
{ label: 'Household timezone', value: effective.timezone || 'not set' },
|
||||
{ label: 'Server timezone', value: effective.timezone || 'not set' },
|
||||
{ label: 'Log level', value: effective.logLevel },
|
||||
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
|
||||
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
|
||||
@@ -176,7 +176,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<div className="fields">
|
||||
<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.`}
|
||||
>
|
||||
<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: '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>
|
||||
{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>
|
||||
</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>
|
||||
{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>
|
||||
|
||||
+94
-25
@@ -1881,12 +1881,19 @@ select {
|
||||
font: 12px/1.5 var(--sans);
|
||||
contain: layout paint style;
|
||||
}
|
||||
/* Every track but the event is a fixed width, and that is the whole of why the columns
|
||||
line up. A row is its own grid container — there are twenty thousand of them and no
|
||||
shared table — so a `minmax()` track sized from its own content gave each row a slightly
|
||||
different Service and Result column, and the Result column moved as the widest verdict
|
||||
on screen changed. Fixed tracks cannot move; content inside them truncates instead.
|
||||
|
||||
Vertical rhythm is set here too: cells start at the top of the row rather than being
|
||||
centred in it, so the Time, Level, Service and Result of a two-line event sit on the
|
||||
same line as its summary instead of dropping half a line to the middle of the pair. */
|
||||
.loghead,
|
||||
.logrow {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
92px 52px minmax(150px, 190px) minmax(240px, 1fr)
|
||||
minmax(96px, 150px) 68px;
|
||||
grid-template-columns: 92px 52px 190px minmax(240px, 1fr) 150px 68px;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -1942,15 +1949,26 @@ select {
|
||||
background: var(--line-soft);
|
||||
}
|
||||
|
||||
/* The figures below are the twin of the row geometry in `pages/Logs.tsx`: 7px of padding,
|
||||
a 16px first line, a 2px gap and a 15px second line, over a 1px hairline. The row's
|
||||
height is set inline from those constants, so a cell drawn taller than its share here is
|
||||
text sliced through the middle rather than a row that grows. Every first-line cell is
|
||||
therefore given `line-height: 16px` explicitly, whatever its font size. */
|
||||
.logrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .03);
|
||||
contain: strict;
|
||||
}
|
||||
.logrow > *,
|
||||
.logrow-line {
|
||||
line-height: 16px;
|
||||
}
|
||||
.logrow:hover {
|
||||
background: rgba(255, 255, 255, .035);
|
||||
}
|
||||
@@ -1971,7 +1989,7 @@ select {
|
||||
|
||||
.logrow-time {
|
||||
color: var(--quiet);
|
||||
font: 11px/1 var(--mono);
|
||||
font: 11px/16px var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -2003,7 +2021,7 @@ select {
|
||||
|
||||
.logrow-level {
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1 var(--sans);
|
||||
font: 700 10px/16px var(--sans);
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
.logrow-level[data-level="ERROR"] { color: var(--danger-ink); }
|
||||
@@ -2019,16 +2037,18 @@ select {
|
||||
on every other page and must not start meaning "playback" on this one. */
|
||||
.logrow-place {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logrow-service {
|
||||
flex: 0 0 auto;
|
||||
max-width: 96px;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1.4 var(--sans);
|
||||
font: 700 10px/16px var(--sans);
|
||||
letter-spacing: .07em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
@@ -2047,6 +2067,7 @@ select {
|
||||
flex: 1 1 auto;
|
||||
color: var(--quiet);
|
||||
font-size: 11.5px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
/* The summary is the row. It is a button because selecting the row is what opens the
|
||||
@@ -2055,9 +2076,9 @@ select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -2079,11 +2100,12 @@ select {
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
}
|
||||
.logrow-action {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font: 600 10.5px/1.4 var(--mono);
|
||||
font: 600 10.5px/16px var(--mono);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.logrow-action[data-method="POST"],
|
||||
@@ -2098,15 +2120,13 @@ select {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The reason a failure needs no drawer. */
|
||||
.logrow-error {
|
||||
overflow: hidden;
|
||||
color: var(--danger-ink);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logrow-context {
|
||||
/* Identity on a request row, kept on the summary's own line. It gives way before the path
|
||||
does — knowing which route was called matters more than which television called it, and
|
||||
the drawer holds both either way. */
|
||||
.logrow-trail {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 38%;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font-size: 11px;
|
||||
@@ -2114,21 +2134,42 @@ select {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The second line: the reason a failure needs no drawer, or the person and position that
|
||||
make an application event mean something. Exactly one line tall, 15px, which is the
|
||||
figure the row was measured with — it can truncate but it can never wrap, because a wrap
|
||||
is a row overflowing into the one below it. */
|
||||
.logrow-second {
|
||||
height: 15px;
|
||||
overflow: hidden;
|
||||
color: var(--quiet);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logrow-second[data-tone="error"] {
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* A result is a word, not a badge. A success is understated to the point of being ignorable
|
||||
— which is the correct amount of attention for the four hundredth 200 in a row — and only
|
||||
a failure is given a fill. */
|
||||
.logrow-result {
|
||||
min-width: 0;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logrow-verdict {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
color: var(--quiet);
|
||||
font: 500 11px/1.5 var(--mono);
|
||||
font: 500 11px/14px var(--mono);
|
||||
}
|
||||
.logrow-verdict[data-tone="ok"] { color: var(--muted); }
|
||||
.logrow-verdict[data-tone="info"] { color: var(--info-ink); }
|
||||
.logrow-verdict[data-tone="data"] { color: var(--data-ink); }
|
||||
/* A fill still has to fit the row's line box: 14px of text and 1px either side is the
|
||||
16px every other cell on the line occupies. */
|
||||
.logrow-verdict[data-tone="warn"],
|
||||
.logrow-verdict[data-tone="bad"] {
|
||||
padding: 1px 6px;
|
||||
@@ -2146,7 +2187,7 @@ select {
|
||||
|
||||
.logrow-duration {
|
||||
color: var(--quiet);
|
||||
font: 11px/1 var(--mono);
|
||||
font: 11px/16px var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -2283,7 +2324,7 @@ select {
|
||||
@media (max-width: 1180px) {
|
||||
.loghead,
|
||||
.logrow {
|
||||
grid-template-columns: 84px 46px minmax(130px, 170px) minmax(200px, 1fr) minmax(88px, 130px);
|
||||
grid-template-columns: 84px 46px 170px minmax(200px, 1fr) 130px;
|
||||
gap: 10px;
|
||||
}
|
||||
.loghead > :last-child,
|
||||
@@ -2292,13 +2333,13 @@ select {
|
||||
}
|
||||
.loghead,
|
||||
.logbody {
|
||||
min-width: 640px;
|
||||
min-width: 680px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.loghead,
|
||||
.logrow {
|
||||
grid-template-columns: 46px minmax(110px, 140px) minmax(180px, 1fr) minmax(72px, 110px);
|
||||
grid-template-columns: 46px 140px minmax(180px, 1fr) 110px;
|
||||
gap: 8px;
|
||||
}
|
||||
.loghead > :first-child,
|
||||
@@ -3513,3 +3554,31 @@ details summary {
|
||||
background: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* The notification history's detail row.
|
||||
*
|
||||
* The drawer is a row inside the table rather than a panel beside it, so the record stays
|
||||
* under the row it belongs to while an operator reads down a filtered list. It borrows the
|
||||
* log viewer's drawer vocabulary wholesale — the two answer the same shape of question and
|
||||
* a second look for it would be a second thing to keep in step. */
|
||||
.detail-row > td {
|
||||
padding: 0 0 12px;
|
||||
background: var(--surface-sunken, transparent);
|
||||
}
|
||||
.logdrawer-raw h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--quiet);
|
||||
font: 700 10px/1 var(--sans);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* The message is the one thing on the page rendered as prose rather than as a field: it is
|
||||
the sentence a viewer actually read, and setting it in the mono field type would make it
|
||||
look like a value rather than like the notification it is. */
|
||||
.notification-body {
|
||||
max-width: 70ch;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font: 13px/1.6 var(--sans);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.76"
|
||||
val defaultVersionName = "0.2.77"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.RadarrMovieDetail
|
||||
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||
import com.ponzischeme89.memby.data.model.h264TranscodeFallback
|
||||
@@ -286,6 +287,32 @@ class EmbyRepository internal constructor(
|
||||
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
|
||||
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val playbackStops = _playbackStops.asSharedFlow()
|
||||
|
||||
/**
|
||||
* The position the player left a title at, published the instant it is known rather
|
||||
* than once Emby has accepted the report. It is what lets the launcher show the
|
||||
* progress a viewer just made on the card they are standing on, without waiting for a
|
||||
* round trip that [playbackStops] triggers afterwards.
|
||||
*/
|
||||
private val _playbackPositions = MutableSharedFlow<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. */
|
||||
private val playbackReportMutex = Mutex()
|
||||
private val playableMutex = Mutex()
|
||||
@@ -334,6 +361,16 @@ class EmbyRepository internal constructor(
|
||||
private val extrasCache =
|
||||
LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true)
|
||||
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)
|
||||
|
||||
@@ -433,6 +470,7 @@ class EmbyRepository internal constructor(
|
||||
): String {
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
@@ -524,6 +562,7 @@ class EmbyRepository internal constructor(
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
@@ -538,6 +577,7 @@ class EmbyRepository internal constructor(
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
@@ -547,6 +587,7 @@ class EmbyRepository internal constructor(
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
}
|
||||
|
||||
suspend fun removeProfile(profile: EmbyProfile) {
|
||||
@@ -563,6 +604,7 @@ class EmbyRepository internal constructor(
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
clearLocalResume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,6 +1164,17 @@ class EmbyRepository internal constructor(
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a notification back to new.
|
||||
*
|
||||
* Its own route rather than a flag on [markNotificationRead], because the two are not the
|
||||
* same kind of event: read is set by the page focusing a row, unread is only ever somebody
|
||||
* pressing the toggle on it.
|
||||
*/
|
||||
suspend fun markNotificationUnread(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "unread")
|
||||
}
|
||||
|
||||
suspend fun dismissNotification(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
||||
}
|
||||
@@ -1528,6 +1581,9 @@ class EmbyRepository internal constructor(
|
||||
|
||||
/** Sets watched state explicitly and returns the value confirmed by Emby. */
|
||||
suspend fun setPlayed(itemId: String, played: Boolean): Boolean {
|
||||
// Watched or unwatched, this decides the title's position from outside playback, so
|
||||
// whatever the player last recorded no longer describes anything.
|
||||
forgetLocalResume(itemId)
|
||||
if (ServerConfig.isGateway) {
|
||||
return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played
|
||||
}
|
||||
@@ -1543,6 +1599,8 @@ class EmbyRepository internal constructor(
|
||||
|
||||
/** Removes a title from Continue Watching without changing its watched state. */
|
||||
suspend fun removeFromContinueWatching(itemId: String) {
|
||||
// Taking a title off the shelf is a statement that its playhead no longer matters.
|
||||
forgetLocalResume(itemId)
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().hideFromResume(itemId)
|
||||
return
|
||||
@@ -1735,6 +1793,56 @@ class EmbyRepository internal constructor(
|
||||
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
|
||||
}
|
||||
|
||||
/**
|
||||
* The Radarr-only page for a film the household is tracking and has no copy of.
|
||||
*
|
||||
* Single-flighted on the repository's own scope like [getExtras], for the same reason:
|
||||
* the caller is a page that can be left before its answer lands, and a request tied to
|
||||
* the caller would be abandoned by exactly the navigation about to want it back.
|
||||
*
|
||||
* Never throws. There is no gateway on the direct path, an older container answers 404
|
||||
* and a film Radarr has since forgotten answers nothing — all three are "no page", and
|
||||
* the card stays where it was rather than opening onto an error.
|
||||
*
|
||||
* The **successful** answer is cached and a failure is not, so one bad minute does not
|
||||
* leave a card inert for the session. It is deliberately not refreshed while it is
|
||||
* held: the facts on it — a release date, a certificate, a studio — move on the scale of
|
||||
* weeks, and the one that does not is [RadarrMovieDetail.embyItemId], which the home
|
||||
* row's own refresh reports first anyway.
|
||||
*/
|
||||
suspend fun getRadarrMovie(itemId: String): RadarrMovieDetail? {
|
||||
if (itemId.isBlank() || !ServerConfig.isGateway) return null
|
||||
val inFlight = radarrMovieMutex.withLock {
|
||||
radarrMovieCache[itemId]?.let { return it }
|
||||
radarrMovieInFlight[itemId] ?: newRadarrMovieRequest(itemId)
|
||||
}
|
||||
return inFlight.await()
|
||||
}
|
||||
|
||||
private fun newRadarrMovieRequest(itemId: String): Deferred<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.
|
||||
*
|
||||
@@ -1910,7 +2018,14 @@ class EmbyRepository internal constructor(
|
||||
else -> null
|
||||
},
|
||||
isSeries = item.isSeries,
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
// The card is only as fresh as the last home refresh, so the ledger corrects it
|
||||
// here — at the one funnel every launch passes through, which is also what the
|
||||
// player is opened with and what the gateway is given as its resume hint.
|
||||
resumePositionMs = launchResumePositionMs(
|
||||
resolvedPositionMs = 0L,
|
||||
requestedPositionMs = item.resumePositionMs,
|
||||
localPositionMs = localResumePositionMs(item.id),
|
||||
),
|
||||
logoUrl = logoUrl(item),
|
||||
overview = item.overview,
|
||||
episodeCode = episodeCode(item),
|
||||
@@ -1937,6 +2052,7 @@ class EmbyRepository internal constructor(
|
||||
resumePositionMs = launchResumePositionMs(
|
||||
resolvedPositionMs = entry.playable.resumePositionMs,
|
||||
requestedPositionMs = request.resumePositionMs,
|
||||
localPositionMs = localResumePositionMs(request.itemId),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
@@ -1970,6 +2086,7 @@ class EmbyRepository internal constructor(
|
||||
resumePositionMs = launchResumePositionMs(
|
||||
resolvedPositionMs = cached.resumePositionMs,
|
||||
requestedPositionMs = request.resumePositionMs,
|
||||
localPositionMs = localResumePositionMs(request.itemId),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1984,6 +2101,7 @@ class EmbyRepository internal constructor(
|
||||
resumePositionMs = launchResumePositionMs(
|
||||
resolvedPositionMs = resolved.resumePositionMs,
|
||||
requestedPositionMs = request.resumePositionMs,
|
||||
localPositionMs = localResumePositionMs(request.itemId),
|
||||
),
|
||||
).also {
|
||||
playableMutex.withLock { playableCache.remove(request.itemId) }
|
||||
@@ -2273,6 +2391,11 @@ class EmbyRepository internal constructor(
|
||||
/** Drop negotiated playback state when a catalogue refresh can change episode selection. */
|
||||
suspend fun invalidatePlaybackPrefetch() = clearPlayableCache()
|
||||
|
||||
/**
|
||||
* Deliberately does not touch [localResume]: a playback stop clears this cache, and the
|
||||
* whole point of the ledger is to outlive that and answer the launch that follows.
|
||||
* Session changes call [clearLocalResume] beside this one.
|
||||
*/
|
||||
private suspend fun clearPlayableCache() {
|
||||
playableMutex.withLock {
|
||||
playableCache.clear()
|
||||
@@ -2313,6 +2436,62 @@ class EmbyRepository internal constructor(
|
||||
extrasInFlight.values.forEach { it.cancel() }
|
||||
extrasInFlight.clear()
|
||||
}
|
||||
radarrMovieMutex.withLock {
|
||||
radarrMovieCache.clear()
|
||||
radarrMovieInFlight.values.forEach { it.cancel() }
|
||||
radarrMovieInFlight.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers where the player left [itemId], or forgets it where the title was finished
|
||||
* — a completed title has its position reset by the server, so a record kept past that
|
||||
* would send somebody back into the closing minutes of something they had deliberately
|
||||
* started again.
|
||||
*
|
||||
* Called before the report goes out rather than after it lands, because the failure this
|
||||
* exists for is a viewer pressing Play again inside the second or two the round trip
|
||||
* takes, and a record written on success would not be there yet.
|
||||
*/
|
||||
private fun recordLocalResume(itemId: String, positionMs: Long, durationMs: Long) {
|
||||
if (itemId.isBlank()) return
|
||||
synchronized(localResume) {
|
||||
if (positionMs <= 0L || playbackCompletesItem(positionMs, durationMs)) {
|
||||
localResume.remove(itemId)
|
||||
} else {
|
||||
localResume[itemId] = LocalResume(positionMs, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The remembered playhead for [itemId], or zero where there is nothing current to say. */
|
||||
private fun localResumePositionMs(itemId: String): Long {
|
||||
if (itemId.isBlank()) return 0L
|
||||
val now = System.currentTimeMillis()
|
||||
return synchronized(localResume) {
|
||||
val entry = localResume[itemId] ?: return@synchronized 0L
|
||||
if (isFreshLocalResume(entry.recordedAtMs, now)) {
|
||||
entry.positionMs
|
||||
} else {
|
||||
localResume.remove(itemId)
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the remembered playhead for [itemId]. Marking a title watched or unwatched sets
|
||||
* its position from outside playback entirely, so a record made before that decision has
|
||||
* nothing left to describe.
|
||||
*/
|
||||
private fun forgetLocalResume(itemId: String) {
|
||||
if (itemId.isBlank()) return
|
||||
synchronized(localResume) { localResume.remove(itemId) }
|
||||
}
|
||||
|
||||
/** Another viewer's playheads are not this one's; cleared wherever the session changes. */
|
||||
private fun clearLocalResume() {
|
||||
synchronized(localResume) { localResume.clear() }
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
|
||||
@@ -2332,6 +2511,10 @@ class EmbyRepository internal constructor(
|
||||
eventName: String,
|
||||
durationMs: Long = 0L,
|
||||
): String? = playbackReportMutex.withLock {
|
||||
// The heartbeat is not what the resume point depends on any more, but it is free
|
||||
// evidence: a process killed between two reports still leaves the ledger describing
|
||||
// a position within ten seconds of the truth.
|
||||
recordLocalResume(session.itemId, positionMs, durationMs)
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report(
|
||||
"progress",
|
||||
@@ -2345,13 +2528,24 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||
suspend fun reportPlaybackStopped(
|
||||
session: PlaybackSession,
|
||||
positionMs: Long,
|
||||
durationMs: Long = 0L,
|
||||
) {
|
||||
// Recorded and published before the report is even attempted. Everything after this
|
||||
// line can fail, be retried by WorkManager or simply be slower than the viewer, and
|
||||
// the position they left at is still the one the next launch starts from.
|
||||
publishFinalPosition(session.itemId, positionMs, durationMs)
|
||||
try {
|
||||
playbackReportMutex.withLock {
|
||||
if (ServerConfig.isGateway) {
|
||||
// Stopping is also what drops the gateway's cached rows for this user,
|
||||
// so Continue Watching reflects the new position on the next home load.
|
||||
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
|
||||
requireGateway().report(
|
||||
"stopped",
|
||||
session.gatewayReport(positionMs, true, null, durationMs),
|
||||
)
|
||||
} else {
|
||||
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
|
||||
}
|
||||
@@ -2364,13 +2558,31 @@ class EmbyRepository internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The final playhead, taken as authoritative the moment the player reports it: written
|
||||
* to the local ledger and announced to the launcher. It is idempotent, which matters
|
||||
* because the durable WorkManager fallback replays the same stop.
|
||||
*/
|
||||
private fun publishFinalPosition(itemId: String, positionMs: Long, durationMs: Long) {
|
||||
if (itemId.isBlank()) return
|
||||
recordLocalResume(itemId, positionMs, durationMs)
|
||||
_playbackPositions.tryEmit(
|
||||
PlaybackPosition(itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
|
||||
)
|
||||
}
|
||||
|
||||
fun enqueuePlaybackStopped(
|
||||
session: PlaybackSession,
|
||||
positionMs: Long,
|
||||
durationMs: Long = 0L,
|
||||
onSuccess: () -> Unit = {},
|
||||
) {
|
||||
// Synchronously, before the coroutine is even scheduled: leaving the player and
|
||||
// pressing Play again is a couple of hundred milliseconds, and the ledger has to be
|
||||
// right by then rather than whenever the dispatcher gets round to it.
|
||||
publishFinalPosition(session.itemId, positionMs, durationMs)
|
||||
scope.launch {
|
||||
runCatching { reportPlaybackStopped(session, positionMs) }
|
||||
runCatching { reportPlaybackStopped(session, positionMs, durationMs) }
|
||||
.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
@@ -3066,6 +3278,22 @@ data class PlaybackSession(
|
||||
val playMethod: String = "DirectPlay",
|
||||
)
|
||||
|
||||
/** Where the player left a title, and when this set recorded that. */
|
||||
internal data class LocalResume(val positionMs: Long, val recordedAtMs: Long)
|
||||
|
||||
/**
|
||||
* A playhead this television is sure of, published as it leaves the player so the launcher
|
||||
* can move the card's progress bar without waiting on the server.
|
||||
*/
|
||||
data class PlaybackPosition(
|
||||
val itemId: String,
|
||||
val positionMs: Long,
|
||||
/** Zero where the runtime was not known; only a positive value can say a title finished. */
|
||||
val durationMs: Long = 0L,
|
||||
) {
|
||||
val completed: Boolean get() = playbackCompletesItem(positionMs, durationMs)
|
||||
}
|
||||
|
||||
private data class PlaybackDiscovery(
|
||||
val subtitles: List<PlayableSubtitle> = emptyList(),
|
||||
val mediaSourceId: String = "",
|
||||
@@ -3205,6 +3433,9 @@ private const val CONTINUE_PLAY_LOOKBACK = 120
|
||||
*/
|
||||
private const val TRICKPLAY_CACHE_SIZE = 12
|
||||
|
||||
/** Enough titles to cover an evening's browsing; the record only has to outlive one refresh. */
|
||||
private const val LOCAL_RESUME_CACHE_SIZE = 32
|
||||
|
||||
// A season's worth, so working through one show in an evening never asks twice.
|
||||
private const val INTRO_CACHE_SIZE = 24
|
||||
|
||||
@@ -3240,6 +3471,7 @@ private const val RELATED_LIMIT = 12
|
||||
*/
|
||||
private const val TRAILER_CACHE_SIZE = 64
|
||||
private const val EXTRAS_CACHE_SIZE = 64
|
||||
private const val RADARR_MOVIE_CACHE_SIZE = 32
|
||||
private const val MAX_TRAILER_CANDIDATES = 12
|
||||
|
||||
// Long enough that walking back and forth between a row and a detail page never re-asks,
|
||||
@@ -3252,9 +3484,61 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
|
||||
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
|
||||
|
||||
/** A positive position on the pressed card outranks an older prefetched zero. */
|
||||
internal fun launchResumePositionMs(resolvedPositionMs: Long, requestedPositionMs: Long): Long =
|
||||
if (requestedPositionMs > 0L) requestedPositionMs else resolvedPositionMs.coerceAtLeast(0L)
|
||||
/** Emby's own rule for a finished title; a stop past it resets the position rather than saving it. */
|
||||
internal const val PLAYBACK_COMPLETION_FRACTION = 0.9
|
||||
|
||||
/** How long the television argues with a stale card before deferring to the server again. */
|
||||
internal const val LOCAL_RESUME_MAX_AGE_MS = 12L * 60L * 60L * 1_000L
|
||||
|
||||
/**
|
||||
* Where a launch starts from.
|
||||
*
|
||||
* The television's own record of where it last left this title outranks both of the
|
||||
* others, and that is the whole of the fix for a short session losing its progress.
|
||||
* Neither of the other two can be trusted to be current: [requestedPositionMs] is read
|
||||
* off the card, which is only as fresh as the last home refresh, and [resolvedPositionMs]
|
||||
* is no better, because the direct path resolves nothing at all and the gateway accepts
|
||||
* the client's position as a hint rather than reading Emby again. So the position the
|
||||
* player left at has to be remembered here, or a viewer who exits and presses Play again
|
||||
* before the refresh lands is sent back to where they were before they watched.
|
||||
*
|
||||
* It is a **greatest**, never simply a preference, which is what retires the record with
|
||||
* no bookkeeping at all: once a refresh brings the card back carrying that position — or a
|
||||
* further one, watched on another set — the card is at least as current and the local
|
||||
* record can no longer change the answer.
|
||||
*/
|
||||
internal fun launchResumePositionMs(
|
||||
resolvedPositionMs: Long,
|
||||
requestedPositionMs: Long,
|
||||
localPositionMs: Long = 0L,
|
||||
): Long {
|
||||
val known = maxOf(requestedPositionMs, localPositionMs)
|
||||
return if (known > 0L) known else resolvedPositionMs.coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a title stopped at [positionMs] has been finished, in which case there is no
|
||||
* resume point worth remembering: the server resets a completed title's position, and a
|
||||
* record kept past that would drop somebody back into the closing minutes of something
|
||||
* they had chosen to watch again from the start.
|
||||
*/
|
||||
internal fun playbackCompletesItem(
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
completedFraction: Double = PLAYBACK_COMPLETION_FRACTION,
|
||||
): Boolean = durationMs > 0L && positionMs >= (durationMs * completedFraction).toLong()
|
||||
|
||||
/**
|
||||
* Whether a locally recorded resume point may still speak for a title. The backstop is
|
||||
* deliberately generous — the record retires itself as soon as a refreshed card catches up
|
||||
* with it — but not unbounded, or a set that recorded a position and was then left alone
|
||||
* for a week would still be arguing with the server about it.
|
||||
*/
|
||||
internal fun isFreshLocalResume(
|
||||
recordedAtMs: Long,
|
||||
nowMs: Long,
|
||||
maxAgeMs: Long = LOCAL_RESUME_MAX_AGE_MS,
|
||||
): Boolean = recordedAtMs in 1L..nowMs && nowMs - recordedAtMs <= maxAgeMs
|
||||
|
||||
private val BaseItem.resumePositionMs: Long
|
||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
||||
|
||||
@@ -304,6 +304,8 @@ data class Settings(
|
||||
val username: String? = null,
|
||||
/** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */
|
||||
val profileInitials: String = "",
|
||||
/** Admin-defined friendly name for the launcher's greeting; blank uses [username]. */
|
||||
val shortName: String = "",
|
||||
val deviceId: String = "",
|
||||
val deviceName: String = "",
|
||||
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
|
||||
@@ -479,6 +481,7 @@ data class EmbyProfile(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val profileInitials: String = "",
|
||||
val shortName: String = "",
|
||||
val serverId: String? = null,
|
||||
val homeCacheJson: String? = null,
|
||||
val forYouMinutes: Int = 0,
|
||||
@@ -574,6 +577,7 @@ class SettingsStore(private val context: Context) {
|
||||
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
|
||||
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
|
||||
val PROFILE_INITIALS = stringPreferencesKey("profile_initials")
|
||||
val SHORT_NAME = stringPreferencesKey("short_name")
|
||||
val THEME_ID = stringPreferencesKey("theme_id")
|
||||
val THEME_PALETTE = stringPreferencesKey("theme_palette")
|
||||
val THEME_ICON_SET = stringPreferencesKey("theme_icon_set")
|
||||
@@ -806,6 +810,7 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { store ->
|
||||
store[Keys.HOME_SECTIONS] = sections
|
||||
store[Keys.PROFILE_INITIALS] = preferences.profileInitials
|
||||
store[Keys.SHORT_NAME] = preferences.shortName
|
||||
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
|
||||
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
|
||||
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
|
||||
@@ -830,6 +835,7 @@ class SettingsStore(private val context: Context) {
|
||||
updateActiveProfile(store) {
|
||||
it.copy(
|
||||
profileInitials = preferences.profileInitials,
|
||||
shortName = preferences.shortName,
|
||||
homeSections = sections,
|
||||
homeCardDensity = preferences.homeCardDensity,
|
||||
homeArtworkStyle = preferences.homeArtworkStyle,
|
||||
@@ -1080,10 +1086,18 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markUpdateAlertRead() {
|
||||
/**
|
||||
* The local update notice's read flag, both ways.
|
||||
*
|
||||
* This alert is the one row on the Notifications page the gateway knows nothing about —
|
||||
* it is a property of the APK on *this* set — so its seen toggle has to be written here
|
||||
* rather than posted. Guarded on the version still being recorded: a flag left behind by
|
||||
* an alert somebody has already dismissed describes nothing.
|
||||
*/
|
||||
suspend fun setUpdateAlertRead(read: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
|
||||
preferences[Keys.UPDATE_ALERT_READ] = true
|
||||
preferences[Keys.UPDATE_ALERT_READ] = read
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1246,6 +1260,7 @@ class SettingsStore(private val context: Context) {
|
||||
userId = userId,
|
||||
username = username,
|
||||
profileInitials = previous?.profileInitials.orEmpty(),
|
||||
shortName = previous?.shortName.orEmpty(),
|
||||
serverId = serverId,
|
||||
homeCacheJson = previous?.homeCacheJson,
|
||||
forYouMinutes = previous?.forYouMinutes ?: 0,
|
||||
@@ -1367,6 +1382,7 @@ class SettingsStore(private val context: Context) {
|
||||
// this TV believe it had already synced settings it has never seen.
|
||||
preferences.remove(Keys.PREFERENCES_REVISION)
|
||||
preferences.remove(Keys.PROFILE_INITIALS)
|
||||
preferences.remove(Keys.SHORT_NAME)
|
||||
preferences.remove(Keys.USERNAME)
|
||||
}
|
||||
|
||||
@@ -1376,6 +1392,7 @@ class SettingsStore(private val context: Context) {
|
||||
preferences[Keys.USER_ID] = profile.userId
|
||||
preferences[Keys.USERNAME] = profile.username
|
||||
preferences[Keys.PROFILE_INITIALS] = profile.profileInitials
|
||||
preferences[Keys.SHORT_NAME] = profile.shortName
|
||||
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
|
||||
else preferences[Keys.SERVER_ID] = profile.serverId
|
||||
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
|
||||
@@ -1442,6 +1459,7 @@ class SettingsStore(private val context: Context) {
|
||||
userId = userId,
|
||||
username = username,
|
||||
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
|
||||
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
|
||||
serverId = preferences[Keys.SERVER_ID],
|
||||
homeCacheJson = activeHomeCache(preferences),
|
||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||
@@ -1491,6 +1509,7 @@ class SettingsStore(private val context: Context) {
|
||||
serverId = preferences[Keys.SERVER_ID],
|
||||
username = preferences[Keys.USERNAME],
|
||||
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
|
||||
shortName = preferences[Keys.SHORT_NAME].orEmpty(),
|
||||
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
|
||||
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
|
||||
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
|
||||
|
||||
@@ -25,6 +25,12 @@ import kotlinx.serialization.json.putJsonArray
|
||||
data class UserPreferences(
|
||||
/** Admin-defined avatar text; blank keeps the name-derived fallback. */
|
||||
val profileInitials: String = "",
|
||||
/**
|
||||
* The friendly name the launcher greets this person by — "Matt" for an account called
|
||||
* MattCohen. It is not a second username: nothing is keyed on it and nothing signs in
|
||||
* with it, so blank is the ordinary state and the account name stands in.
|
||||
*/
|
||||
val shortName: String = "",
|
||||
val homeSections: List<String> = DEFAULT_SECTIONS,
|
||||
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
@@ -78,6 +84,7 @@ data class UserPreferences(
|
||||
*/
|
||||
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
|
||||
profileInitials = profileInitials,
|
||||
shortName = shortName,
|
||||
homeSections = homeSections.decodeCommaList(),
|
||||
homeCardDensity = homeCardDensity,
|
||||
homeArtworkStyle = homeArtworkStyle,
|
||||
@@ -120,6 +127,7 @@ fun decodeUserPreferences(
|
||||
fallback: UserPreferences = UserPreferences(),
|
||||
): UserPreferences = UserPreferences(
|
||||
profileInitials = json.string("profileInitials", fallback.profileInitials),
|
||||
shortName = json.string("shortName", fallback.shortName),
|
||||
homeSections = json.stringList("homeSections", fallback.homeSections)
|
||||
.ifEmpty { fallback.homeSections },
|
||||
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
|
||||
@@ -155,6 +163,7 @@ fun decodeUserPreferences(
|
||||
/** The document as the gateway expects it. The server normalises whatever arrives. */
|
||||
fun UserPreferences.encode(): JsonObject = buildJsonObject {
|
||||
put("profileInitials", profileInitials)
|
||||
put("shortName", shortName)
|
||||
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
|
||||
put("homeCardDensity", homeCardDensity)
|
||||
put("homeArtworkStyle", homeArtworkStyle)
|
||||
|
||||
@@ -457,6 +457,10 @@ data class BaseItem(
|
||||
// The Emby series a schedule card stands for, when the library holds it. Absent for a
|
||||
// show Sonarr follows but Emby has never imported, so the card stays informational.
|
||||
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
|
||||
// The Emby film a movie-schedule card stands for, when the library holds it. Absent for
|
||||
// a film Radarr is tracking but Emby has never imported, which is what sends the card to
|
||||
// the Radarr-only detail page instead of to the ordinary one.
|
||||
@SerialName("MembyMovieItemId") val membyMovieItemId: String? = null,
|
||||
// Derived by the TV from the weekly schedule row and retained in the local home cache.
|
||||
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
|
||||
// Explainability supplied only by the gateway's dedicated For You endpoint.
|
||||
@@ -491,6 +495,14 @@ data class BaseItem(
|
||||
val isMovieSchedule: Boolean get() = membySource == "radarr"
|
||||
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
|
||||
|
||||
/**
|
||||
* A film Radarr is tracking that Emby has no copy of, which is the one card in the app
|
||||
* with a page of its own rather than an Emby one. The moment the library imports it the
|
||||
* gateway attaches [membyMovieItemId] and this is false, so a title stops being a
|
||||
* Radarr card without anything having to be invalidated.
|
||||
*/
|
||||
val isRadarrOnly: Boolean get() = isMovieSchedule && membyMovieItemId.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
|
||||
* one, since it knows about a season announced but not yet imported; Emby's own
|
||||
|
||||
@@ -417,6 +417,59 @@ data class GatewayActiveHero(
|
||||
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. */
|
||||
@Serializable
|
||||
data class MediaRating(
|
||||
|
||||
@@ -220,6 +220,18 @@ interface GatewayApi {
|
||||
@GET("v1/people/{id}/filmography")
|
||||
suspend fun personFilmography(@Path("id") personId: String): GatewayItems
|
||||
|
||||
/**
|
||||
* Everything the Radarr-only detail page draws, for a film the household is tracking but
|
||||
* has no copy of. Its own route rather than `v1/items/{id}` because there is no Emby
|
||||
* item to ask about: the answer is Radarr's catalogue, the ratings store and whether
|
||||
* Emby has since imported it. A gateway that predates the route answers 404, which the
|
||||
* repository reads as "no page to open" rather than as an error.
|
||||
*/
|
||||
@GET("v1/radarr/movies/{id}")
|
||||
suspend fun radarrMovie(
|
||||
@Path("id") movieId: String,
|
||||
): com.ponzischeme89.memby.data.model.RadarrMovieDetail
|
||||
|
||||
/** Optional, server-filtered external movie ratings. Empty is always a valid result. */
|
||||
@GET("v1/items/{id}/ratings")
|
||||
suspend fun movieRatings(@Path("id") itemId: String): GatewayMovieRatings
|
||||
|
||||
@@ -1027,6 +1027,21 @@ private fun SkeletonBlock(width: Dp, height: Dp) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the long-press menu.
|
||||
*
|
||||
* The menu is built as a list rather than as a fixed sequence of blocks with hand-written
|
||||
* indices, because what belongs on it depends on the card: a film Radarr is tracking has no
|
||||
* Emby record to favourite or mark watched, and it has a trailer where an ordinary card
|
||||
* does not. Deriving the focus indices from the list is what keeps that from being four
|
||||
* pieces of arithmetic to hold in step.
|
||||
*/
|
||||
private data class QuickAction(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MediaQuickActionsOverlay(
|
||||
item: BaseItem,
|
||||
@@ -1034,6 +1049,13 @@ fun MediaQuickActionsOverlay(
|
||||
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
||||
onRemoveFromContinueWatching: (() -> Unit)? = null,
|
||||
/**
|
||||
* Offered from the row for a film that is not in the library yet, which is the one card
|
||||
* whose trailer is the only thing there is to play. Pressing the card itself still opens
|
||||
* its page — a long press is where a shortcut belongs, not where the ordinary action is
|
||||
* replaced by it.
|
||||
*/
|
||||
onPlayTrailer: (() -> Unit)? = null,
|
||||
rowTitle: String? = null,
|
||||
rowPinned: Boolean = false,
|
||||
onToggleRowPinned: (() -> Unit)? = null,
|
||||
@@ -1045,10 +1067,56 @@ fun MediaQuickActionsOverlay(
|
||||
onToggleRowPinned != null &&
|
||||
onHideRow != null &&
|
||||
onMoveRow != null
|
||||
val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0
|
||||
val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) +
|
||||
(if (hasRowActions) 4 else 0)
|
||||
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
|
||||
// Library state belongs to an Emby item. A card standing for a film the household does
|
||||
// not hold has none, and a "Mark watched" that answers 404 is worse than no entry.
|
||||
val hasLibraryActions = !item.isRadarrOnly
|
||||
val itemActions = buildList {
|
||||
add(QuickAction("View details", MembyIcon.Info.mark) { onOpenDetails(item) })
|
||||
onPlayTrailer?.let { play ->
|
||||
add(QuickAction("Play trailer", MembyIcon.Movie.mark) { play() })
|
||||
}
|
||||
if (hasLibraryActions) {
|
||||
add(
|
||||
QuickAction(
|
||||
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
|
||||
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
|
||||
) {
|
||||
onSetFavorite(item, !item.isFavorite)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
add(
|
||||
QuickAction(
|
||||
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
|
||||
icon = MembyIcon.CheckCircle.mark,
|
||||
) {
|
||||
onSetPlayed(item, item.userData?.played != true)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
}
|
||||
onRemoveFromContinueWatching?.let {
|
||||
add(QuickAction("Remove from Continue Watching", MembyIcon.PlaylistRemove.mark, it))
|
||||
}
|
||||
}
|
||||
val rowActions = if (!hasRowActions) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
QuickAction(
|
||||
label = if (rowPinned) "Unpin row" else "Pin row to top",
|
||||
icon = MembyIcon.Pin.mark,
|
||||
onClick = onToggleRowPinned!!,
|
||||
),
|
||||
QuickAction("Move row up", MembyIcon.ArrowUp.mark) { onMoveRow!!(-1) },
|
||||
QuickAction("Move row down", MembyIcon.ArrowDown.mark) { onMoveRow!!(1) },
|
||||
QuickAction("Hide this row", MembyIcon.HideWatched.mark, onHideRow!!),
|
||||
)
|
||||
}
|
||||
val actions = itemActions + rowActions +
|
||||
QuickAction("Close", MembyIcon.ChevronLeft.mark, onClose)
|
||||
val actionCount = actions.size
|
||||
val focusRequesters = remember(item.id, actionCount) { List(actionCount) { FocusRequester() } }
|
||||
var focusedIndex by remember(item.id) { mutableStateOf(0) }
|
||||
// The menu can appear while OK is still physically held. Until that opening press
|
||||
// is released, consume all activation events so it cannot trigger the first action.
|
||||
@@ -1127,116 +1195,51 @@ fun MediaQuickActionsOverlay(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp),
|
||||
)
|
||||
QuickActionMenuItem(
|
||||
label = "View details",
|
||||
icon = MembyIcon.Info.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[0])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = 0 },
|
||||
onClick = { onOpenDetails(item) },
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
QuickActionMenuItem(
|
||||
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
|
||||
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[1])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = 1 },
|
||||
onClick = {
|
||||
onSetFavorite(item, !item.isFavorite)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
QuickActionMenuItem(
|
||||
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
|
||||
icon = MembyIcon.CheckCircle.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[2])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = 2 },
|
||||
onClick = {
|
||||
onSetPlayed(item, item.userData?.played != true)
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
if (onRemoveFromContinueWatching != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
QuickActionMenuItem(
|
||||
label = "Remove from Continue Watching",
|
||||
icon = MembyIcon.PlaylistRemove.mark,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[3])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
|
||||
onClick = onRemoveFromContinueWatching,
|
||||
)
|
||||
}
|
||||
if (hasRowActions) {
|
||||
actions.forEachIndexed { index, action ->
|
||||
// The row actions are about the shelf rather than about the title, and
|
||||
// Close is about neither, so each is introduced by its own rule.
|
||||
when {
|
||||
rowActions.isNotEmpty() && index == itemActions.size -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(Color.White.copy(alpha = 0.07f)),
|
||||
)
|
||||
QuickActionDivider()
|
||||
Text(
|
||||
rowTitle.orEmpty(),
|
||||
color = QuietText,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
|
||||
)
|
||||
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!!,
|
||||
.padding(start = 8.dp, top = 7.dp, end = 8.dp, bottom = 3.dp),
|
||||
)
|
||||
}
|
||||
index == actionCount - 1 -> {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
QuickActionDivider()
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
index > 0 -> Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
QuickActionMenuItem(
|
||||
label = action.label,
|
||||
icon = action.icon,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.onFocusChanged { if (it.isFocused) focusedIndex = index },
|
||||
onClick = action.onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionDivider() {
|
||||
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
|
||||
|
||||
@@ -24,6 +24,19 @@ internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hour
|
||||
* It still goes away further down the launcher: by then somebody is looking for something
|
||||
* to watch rather than being welcomed.
|
||||
*/
|
||||
/**
|
||||
* The name Memby addresses somebody by, on the launcher and anywhere else it speaks to
|
||||
* them directly.
|
||||
*
|
||||
* The short name is an operator's answer and wins where there is one, because it is the
|
||||
* only one of the two a person actually chose to be called. Everything else falls back to
|
||||
* [friendlyProfileName]'s reading of the account name, so a household that has never set
|
||||
* one is greeted exactly as it was before — a blank or whitespace-only value is the
|
||||
* ordinary state, not a name.
|
||||
*/
|
||||
internal fun greetingName(shortName: String?, username: String?): String? =
|
||||
shortName?.trim()?.takeIf(String::isNotEmpty) ?: friendlyProfileName(username)
|
||||
|
||||
internal fun shouldShowHomeGreeting(
|
||||
hasHero: Boolean,
|
||||
focusedRowId: String?,
|
||||
|
||||
@@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.PlaybackPosition
|
||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
|
||||
import com.ponzischeme89.memby.data.analytics.JourneySink
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyTracker
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||
import com.ponzischeme89.memby.data.millisecondsToTicks
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
@@ -181,6 +183,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
init {
|
||||
refreshAll()
|
||||
viewModelScope.launch {
|
||||
// Arrives as the player exits, ahead of the report and well ahead of the rows
|
||||
// coming back, so the card a viewer is standing on already shows the progress
|
||||
// they just made rather than the progress bar they left home with.
|
||||
repository.playbackPositions.collect(::applyPlaybackPosition)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.playbackStops.collect { refreshWatching() }
|
||||
}
|
||||
@@ -423,6 +431,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* of the request.
|
||||
*/
|
||||
private suspend fun warmDetailPage(item: BaseItem) {
|
||||
// A movie-schedule card whose film is not in the library opens a page of its own,
|
||||
// and that page is one request. Warming it here is what makes it open on the press
|
||||
// rather than a moment after it, and it is the only warm a schedule card has any
|
||||
// use for — there is no Emby item behind it to fetch anything else about.
|
||||
if (item.isRadarrOnly) {
|
||||
runCatching { repository.getRadarrMovie(item.id) }
|
||||
return
|
||||
}
|
||||
if (item.isSchedule) return
|
||||
// Focus settling on a playable card is the best warning of a Play press this app
|
||||
// gets. Opening the connection to Emby now means the press pays for bytes rather
|
||||
@@ -556,6 +572,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a card's playhead to where the player just left it, before anything is asked of
|
||||
* the server. [refreshWatching] follows and replaces this with the server's own answer;
|
||||
* this is what stands in for it in the meantime, which is exactly the window a viewer
|
||||
* who exits and presses Play again is inside.
|
||||
*
|
||||
* A completed title is left alone rather than pushed to its own end: what happens to it
|
||||
* is that it leaves Continue Watching, which is the refresh's answer to give.
|
||||
*/
|
||||
private fun applyPlaybackPosition(position: PlaybackPosition) {
|
||||
if (position.itemId.isBlank() || position.completed) return
|
||||
val ticks = millisecondsToTicks(position.positionMs)
|
||||
updateUserData(position.itemId) {
|
||||
// Never backwards: a stop and the ten-second report before it can arrive in
|
||||
// either order, and the card must not step back to the earlier of the two.
|
||||
if (ticks > it.playbackPositionTicks) it.copy(playbackPositionTicks = ticks) else it
|
||||
}
|
||||
viewModelScope.launch { persistCurrentHome() }
|
||||
}
|
||||
|
||||
private suspend fun refreshWatching() {
|
||||
refreshMutex.withLock {
|
||||
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
|
||||
|
||||
@@ -140,6 +140,7 @@ import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
|
||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleMovieStub
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
|
||||
import com.ponzischeme89.memby.ui.requests.RequestsScreen
|
||||
@@ -1942,6 +1943,10 @@ private fun HomeScreen(
|
||||
// same series reached from Favourites or a search never claims a schedule.
|
||||
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(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 focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
||||
var sectionHeroRows by remember(settings.userId) {
|
||||
@@ -3216,13 +3221,22 @@ private fun HomeScreen(
|
||||
// the viewer asked for is the show — carrying the air
|
||||
// time across, since that is why they pressed it.
|
||||
val seriesStub = scheduleSeriesStub(item)
|
||||
// A movie-schedule card whose film Emby has since
|
||||
// imported is the ordinary movie page; one whose film
|
||||
// is still only Radarr's opens its own. Neither is
|
||||
// inert, which is what the card used to be.
|
||||
val movieStub = scheduleMovieStub(item)
|
||||
if (seriesStub != null) {
|
||||
detailsAiringNotice = airingNoticeFor(item)
|
||||
homeViewModel.focusItem(seriesStub)
|
||||
detailsItem = seriesStub
|
||||
} else if (movieStub != null) {
|
||||
detailsAiringNotice = null
|
||||
homeViewModel.focusItem(movieStub)
|
||||
detailsItem = movieStub
|
||||
} else {
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable) {
|
||||
if (item.membyPlayable || item.isRadarrOnly) {
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
}
|
||||
@@ -3233,7 +3247,13 @@ private fun HomeScreen(
|
||||
returnRowKind = row.kind.name
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable) {
|
||||
if (item.membyPlayable || item.isRadarrOnly) {
|
||||
// Cleared here rather than only in the effect that
|
||||
// answers it: the effect runs after the menu's
|
||||
// first frame, and the previous card's answer
|
||||
// showing on it would be an entry that appears and
|
||||
// then vanishes.
|
||||
quickMenuTrailerAvailable = false
|
||||
quickMenuRowId = row.id
|
||||
quickMenuItem = item
|
||||
}
|
||||
@@ -3269,6 +3289,7 @@ private fun HomeScreen(
|
||||
HomeClock(
|
||||
showGreeting = showHomeGreeting,
|
||||
username = settings.username,
|
||||
shortName = settings.shortName,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 24.dp, bottom = 18.dp),
|
||||
@@ -3524,6 +3545,14 @@ private fun HomeScreen(
|
||||
// and should not: it was news, and it has been read.
|
||||
detailsAiringNotice = null
|
||||
},
|
||||
onOpenEmbyItem = { embyItem ->
|
||||
// Not a step in the trail: the Radarr page and the Emby page are two
|
||||
// answers about one title, so Back from here still belongs where the
|
||||
// card was pressed rather than on the page it replaced.
|
||||
homeViewModel.focusItem(embyItem)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = embyItem
|
||||
},
|
||||
onPlay = {
|
||||
// Kept, not discarded: this is what the viewer comes back to when the
|
||||
// film ends or they press Back out of the player.
|
||||
@@ -3780,50 +3809,39 @@ private fun HomeScreen(
|
||||
notificationsLoading = false
|
||||
}
|
||||
},
|
||||
onToggleEnabled = {
|
||||
if (notificationsMutationBusy) return@MyAlertsPage
|
||||
notificationsMutationBusy = true
|
||||
scope.launch {
|
||||
val updated = notificationState.preferences.copy(
|
||||
enabled = !notificationState.preferences.enabled,
|
||||
)
|
||||
runCatching { repo.setNotificationPreferences(updated) }
|
||||
.onSuccess { notificationState = it }
|
||||
.onFailure { notificationsError = friendlyEmbyError(it) }
|
||||
notificationsMutationBusy = false
|
||||
}
|
||||
},
|
||||
onToggleShowReturns = {
|
||||
if (notificationsMutationBusy) return@MyAlertsPage
|
||||
notificationsMutationBusy = true
|
||||
scope.launch {
|
||||
val updated = notificationState.preferences.copy(
|
||||
showReturnAlerts = !notificationState.preferences.showReturnAlerts,
|
||||
)
|
||||
runCatching { repo.setNotificationPreferences(updated) }
|
||||
.onSuccess { notificationState = it }
|
||||
.onFailure { notificationsError = friendlyEmbyError(it) }
|
||||
notificationsMutationBusy = false
|
||||
}
|
||||
},
|
||||
// Marked read locally first. This fires on *focus*, so on a slow
|
||||
// connection walking down the list and back up would send the same row's
|
||||
// request once per pass — clearing the flag immediately is what makes the
|
||||
// row stop asking.
|
||||
onRead = onRead@{ notification ->
|
||||
// The seen toggle, and the only thing that moves a row between Inbox and
|
||||
// Seen — nothing is marked read merely by being looked at any more, because
|
||||
// with the two halves split that would empty the Inbox under the remote.
|
||||
//
|
||||
// Optimistic and reversed on failure, like the dismissal below: the flag is
|
||||
// the only thing that changed, so a row that sat unmoved while its request
|
||||
// was in flight is one pressed a second time. The locally-held update notice
|
||||
// has no server row to post, so its flag is written to this television's own
|
||||
// settings instead — it is a fact about the APK on this set.
|
||||
onToggleSeen = onToggleSeen@{ notification ->
|
||||
val markingSeen = notification.unread
|
||||
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
|
||||
scope.launch { ServiceLocator.settings.markUpdateAlertRead() }
|
||||
return@onRead
|
||||
scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) }
|
||||
return@onToggleSeen
|
||||
}
|
||||
val previousReadAt = notification.readAt
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.map {
|
||||
if (it.id == notification.id) it.copy(readAt = "now") else it
|
||||
if (it.id == notification.id) {
|
||||
it.copy(readAt = if (markingSeen) "now" else null)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { repo.markNotificationRead(notification.id) }
|
||||
.onFailure { failure ->
|
||||
runCatching {
|
||||
if (markingSeen) {
|
||||
repo.markNotificationRead(notification.id)
|
||||
} else {
|
||||
repo.markNotificationUnread(notification.id)
|
||||
}
|
||||
}.onFailure { failure ->
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.map {
|
||||
if (it.id == notification.id) {
|
||||
@@ -3868,16 +3886,27 @@ private fun HomeScreen(
|
||||
// list is emptied optimistically: the page is judged on emptying itself,
|
||||
// and a row that lingered while its request was in flight would be pressed
|
||||
// a second time.
|
||||
onDismissAll = {
|
||||
onDismissAll = { pending ->
|
||||
if (notificationsMutationBusy) return@MyAlertsPage
|
||||
notificationsMutationBusy = true
|
||||
val previous = notificationState
|
||||
val pending = notificationState.notifications.map(UserNotification::id)
|
||||
val dismissLocalUpdate = settings.updateAlertVersion != null
|
||||
notificationState = notificationState.copy(notifications = emptyList())
|
||||
// Only the half on screen. The page dismisses what it is showing, so
|
||||
// emptying Seen must not also throw away an Inbox the viewer has not
|
||||
// read — a bulk action nobody can see the extent of is one nobody presses.
|
||||
val pendingIds = pending.map(UserNotification::id).toSet()
|
||||
val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds &&
|
||||
settings.updateAlertVersion != null
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.filterNot {
|
||||
it.id in pendingIds
|
||||
},
|
||||
)
|
||||
scope.launch {
|
||||
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
|
||||
val failed = pending.filter { id ->
|
||||
// The local update notice has no server row, so asking the gateway to
|
||||
// dismiss it would be one guaranteed failure per pass.
|
||||
val failed = pendingIds.filter { id ->
|
||||
id != MEMBY_UPDATE_NOTIFICATION_ID &&
|
||||
runCatching { repo.dismissNotification(id) }.isFailure
|
||||
}
|
||||
runCatching { repo.getNotifications() }
|
||||
@@ -3896,6 +3925,10 @@ private fun HomeScreen(
|
||||
)
|
||||
}
|
||||
quickMenuItem?.let { selected ->
|
||||
LaunchedEffect(selected.id) {
|
||||
quickMenuTrailerAvailable = selected.isRadarrOnly &&
|
||||
repo.getRadarrMovie(selected.id)?.trailerAvailable == true
|
||||
}
|
||||
val closeQuickActions: (Boolean) -> Unit = { originWillDisappear ->
|
||||
quickMenuItem = null
|
||||
quickMenuRowId = null
|
||||
@@ -3925,6 +3958,18 @@ private fun HomeScreen(
|
||||
},
|
||||
onSetFavorite = homeViewModel::setFavorite,
|
||||
onSetPlayed = homeViewModel::setPlayed,
|
||||
// Only for a film with no page to play from, and only when the gateway has
|
||||
// a candidate to resolve — the same answer the detail page's button waits
|
||||
// for, so the two can never disagree about whether there is a trailer.
|
||||
onPlayTrailer = if (selected.isRadarrOnly && quickMenuTrailerAvailable) {
|
||||
{
|
||||
quickMenuItem = null
|
||||
quickMenuRowId = null
|
||||
playTrailer(selected)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onRemoveFromContinueWatching = if (
|
||||
rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE
|
||||
) {
|
||||
@@ -4021,6 +4066,7 @@ private fun HomeScreen(
|
||||
!settings.hasOpenedForYou &&
|
||||
liveMaintenance == null,
|
||||
username = settings.username,
|
||||
shortName = settings.shortName,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
// Emby has stopped answering. Persistent, unlike the news bar below it, because
|
||||
@@ -4170,6 +4216,7 @@ private fun RecentSearchesRow(
|
||||
private fun HomeClock(
|
||||
showGreeting: Boolean,
|
||||
username: String?,
|
||||
shortName: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -4189,7 +4236,7 @@ private fun HomeClock(
|
||||
val period = homeGreetingPeriod(
|
||||
Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY),
|
||||
)
|
||||
val name = friendlyProfileName(username)
|
||||
val name = greetingName(shortName, username)
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -4353,11 +4400,27 @@ private fun FocusedDetailsOverlay(
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit,
|
||||
/**
|
||||
* Where a Radarr card goes once Emby has imported the film. The row is cached for the
|
||||
* day on the gateway, so the card can still arrive without an Emby id long after the
|
||||
* import; the detail request is what notices, and this is what acts on it.
|
||||
*/
|
||||
onOpenEmbyItem: (BaseItem) -> Unit = {},
|
||||
airingNotice: AiringNotice? = null,
|
||||
) {
|
||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||
val item = focusedItem?.takeIf { it.id == selected.id } ?: selected
|
||||
if (item.isSeries) {
|
||||
if (item.isRadarrOnly) {
|
||||
// A film Radarr is tracking that Emby has never imported. It is the one card with a
|
||||
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
|
||||
// it is not the movie page with its playable parts taken away.
|
||||
RadarrMovieDetailsOverlay(
|
||||
card = item,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
onClose = onClose,
|
||||
onOpenEmbyItem = onOpenEmbyItem,
|
||||
)
|
||||
} else if (item.isSeries) {
|
||||
SeriesDetailsOverlay(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
@@ -4405,6 +4468,7 @@ private fun FocusedQuickActionsOverlay(
|
||||
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
||||
onRemoveFromContinueWatching: (() -> Unit)?,
|
||||
onPlayTrailer: (() -> Unit)?,
|
||||
rowTitle: String?,
|
||||
rowPinned: Boolean,
|
||||
onToggleRowPinned: (() -> Unit)?,
|
||||
@@ -4419,6 +4483,7 @@ private fun FocusedQuickActionsOverlay(
|
||||
onSetFavorite = onSetFavorite,
|
||||
onSetPlayed = onSetPlayed,
|
||||
onRemoveFromContinueWatching = onRemoveFromContinueWatching,
|
||||
onPlayTrailer = onPlayTrailer,
|
||||
rowTitle = rowTitle,
|
||||
rowPinned = rowPinned,
|
||||
onToggleRowPinned = onToggleRowPinned,
|
||||
@@ -4777,9 +4842,12 @@ private fun ForYouTimeBudget(
|
||||
private fun ForYouNudgeBanner(
|
||||
visible: Boolean,
|
||||
username: String?,
|
||||
shortName: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val name = friendlyProfileName(username)
|
||||
// The same name the hero greeting uses: two places addressing one person by two
|
||||
// different names is worse than neither of them being personalised.
|
||||
val name = greetingName(shortName, username)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = androidx.compose.animation.fadeIn(tween(220)),
|
||||
|
||||
@@ -38,6 +38,7 @@ import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOutline
|
||||
|
||||
/**
|
||||
* One green Play button, in two sizes.
|
||||
@@ -153,6 +154,45 @@ private fun PrimaryActionSurface(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The quiet counterpart to [MembyPlayButton]: an action that is available but is not what
|
||||
* the screen is for. It is an outline rather than a fill, so the primary action stays the
|
||||
* only green thing on the page and the two are told apart at three metres.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MembySecondaryButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "secondary-focus")
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||
.clip(shape)
|
||||
.background(if (focused) MembyOutline else Color.Transparent)
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyOutline, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(
|
||||
horizontal = if (compact) 14.dp else 23.dp,
|
||||
vertical = if (compact) 8.dp else 13.dp,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) Color.White else MembyMutedText,
|
||||
fontSize = if (compact) 13.sp else 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable chip for a small set of mutually exclusive choices. The tick is a real icon
|
||||
* on the selected chip rather than a character in the label, so the chip does not change
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
|
||||
/**
|
||||
* The wording and the counting behind Notifications, kept pure so the badge a viewer sees in the
|
||||
* user picker and the summary line on the page itself are the same arithmetic tested once.
|
||||
@@ -32,3 +34,39 @@ internal fun alertsSummary(total: Int, unread: Int): String = when {
|
||||
"$notifications · $unread new"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two halves of the page: what is waiting, and what has been dealt with.
|
||||
*
|
||||
* The split is what replaced the on/off switch. A viewer who could turn notifications off was
|
||||
* being offered a way to make the page permanently useless — and the reason to reach for it
|
||||
* was that a list mixing new news with everything already read never emptied. Two named
|
||||
* halves with their counts on them is the same relief without the off switch: the Inbox is
|
||||
* the short list somebody has to do something about, and Seen is where it goes.
|
||||
*
|
||||
* Membership is the *read* flag and nothing else, which is what lets both counts and both
|
||||
* panes be derived from one list the caller already holds — there is no third state to keep
|
||||
* in step, and dismissing a row still removes it from the page entirely.
|
||||
*/
|
||||
enum class AlertsTab(val label: String) { INBOX("Inbox"), SEEN("Seen") }
|
||||
|
||||
/** The alerts [tab] holds, in the order the caller gave them. */
|
||||
internal fun alertsForTab(
|
||||
tab: AlertsTab,
|
||||
notifications: List<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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -61,10 +62,10 @@ import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.ui.MembyChoiceChip
|
||||
import com.ponzischeme89.memby.ui.formatMyShowDate
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
@@ -81,14 +82,26 @@ import kotlinx.coroutines.delay
|
||||
* drawn on Home, and cost a focus target on every set in the house whether or not there was
|
||||
* anything behind it.
|
||||
*
|
||||
* It reads like Settings on purpose — black canvas, flat rows on a shared 16dp inset with
|
||||
* hairlines between them, and the row under focus the only lit surface on the page.
|
||||
* It is laid out as My Requests is — the marked heading, a tab strip under it, one pane at a
|
||||
* time — because the two pages answer the same shape of question about a person's own list,
|
||||
* and a household should not have to learn two of them.
|
||||
*
|
||||
* **There is no off switch.** Turning notifications off was the page offering a way to make
|
||||
* itself permanently useless, and the reason to reach for it was that a single list mixing
|
||||
* new news with everything already read never emptied. [AlertsTab] is the answer instead:
|
||||
* Inbox is the short list to do something about, Seen is where it goes, and both wear their
|
||||
* count so a viewer can see from the strip whether there is anything to open. The stored
|
||||
* preferences are still honoured — they are just no longer the viewer's to switch from here,
|
||||
* which is why the empty state still says so when nothing is arriving.
|
||||
*
|
||||
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
|
||||
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
|
||||
* and a second confirmation press on every alert is what made the old panel not worth
|
||||
* opening. And **focus marks read** — a row can only be read by being looked at, so nothing
|
||||
* has to be pressed to clear the "new" flag on it.
|
||||
* opening. And **nothing moves under the remote by being looked at** — focus used to mark a
|
||||
* row read, which was harmless while the list was one list and would now empty the Inbox
|
||||
* merely by somebody scrolling it. Seen is a state a viewer puts a row into, with the toggle
|
||||
* beside it, and focus lands back on that toggle afterwards so a run of them is a run of one
|
||||
* press.
|
||||
*
|
||||
* Stateless by design: the caller owns the list and the requests, so this can be previewed
|
||||
* and screenshotted with no server.
|
||||
@@ -100,48 +113,74 @@ fun MyAlertsPage(
|
||||
loading: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
onRetry: () -> Unit = {},
|
||||
onToggleEnabled: () -> Unit,
|
||||
onToggleShowReturns: () -> Unit,
|
||||
onRead: (UserNotification) -> Unit,
|
||||
onToggleSeen: (UserNotification) -> Unit = {},
|
||||
onDismiss: (UserNotification) -> Unit,
|
||||
onDismissAll: () -> Unit,
|
||||
onDismissAll: (List<UserNotification>) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actionsFocusRequester = remember { FocusRequester() }
|
||||
val notificationIds = notifications.map(UserNotification::id)
|
||||
val rowFocusRequesters = remember(notificationIds) {
|
||||
List(notificationIds.size) { FocusRequester() }
|
||||
}
|
||||
var tab by remember { mutableStateOf(AlertsTab.INBOX) }
|
||||
var page by remember { mutableStateOf(0) }
|
||||
val inbox = remember(notifications) { alertsForTab(AlertsTab.INBOX, notifications) }
|
||||
val seen = remember(notifications) { alertsForTab(AlertsTab.SEEN, notifications) }
|
||||
val tabNotifications = if (tab == AlertsTab.INBOX) inbox else seen
|
||||
val pageCount = alertPageCount(tabNotifications.size)
|
||||
// Derived rather than only corrected in an effect: an effect runs after the frame, so a
|
||||
// list that shrank under a viewer standing on the last page would draw one empty frame
|
||||
// before the correction landed. [page] is written back below to keep the two in step.
|
||||
val safePage = alertPageAfterChange(page, tabNotifications.size)
|
||||
val pageNotifications = alertPageItems(tabNotifications, safePage)
|
||||
val pageIds = pageNotifications.map(UserNotification::id)
|
||||
// Two requesters per row, because a row holds two focus targets and which of them a list
|
||||
// change should land on depends on what the viewer just pressed — see [pendingFocusToggle].
|
||||
val rowFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
|
||||
val toggleFocusRequesters = remember(pageIds) { List(pageIds.size) { FocusRequester() } }
|
||||
val tabsFocusRequester = remember { FocusRequester() }
|
||||
val listState = rememberLazyListState()
|
||||
var pendingFocusIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val hasAlerts = notifications.isNotEmpty()
|
||||
LaunchedEffect(Unit) {
|
||||
// One frame for the list to place its first row; an empty page has nothing below
|
||||
// the actions to land on, so the chips take the remote instead.
|
||||
var pendingFocusToggle by remember { mutableStateOf(false) }
|
||||
var pendingFocusPage by remember { mutableStateOf(0) }
|
||||
val hasAlerts = tabNotifications.isNotEmpty()
|
||||
LaunchedEffect(safePage) { page = safePage }
|
||||
// Opening the page, and every tab press after it. A tab press moves focus into the pane it
|
||||
// opened, the stance My Requests takes: the strip is what Back returns to, so leaving
|
||||
// focus on it would cost a press before anything could be read.
|
||||
LaunchedEffect(tab) {
|
||||
// One frame for the list to place its first row; an empty pane has nothing below the
|
||||
// strip to land on, so the strip keeps the remote instead.
|
||||
delay(16)
|
||||
runCatching {
|
||||
if (hasAlerts) rowFocusRequesters.first().requestFocus() else actionsFocusRequester.requestFocus()
|
||||
val first = rowFocusRequesters.firstOrNull()
|
||||
if (first != null) first.requestFocus() else tabsFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(notificationIds) {
|
||||
if (notifications.isEmpty()) {
|
||||
LaunchedEffect(pageIds) {
|
||||
if (!hasAlerts) {
|
||||
pendingFocusIndex = null
|
||||
delay(16)
|
||||
runCatching { actionsFocusRequester.requestFocus() }
|
||||
runCatching { tabsFocusRequester.requestFocus() }
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
|
||||
val wantsToggle = pendingFocusToggle
|
||||
// Spent on this list change however it turns out. Left set, a request that could
|
||||
// not be honoured — an empty list, a dismissal the server refused and put back —
|
||||
// would be honoured against the *next* change instead, which is commonly an alert
|
||||
// arriving on its own: focus would jump for a press made minutes ago.
|
||||
pendingFocusIndex = null
|
||||
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
|
||||
?: return@LaunchedEffect
|
||||
val targetIndex = if (safePage != pendingFocusPage) {
|
||||
// The last row of a page went, so the pager stepped back one. The row the eye is
|
||||
// already nearest is the bottom of the page now on screen, not its top.
|
||||
pageIds.lastIndex.takeIf { it >= 0 }
|
||||
} else {
|
||||
alertFocusIndexAfterRemoval(requestedIndex, pageIds.size)
|
||||
} ?: return@LaunchedEffect
|
||||
runCatching { listState.scrollToItem(targetIndex) }
|
||||
delay(16)
|
||||
runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() }
|
||||
runCatching {
|
||||
val targets = if (wantsToggle) toggleFocusRequesters else rowFocusRequesters
|
||||
targets.getOrNull(targetIndex)?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
|
||||
@@ -149,69 +188,98 @@ fun MyAlertsPage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 56.dp)
|
||||
.padding(top = 40.dp, bottom = 28.dp),
|
||||
.padding(top = 32.dp, bottom = 24.dp),
|
||||
) {
|
||||
AlertsHeader(
|
||||
total = notifications.size,
|
||||
unread = notifications.count(UserNotification::unread),
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
AlertsHeader(total = notifications.size, unread = inbox.size)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().focusGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MembyChoiceChip(
|
||||
label = if (preferences.enabled) "Notifications on" else "Notifications off",
|
||||
selected = preferences.enabled,
|
||||
onClick = onToggleEnabled,
|
||||
modifier = Modifier.focusRequester(actionsFocusRequester),
|
||||
AlertsTab.entries.forEach { entry ->
|
||||
AlertsTabChip(
|
||||
tab = entry,
|
||||
selected = entry == tab,
|
||||
count = if (entry == AlertsTab.INBOX) inbox.size else seen.size,
|
||||
// Both anchors hang off the selected tab rather than off a fixed
|
||||
// index: it is where the page opens and where Back returns to.
|
||||
focusRequester = tabsFocusRequester.takeIf { entry == tab },
|
||||
// Only ever pointed at a row that is actually placed this frame.
|
||||
paneFocusRequester = rowFocusRequesters.firstOrNull(),
|
||||
onClick = {
|
||||
if (entry != tab) {
|
||||
page = 0
|
||||
pendingFocusIndex = null
|
||||
tab = entry
|
||||
}
|
||||
},
|
||||
)
|
||||
MembyChoiceChip(
|
||||
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
|
||||
selected = preferences.enabled && preferences.showReturnAlerts,
|
||||
onClick = { if (preferences.enabled) onToggleShowReturns() },
|
||||
)
|
||||
Spacer(Modifier.width(1.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (hasAlerts) {
|
||||
MembyChoiceChip(
|
||||
AlertsPillButton(
|
||||
label = "Dismiss all",
|
||||
selected = false,
|
||||
onClick = onDismissAll,
|
||||
icon = MembyIcon.PlaylistRemove.mark,
|
||||
// What this pane is showing, not the whole page: emptying Seen must
|
||||
// not take an unread Inbox with it.
|
||||
onClick = { onDismissAll(tabNotifications) },
|
||||
)
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry)
|
||||
AlertsPillButton(
|
||||
label = "Try again",
|
||||
icon = MembyIcon.Refresh.mark,
|
||||
onClick = onRetry,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
|
||||
AlertsPillButton(
|
||||
label = "Close",
|
||||
icon = MembyIcon.Close.mark,
|
||||
onClick = onClose,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
|
||||
if (loading && !hasAlerts) {
|
||||
if (loading && notifications.isEmpty()) {
|
||||
AlertsNotice("Loading notifications…")
|
||||
} else if (errorMessage != null && !hasAlerts) {
|
||||
} else if (errorMessage != null && notifications.isEmpty()) {
|
||||
AlertsNotice(errorMessage)
|
||||
} else if (!hasAlerts) {
|
||||
AlertsEmptyState(enabled = preferences.enabled)
|
||||
AlertsEmptyState(tab = tab, listening = preferences.enabled)
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 6.dp),
|
||||
) {
|
||||
itemsIndexed(notifications, key = { _, notification -> notification.id }) {
|
||||
itemsIndexed(pageNotifications, key = { _, notification -> notification.id }) {
|
||||
index, notification ->
|
||||
AlertRow(
|
||||
notification = notification,
|
||||
modifier = Modifier.focusRequester(rowFocusRequesters[index]),
|
||||
onFocused = { if (notification.unread) onRead(notification) },
|
||||
onClick = {
|
||||
focusRequester = rowFocusRequesters[index],
|
||||
toggleFocusRequester = toggleFocusRequesters[index],
|
||||
// Up out of the top row reaches the strip. Only the first row
|
||||
// states it; the rest are found by the ordinary focus search.
|
||||
upFocusRequester = tabsFocusRequester.takeIf { index == 0 },
|
||||
onToggleSeen = {
|
||||
// The row leaves this pane for the other one, so it needs the
|
||||
// same re-aim a dismissal does — landing on the toggle rather
|
||||
// than the body, or a run of "Mark as seen" presses would put
|
||||
// the remote on something that dismisses.
|
||||
pendingFocusIndex = index
|
||||
pendingFocusToggle = true
|
||||
pendingFocusPage = safePage
|
||||
onToggleSeen(notification)
|
||||
},
|
||||
onDismiss = {
|
||||
pendingFocusIndex = index
|
||||
pendingFocusToggle = false
|
||||
pendingFocusPage = safePage
|
||||
onDismiss(notification)
|
||||
},
|
||||
)
|
||||
if (notification.id != notifications.last().id) {
|
||||
if (notification.id != pageNotifications.last().id) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -222,9 +290,186 @@ 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
|
||||
@@ -238,33 +483,68 @@ private fun AlertsNotice(message: String) {
|
||||
internal fun alertFocusIndexAfterRemoval(removedIndex: Int, remainingCount: Int): Int? =
|
||||
if (remainingCount <= 0) null else removedIndex.coerceIn(0, remainingCount - 1)
|
||||
|
||||
/**
|
||||
* The marked heading My Requests wears, so the two pages a person opens about their own list
|
||||
* are recognisably the same page. The summary sits at the end of the row rather than under
|
||||
* the title: it is a caption for the whole page, and the strip below it already accounts for
|
||||
* each half.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertsHeader(total: Int, unread: Int) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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
|
||||
private fun AlertsEmptyState(enabled: Boolean) {
|
||||
private fun AlertsEmptyState(tab: AlertsTab, listening: Boolean) {
|
||||
val inbox = tab == AlertsTab.INBOX
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AlertsEmptyMark(listening = enabled)
|
||||
AlertsEmptyMark(listening = listening && inbox)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text("You’re all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (inbox) "You’re all caught up." else "Nothing marked as seen.",
|
||||
color = MembyMutedText,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
if (enabled) {
|
||||
"Notifications about the shows you follow will show up here."
|
||||
} else {
|
||||
"Notifications are switched off, so nothing new will arrive here."
|
||||
when {
|
||||
!inbox -> "Alerts you mark as seen wait here until you dismiss them."
|
||||
listening -> "Notifications about the shows you follow will show up here."
|
||||
else -> "Notifications are switched off for this profile, so nothing new will arrive here."
|
||||
},
|
||||
color = MembyQuietText,
|
||||
fontSize = 14.sp,
|
||||
@@ -399,39 +679,68 @@ private fun ringSwing(progress: Float): Float {
|
||||
return (kotlin.math.sin(phase * 3f * TWO_PI) * (1.0 - phase) * 5.0).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* One notification.
|
||||
*
|
||||
* The row holds **two** focus targets rather than one, and the split is what makes a seen
|
||||
* toggle possible at all on a remote with a single confirm key. The body keeps the press it
|
||||
* always had — OK dismisses, with the hint stated on the row about to go — and Right reaches
|
||||
* a toggle beside it. Down still moves to the next row from either, so the second target
|
||||
* costs nothing to somebody walking the list who never wants it.
|
||||
*
|
||||
* The lit surface belongs to the whole row, driven by `hasFocus` rather than `isFocused`, so
|
||||
* a row does not go dark the moment the remote steps sideways into its own toggle.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertRow(
|
||||
notification: UserNotification,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
toggleFocusRequester: FocusRequester,
|
||||
upFocusRequester: FocusRequester?,
|
||||
onToggleSeen: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
var rowHasFocus by remember { mutableStateOf(false) }
|
||||
var bodyFocused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) onFocused()
|
||||
}
|
||||
// Observer before the group it observes: `onFocusChanged` reports the state of
|
||||
// the focus target that follows it in the chain, so the two the other way round
|
||||
// leave the row's own lit surface permanently dark.
|
||||
.onFocusChanged { rowHasFocus = it.hasFocus }
|
||||
.focusProperties { if (upFocusRequester != null) up = upFocusRequester }
|
||||
.focusGroup()
|
||||
.clip(shape)
|
||||
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
|
||||
.background(if (rowHasFocus) Color.White.copy(alpha = 0.11f) else Color.Transparent)
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
|
||||
width = if (rowHasFocus) 2.dp else 1.dp,
|
||||
color = if (rowHasFocus) Color.White.copy(alpha = 0.88f) else Color.Transparent,
|
||||
shape = shape,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.semantics {
|
||||
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { bodyFocused = it.isFocused }
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.clickable(onClick = onDismiss)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
"${notification.title}. ${notification.message}. Press to dismiss."
|
||||
}
|
||||
.padding(horizontal = 6.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(38.dp).clip(CircleShape).background(
|
||||
Modifier.size(34.dp).clip(CircleShape).background(
|
||||
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -440,48 +749,45 @@ private fun AlertRow(
|
||||
alertIcon(notification.kind),
|
||||
contentDescription = null,
|
||||
tint = if (notification.unread) MembyAccent else MembyQuietText,
|
||||
modifier = Modifier.size(19.dp),
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.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 = 17.sp,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (notification.unread) {
|
||||
Text(
|
||||
"NEW",
|
||||
color = MembyAccent,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MembyAccent.copy(alpha = 0.14f))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
)
|
||||
// On the title's line rather than under the message: a page of four rows
|
||||
// has no line to spare for a date, and this is where the eye already is.
|
||||
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 11.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
notification.message,
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 13.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) {
|
||||
// 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(
|
||||
"OK to dismiss",
|
||||
color = Color.White,
|
||||
@@ -492,6 +798,18 @@ private fun AlertRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun alertIcon(kind: String): ImageVector = when {
|
||||
|
||||
@@ -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 {
|
||||
// The episode is already on the server, so "airing" would send someone to wait for
|
||||
// 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(
|
||||
session,
|
||||
inputData.getLong(POSITION_MS, 0L),
|
||||
inputData.getLong(DURATION_MS, 0L),
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { Result.success() },
|
||||
@@ -54,19 +55,32 @@ class PlaybackStopWorker(
|
||||
private const val PLAY_SESSION_ID = "play_session_id"
|
||||
private const val PLAY_METHOD = "play_method"
|
||||
private const val POSITION_MS = "position_ms"
|
||||
private const val DURATION_MS = "duration_ms"
|
||||
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
private fun workName(session: PlaybackSession): String =
|
||||
"emby-playback-stop-${session.playSessionId.ifBlank { session.itemId }}"
|
||||
|
||||
fun enqueue(context: Context, session: PlaybackSession, positionMs: Long) {
|
||||
/**
|
||||
* [durationMs] is the title's own length where the player knows it, and it is
|
||||
* carried for one reason: a stop past the end of a title is a completion, and a
|
||||
* completed title has no resume point to remember. Zero simply means the runtime
|
||||
* was not known, never that the title is zero long.
|
||||
*/
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
session: PlaybackSession,
|
||||
positionMs: Long,
|
||||
durationMs: Long = 0L,
|
||||
) {
|
||||
val data = Data.Builder()
|
||||
.putString(ITEM_ID, session.itemId)
|
||||
.putString(MEDIA_SOURCE_ID, session.mediaSourceId)
|
||||
.putString(PLAY_SESSION_ID, session.playSessionId)
|
||||
.putString(PLAY_METHOD, session.playMethod)
|
||||
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
|
||||
.putLong(DURATION_MS, durationMs.coerceAtLeast(0L))
|
||||
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
|
||||
@@ -81,7 +95,7 @@ class PlaybackStopWorker(
|
||||
// WorkManager is the process-death fallback, not the ordinary delivery path.
|
||||
// Send now from the repository's process scope, which survives Activity
|
||||
// destruction, then cancel this exact fallback request once Emby accepts it.
|
||||
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) {
|
||||
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs, durationMs) {
|
||||
workManager.cancelWorkById(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,21 +129,6 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
|
||||
else -> "${preference.codecs.size} formats"
|
||||
}
|
||||
|
||||
/** Episode wording for the station ident, separate from the logo/fallback presentation. */
|
||||
internal fun playbackIdentityEpisodeLabel(
|
||||
title: String,
|
||||
seriesName: String?,
|
||||
episodeCode: String?,
|
||||
): String? {
|
||||
val code = episodeCode?.trim().orEmpty()
|
||||
if (code.isEmpty()) return null
|
||||
val episodeTitle = title.trim()
|
||||
.removePrefix(seriesName?.trim().orEmpty() + " – ")
|
||||
.trim()
|
||||
.takeUnless { it.isEmpty() || it == seriesName?.trim() }
|
||||
return listOfNotNull(code, episodeTitle).joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen Media3 player with native stream-track selection. Press Menu while
|
||||
* playing to choose an audio or subtitle track; the subtitle controller button
|
||||
@@ -251,7 +236,19 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var nowPlayingGroup: View? = null
|
||||
private var playbackIdentityView: View? = null
|
||||
private var playbackIdentityHideJob: Job? = null
|
||||
private var playbackIdentityShown = false
|
||||
|
||||
/**
|
||||
* The ident is one-shot per programme, so its phase — never a boolean — is what decides
|
||||
* whether an arriving playback event may raise it. See [PlaybackIdentity].
|
||||
*/
|
||||
private var playbackIdentityPhase = PlaybackIdentityPhase.PENDING
|
||||
|
||||
/**
|
||||
* What the transport is doing, as media3 reports it. The ident and the transport's own
|
||||
* now-playing block occupy the same corner, so this is the input that keeps exactly one
|
||||
* of them on screen.
|
||||
*/
|
||||
private var transportVisible = false
|
||||
private var bufferingStartedAtMs: Long? = null
|
||||
private var totalBufferingMs = 0L
|
||||
private var bufferingCount = 0
|
||||
@@ -641,6 +638,15 @@ class PlayerActivity : ComponentActivity() {
|
||||
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
|
||||
}
|
||||
applySubtitleAppearance(view)
|
||||
// The ident and the transport's own now-playing block share the top-start corner,
|
||||
// so the player has to say which of them is on screen rather than each deciding for
|
||||
// itself. This is the only thing that reports the transport's real visibility —
|
||||
// media3 raises it for reasons the activity never hears about, auto_show among them.
|
||||
view.setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visibility ->
|
||||
onTransportVisibilityChanged(visibility == View.VISIBLE)
|
||||
},
|
||||
)
|
||||
playerView = view
|
||||
view.findViewById<View>(androidx.media3.ui.R.id.exo_subtitle)?.setOnClickListener {
|
||||
showSubtitleOverlay()
|
||||
@@ -873,6 +879,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
this@PlayerActivity,
|
||||
playbackSession(completedId),
|
||||
previewResumeDurationMs,
|
||||
previewResumeDurationMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1047,6 +1054,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
playMethod = playable.playMethod
|
||||
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
resetPlaybackIdentity()
|
||||
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
|
||||
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
|
||||
}
|
||||
@@ -2250,6 +2258,15 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds what the ident says. Never shows it: raising it belongs to
|
||||
* [showPlaybackIdentity] alone, so re-binding for a corrected title mid-launch — which
|
||||
* [adoptPlayable] does on every gateway launch — cannot open a second one.
|
||||
*
|
||||
* The logo and its text fallback are mutually exclusive and both start hidden, so the
|
||||
* corner is never briefly the series name *and* the series logo while Coil is still
|
||||
* fetching the artwork.
|
||||
*/
|
||||
private fun setUpPlaybackIdentity(
|
||||
title: String,
|
||||
seriesName: String?,
|
||||
@@ -2262,8 +2279,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
|
||||
}
|
||||
findViewById<TextView>(R.id.player_playback_identity_episode).apply {
|
||||
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty()
|
||||
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE
|
||||
val label = playbackIdentityEpisodeLabel(title, seriesName, episodeCode)
|
||||
text = label.orEmpty()
|
||||
visibility = if (label.isNullOrBlank()) View.GONE else View.VISIBLE
|
||||
}
|
||||
if (logoUrl.isNullOrBlank()) {
|
||||
logo.clearColorFilter()
|
||||
@@ -2272,6 +2290,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
fallback.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
fallback.visibility = View.GONE
|
||||
logo.load(logoUrl) {
|
||||
crossfade(false)
|
||||
listener(
|
||||
@@ -2288,10 +2307,54 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets that this programme has had its ident. Called wherever the subject of the
|
||||
* player changes underneath a session that never went back to the launcher — an episode
|
||||
* advance, a trailer resolving, a next-episode preview and the return from one — so the
|
||||
* incoming title gets its own ident and never inherits the outgoing title's.
|
||||
*/
|
||||
private fun resetPlaybackIdentity() {
|
||||
playbackIdentityPhase = PlaybackIdentityPhase.PENDING
|
||||
playbackIdentityHideJob?.cancel()
|
||||
playbackIdentityHideJob = null
|
||||
playbackIdentityView?.apply {
|
||||
animate().cancel()
|
||||
alpha = 0f
|
||||
visibility = View.GONE
|
||||
}
|
||||
applyIdentityRegion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises the ident, once, if the corner is actually free.
|
||||
*
|
||||
* Every path into playback reports "started" at least once and several report it more
|
||||
* than once, so the guard is the phase rather than the event; and the transport being up
|
||||
* means the identity is already on screen in its own block, so the ident stands down
|
||||
* rather than drawing a second copy of it four density pixels away.
|
||||
*/
|
||||
private fun showPlaybackIdentity() {
|
||||
if (playbackIdentityShown) return
|
||||
val identity = playbackIdentityView ?: return
|
||||
playbackIdentityShown = true
|
||||
val paused = pauseOverlay?.visibility == View.VISIBLE
|
||||
if (!shouldRaiseIdent(playbackIdentityPhase, transportVisible, paused)) {
|
||||
MembyDiagnostics.debug(
|
||||
"station_ident_withheld",
|
||||
"playback" to playSessionId,
|
||||
"item" to itemId,
|
||||
"phase" to playbackIdentityPhase.name,
|
||||
"transport_visible" to transportVisible,
|
||||
"paused" to paused,
|
||||
)
|
||||
// Whatever is on screen is already saying it. Spend the ident here rather than
|
||||
// leaving it armed to appear when the controls time out, seconds into the
|
||||
// programme.
|
||||
if (playbackIdentityPhase == PlaybackIdentityPhase.PENDING) {
|
||||
playbackIdentityPhase = PlaybackIdentityPhase.DONE
|
||||
}
|
||||
return
|
||||
}
|
||||
playbackIdentityPhase = PlaybackIdentityPhase.SHOWING
|
||||
applyIdentityRegion()
|
||||
playbackIdentityHideJob?.cancel()
|
||||
identity.animate().cancel()
|
||||
identity.alpha = 0f
|
||||
@@ -2300,14 +2363,75 @@ class PlayerActivity : ComponentActivity() {
|
||||
.alpha(1f)
|
||||
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
|
||||
.start()
|
||||
MembyDiagnostics.info(
|
||||
"station_ident_shown",
|
||||
"playback" to playSessionId,
|
||||
"item" to itemId,
|
||||
"visible_ms" to PLAYBACK_IDENTITY_VISIBLE_MS,
|
||||
)
|
||||
playbackIdentityHideJob = lifecycleScope.launch {
|
||||
delay(PLAYBACK_IDENTITY_VISIBLE_MS - PLAYBACK_IDENTITY_FADE_MS)
|
||||
identity.animate()
|
||||
dismissPlaybackIdentity("elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the ident's turn. It never comes back for this programme: the identity is an
|
||||
* opening announcement, and one that reappeared when the transport timed out would be a
|
||||
* second ident for a title already minutes in.
|
||||
*/
|
||||
private fun dismissPlaybackIdentity(reason: String) {
|
||||
playbackIdentityHideJob?.cancel()
|
||||
playbackIdentityHideJob = null
|
||||
val phase = playbackIdentityPhase
|
||||
playbackIdentityPhase = PlaybackIdentityPhase.DONE
|
||||
if (phase != PlaybackIdentityPhase.SHOWING) {
|
||||
applyIdentityRegion()
|
||||
return
|
||||
}
|
||||
MembyDiagnostics.debug(
|
||||
"station_ident_dismissed",
|
||||
"playback" to playSessionId,
|
||||
"item" to itemId,
|
||||
"reason" to reason,
|
||||
)
|
||||
playbackIdentityView?.apply {
|
||||
animate().cancel()
|
||||
animate()
|
||||
.alpha(0f)
|
||||
.setDuration(PLAYBACK_IDENTITY_FADE_MS)
|
||||
.withEndAction { identity.visibility = View.GONE }
|
||||
.withEndAction { visibility = View.GONE }
|
||||
.start()
|
||||
}
|
||||
applyIdentityRegion()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the top-start corner to whichever surface owns it right now.
|
||||
*
|
||||
* The ident and the transport's own now-playing block are the same information in the
|
||||
* same place, and this is the one place that decides between them — the defect this
|
||||
* replaces was each of them deciding for itself.
|
||||
*/
|
||||
private fun applyIdentityRegion() {
|
||||
val slot = playerIdentitySlot(
|
||||
identWindowOpen = playbackIdentityPhase == PlaybackIdentityPhase.SHOWING,
|
||||
transportVisible = transportVisible,
|
||||
paused = pauseOverlay?.visibility == View.VISIBLE,
|
||||
)
|
||||
nowPlayingGroup?.visibility =
|
||||
if (slot == PlayerIdentitySlot.TRANSPORT) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 tells us when the transport comes and goes. The transport appearing is what
|
||||
* ends the ident — the alternative rules (moving it, fading it, letting the transport
|
||||
* draw over it) all leave two answers to "what is playing" on screen at once.
|
||||
*/
|
||||
private fun onTransportVisibilityChanged(visible: Boolean) {
|
||||
if (transportVisible == visible) return
|
||||
transportVisible = visible
|
||||
if (visible) dismissPlaybackIdentity("transport_shown") else applyIdentityRegion()
|
||||
}
|
||||
|
||||
private fun updatePlaybackTiming(playback: Player) {
|
||||
@@ -3428,6 +3552,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
pausePosterUrl = next.imageUrl
|
||||
pauseOverview = next.overview
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
resetPlaybackIdentity()
|
||||
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
|
||||
renderedFirstFrame = false
|
||||
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
|
||||
@@ -3469,6 +3594,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
playbackStarted = previewResumePlaybackStarted
|
||||
stopReported = previewResumeStopReported
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
resetPlaybackIdentity()
|
||||
setUpPlaybackIdentity(
|
||||
playbackTitle,
|
||||
playbackSeriesName,
|
||||
@@ -3958,6 +4084,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
this,
|
||||
playbackSession(completedItemId),
|
||||
playback?.currentPosition ?: 0L,
|
||||
knownDurationMs(playback),
|
||||
)
|
||||
}
|
||||
finish()
|
||||
@@ -4065,6 +4192,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
this,
|
||||
playbackSession(previousId),
|
||||
playback?.currentPosition ?: 0L,
|
||||
knownDurationMs(playback),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4100,13 +4228,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
itemName = nextTitle(next),
|
||||
itemType = "Episode",
|
||||
)
|
||||
playbackIdentityShown = false
|
||||
playbackIdentityHideJob?.cancel()
|
||||
playbackIdentityView?.apply {
|
||||
animate().cancel()
|
||||
alpha = 0f
|
||||
visibility = View.GONE
|
||||
}
|
||||
// The incoming episode gets its own ident; nothing of the outgoing one's is left
|
||||
// armed, showing, or counted as already spent.
|
||||
resetPlaybackIdentity()
|
||||
initialResumePositionMs = next.resumePositionMs.coerceAtLeast(0L)
|
||||
renderedFirstFrame = false
|
||||
automaticRetryAttempt = 0
|
||||
@@ -4198,6 +4322,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
private fun bindPauseOverlay(view: PlayerView) {
|
||||
pauseOverlay = view.findViewById(R.id.player_pause_overlay)
|
||||
nowPlayingGroup = view.findViewById(R.id.player_now_playing_group)
|
||||
// The group is visible in the layout, so state it here too: nothing else runs before
|
||||
// the transport is first raised, and the ident's five seconds are inside that window.
|
||||
applyIdentityRegion()
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.apply {
|
||||
text = pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
||||
@@ -4217,7 +4344,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
val paused = playbackStarted && !prerollActive &&
|
||||
playback.playbackState == Player.STATE_READY && !playback.isPlaying
|
||||
pauseOverlay?.visibility = if (paused) View.VISIBLE else View.GONE
|
||||
nowPlayingGroup?.visibility = if (paused) View.GONE else View.VISIBLE
|
||||
// Pausing during the ident hands the corner to the pause overlay, which carries the
|
||||
// poster, the title and the synopsis: an ident over the top of that is the same
|
||||
// programme announced twice, in two type sizes, in overlapping space.
|
||||
if (paused) dismissPlaybackIdentity("paused")
|
||||
applyIdentityRegion()
|
||||
if (paused) playerView?.showController()
|
||||
}
|
||||
|
||||
@@ -5271,7 +5402,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
stopReported = true
|
||||
stoppedInBackground = true
|
||||
itemId?.takeIf(String::isNotBlank)?.let { id ->
|
||||
PlaybackStopWorker.enqueue(this, playbackSession(id), it.currentPosition)
|
||||
PlaybackStopWorker.enqueue(
|
||||
this,
|
||||
playbackSession(id),
|
||||
it.currentPosition,
|
||||
knownDurationMs(it),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5327,6 +5463,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
this,
|
||||
playbackSession(itemId!!),
|
||||
playback?.currentPosition ?: 0L,
|
||||
knownDurationMs(playback),
|
||||
)
|
||||
}
|
||||
playerView?.player = null
|
||||
@@ -5431,7 +5568,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
if (changed && playbackStarted && oldSession != null) {
|
||||
stopProgressUploading()
|
||||
PlaybackStopWorker.enqueue(this, oldSession, positionMs)
|
||||
PlaybackStopWorker.enqueue(this, oldSession, positionMs, knownDurationMs())
|
||||
playbackStarted = false
|
||||
stopReported = false
|
||||
stoppedInBackground = false
|
||||
@@ -5442,6 +5579,15 @@ class PlayerActivity : ComponentActivity() {
|
||||
playMethod = newPlayMethod.ifBlank { "DirectPlay" }
|
||||
}
|
||||
|
||||
/**
|
||||
* The title's own length, or zero where media3 does not yet know it. Carried with every
|
||||
* stop so the resume ledger can tell a title somebody left part-way through from one
|
||||
* they finished — a completed title has its position reset, and remembering a playhead
|
||||
* for it would drop the next viewing into the closing minutes.
|
||||
*/
|
||||
private fun knownDurationMs(playback: Player? = player): Long =
|
||||
playback?.duration?.takeIf { it != C.TIME_UNSET && it > 0L } ?: 0L
|
||||
|
||||
private fun playbackSession(id: String) = PlaybackSession(
|
||||
itemId = id,
|
||||
mediaSourceId = mediaSourceId.ifBlank { id },
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.composables.icons.fontawesome.solid.Heart
|
||||
import com.composables.icons.fontawesome.solid.Home
|
||||
import com.composables.icons.fontawesome.solid.Image
|
||||
import com.composables.icons.fontawesome.solid.Inbox
|
||||
import com.composables.icons.fontawesome.solid.Sync
|
||||
import com.composables.icons.fontawesome.solid.InfoCircle
|
||||
import com.composables.icons.fontawesome.solid.Magic
|
||||
import com.composables.icons.fontawesome.solid.Medal
|
||||
@@ -98,6 +99,7 @@ internal val fontAwesomeIconPack = MembyIconPack(
|
||||
MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble },
|
||||
MembyIcon.Add to { FontAwesome.Solid.Plus },
|
||||
MembyIcon.Close to { FontAwesome.Solid.Times },
|
||||
MembyIcon.Refresh to { FontAwesome.Solid.Sync },
|
||||
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
|
||||
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.composables.icons.lucide.Heart
|
||||
import com.composables.icons.lucide.House
|
||||
import com.composables.icons.lucide.ImageOff
|
||||
import com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.RefreshCw
|
||||
import com.composables.icons.lucide.Info
|
||||
import com.composables.icons.lucide.LayoutGrid
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
@@ -98,6 +99,7 @@ internal val lucideIconPack = MembyIconPack(
|
||||
MembyIcon.Check to { Lucide.Check },
|
||||
MembyIcon.Add to { Lucide.Plus },
|
||||
MembyIcon.Close to { Lucide.X },
|
||||
MembyIcon.Refresh to { Lucide.RefreshCw },
|
||||
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { Lucide.ChevronRight },
|
||||
MembyIcon.ChevronDown to { Lucide.ChevronDown },
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Landscape
|
||||
@@ -116,6 +117,7 @@ object MaterialIconPack {
|
||||
MembyIcon.CheckAll to { Icons.Default.DoneAll },
|
||||
MembyIcon.Add to { Icons.Default.Add },
|
||||
MembyIcon.Close to { Icons.Default.Close },
|
||||
MembyIcon.Refresh to { Icons.Default.Refresh },
|
||||
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
|
||||
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
|
||||
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
|
||||
|
||||
@@ -51,6 +51,7 @@ enum class MembyIcon {
|
||||
CheckAll,
|
||||
Add,
|
||||
Close,
|
||||
Refresh,
|
||||
|
||||
// Movement
|
||||
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"?>
|
||||
<!-- A short, non-focusable station ident shown over the first five seconds of content.
|
||||
For television, the programme logo leads and the episode sits directly beneath it. -->
|
||||
<!-- The station ident: a short, non-focusable identity treatment over the opening seconds
|
||||
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"
|
||||
android:id="@+id/player_playback_identity"
|
||||
android:layout_width="560dp"
|
||||
@@ -14,10 +21,16 @@
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/player_playback_identity_mark"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="82dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_playback_identity_logo"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="82dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start|bottom"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@string/player_title_logo"
|
||||
android:scaleType="fitStart"
|
||||
@@ -27,6 +40,7 @@
|
||||
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"
|
||||
@@ -36,21 +50,25 @@
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold" />
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_playback_identity_episode"
|
||||
android:layout_width="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:letterSpacing="0.02"
|
||||
android:maxLines="1"
|
||||
android:maxWidth="540dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#E6FFFFFF"
|
||||
android:textSize="19sp"
|
||||
android:maxWidth="500dp"
|
||||
android:paddingStart="11dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingEnd="11dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.ponzischeme89.memby.data.model.GatewayTrailerPlayback
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -453,6 +454,29 @@ class GatewayPayloadTest {
|
||||
assertEquals("incinemas", item.membyLifecycle)
|
||||
assertEquals("IN CINEMAS", item.membyLifecycleText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
// No Emby id: the household has no copy, which is what sends this card to the
|
||||
// Radarr-only page rather than to an ordinary movie one.
|
||||
assertTrue(item.isRadarrOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Radarr card names the Emby film once the library holds it`() {
|
||||
val payload = """
|
||||
{
|
||||
"Id":"radarr:7",
|
||||
"Name":"Arrival",
|
||||
"Type":"MembyRadarrMovie",
|
||||
"MembySource":"radarr",
|
||||
"MembyPlayable":false,
|
||||
"MembyMovieItemId":"emby-4821"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val item = json.decodeFromString<BaseItem>(payload)
|
||||
|
||||
assertEquals("emby-4821", item.membyMovieItemId)
|
||||
assertTrue(item.isMovieSchedule)
|
||||
assertFalse(item.isRadarrOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -19,6 +19,7 @@ class UserPreferencesTest {
|
||||
fun `encoding and decoding is a fixed point`() {
|
||||
val original = UserPreferences(
|
||||
profileInitials = "MC",
|
||||
shortName = "Matt",
|
||||
homeSections = listOf("latest", "continue"),
|
||||
homeCardDensity = "large",
|
||||
homeArtworkStyle = "poster",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.LOCAL_RESUME_MAX_AGE_MS
|
||||
import com.ponzischeme89.memby.data.isFreshLocalResume
|
||||
import com.ponzischeme89.memby.data.launchResumePositionMs
|
||||
import com.ponzischeme89.memby.data.playbackCompletesItem
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ContinueWatchingResumeTest {
|
||||
@@ -41,4 +46,81 @@ class ContinueWatchingResumeTest {
|
||||
launchResumePositionMs(resolvedPositionMs = 42_000L, requestedPositionMs = 0L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the position the player left at outranks a card the refresh has not reached yet`() {
|
||||
// The reported defect: twenty seconds watched from 10:00, exit, press Play again
|
||||
// before Continue Watching has been refreshed. Both of the other answers still
|
||||
// describe the launch before this one.
|
||||
assertEquals(
|
||||
620_000L,
|
||||
launchResumePositionMs(
|
||||
resolvedPositionMs = 600_000L,
|
||||
requestedPositionMs = 600_000L,
|
||||
localPositionMs = 620_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refreshed card retires the local record by catching up with it`() {
|
||||
// No bookkeeping retires the ledger; being outranked does. A card carrying the
|
||||
// recorded position, or a later one watched on another set, simply wins.
|
||||
assertEquals(
|
||||
620_000L,
|
||||
launchResumePositionMs(
|
||||
resolvedPositionMs = 0L,
|
||||
requestedPositionMs = 620_000L,
|
||||
localPositionMs = 620_000L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
900_000L,
|
||||
launchResumePositionMs(
|
||||
resolvedPositionMs = 0L,
|
||||
requestedPositionMs = 900_000L,
|
||||
localPositionMs = 620_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing known locally the launch is unchanged`() {
|
||||
assertEquals(
|
||||
36_000L,
|
||||
launchResumePositionMs(
|
||||
resolvedPositionMs = 0L,
|
||||
requestedPositionMs = 36_000L,
|
||||
localPositionMs = 0L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
0L,
|
||||
launchResumePositionMs(
|
||||
resolvedPositionMs = -1L,
|
||||
requestedPositionMs = 0L,
|
||||
localPositionMs = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished title has no resume point to remember`() {
|
||||
val runtime = 45L * 60L * 1_000L
|
||||
assertTrue(playbackCompletesItem(positionMs = runtime - 60_000L, durationMs = runtime))
|
||||
assertFalse(playbackCompletesItem(positionMs = 20_000L, durationMs = runtime))
|
||||
// Zero means the runtime was not known, never that the title is zero long.
|
||||
assertFalse(playbackCompletesItem(positionMs = runtime, durationMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local record is trusted for hours but not indefinitely`() {
|
||||
val recordedAt = 1_000_000L
|
||||
assertTrue(isFreshLocalResume(recordedAt, recordedAt + 60_000L))
|
||||
assertTrue(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS))
|
||||
assertFalse(isFreshLocalResume(recordedAt, recordedAt + LOCAL_RESUME_MAX_AGE_MS + 1L))
|
||||
// A clock that moved backwards is no evidence at all.
|
||||
assertFalse(isFreshLocalResume(recordedAt, recordedAt - 1L))
|
||||
assertFalse(isFreshLocalResume(0L, recordedAt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,25 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeGreetingTest {
|
||||
@Test
|
||||
fun `short name is preferred and falls back to the account name`() {
|
||||
assertEquals("Matt", greetingName("Matt", "MattCohen"))
|
||||
// The account-name reading is unchanged for a household that has never set one,
|
||||
// and blank or whitespace is the ordinary state rather than a name.
|
||||
assertEquals("MattCohen", greetingName(null, "MattCohen"))
|
||||
assertEquals("MattCohen", greetingName("", "MattCohen"))
|
||||
assertEquals("Peter", greetingName(" ", "PeterC"))
|
||||
// A short name stands on its own where there is no account name to fall back to.
|
||||
assertEquals("Matt", greetingName(" Matt ", null))
|
||||
assertNull(greetingName(null, null))
|
||||
assertNull(greetingName("", " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time of day selects the expected greeting`() {
|
||||
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4))
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
@@ -38,4 +39,66 @@ class AlertsFormatTest {
|
||||
assertEquals("4 notifications · 2 new", alertsSummary(total = 4, unread = 2))
|
||||
assertEquals("1 notification · 1 new", alertsSummary(total = 1, unread = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two tabs are the read flag and nothing else`() {
|
||||
val notifications = listOf(
|
||||
notification(id = 1, readAt = null),
|
||||
notification(id = 2, readAt = "2026-08-06T09:00:00Z"),
|
||||
notification(id = 3, readAt = null),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(1L, 3L),
|
||||
alertsForTab(AlertsTab.INBOX, notifications).map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(2L),
|
||||
alertsForTab(AlertsTab.SEEN, notifications).map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
/** Every alert is in exactly one half, or the counts on the strip could not add up. */
|
||||
@Test
|
||||
fun `every alert lands in one half`() {
|
||||
val notifications = (1..7).map {
|
||||
notification(id = it.toLong(), readAt = if (it % 2 == 0) "2026-08-06T09:00:00Z" else null)
|
||||
}
|
||||
val inbox = alertsForTab(AlertsTab.INBOX, notifications)
|
||||
val seen = alertsForTab(AlertsTab.SEEN, notifications)
|
||||
assertEquals(notifications.size, inbox.size + seen.size)
|
||||
assertEquals(emptyList<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
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
@@ -40,18 +42,79 @@ class AlertsPageScreenshotTest {
|
||||
capture("my-alerts-populated", sampleAlerts)
|
||||
}
|
||||
|
||||
/** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */
|
||||
/**
|
||||
* Everything already read, which on this page means an empty Inbox — the pane the page
|
||||
* opens on. The capture is the check that the strip still says where the three alerts
|
||||
* went: an empty half beside a Seen tab reading 3 is good news, where an empty page with
|
||||
* no counts on it reads as a list that lost them.
|
||||
*/
|
||||
@Test
|
||||
fun `everything already read`() {
|
||||
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") })
|
||||
}
|
||||
|
||||
/**
|
||||
* More alerts than a page holds, which is what puts the pager on screen. The capture is
|
||||
* the check that four rows and the pager under them fit the 540dp a television has —
|
||||
* a page whose last row is below the fold would be one somebody has to scroll *and*
|
||||
* page through.
|
||||
*/
|
||||
@Test
|
||||
fun `paged`() {
|
||||
capture("my-alerts-paged", manyAlerts)
|
||||
}
|
||||
|
||||
/**
|
||||
* One alert past a full page. The capture opens on page one, so what it shows is the
|
||||
* pager appearing for a list barely long enough to need it — the case where a pager
|
||||
* that took a row's worth of height would not have earned it.
|
||||
*/
|
||||
@Test
|
||||
fun `just past one page`() {
|
||||
capture("my-alerts-paged-shallow", manyAlerts.take(AlertsPageSize + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* A full page of the tallest row this page can draw — every message wrapping onto a
|
||||
* second line. This is the capture the four-rows-to-a-page figure is answerable to: the
|
||||
* pager is anchored to the bottom edge, so the thing to look at is whether the last row
|
||||
* still clears it.
|
||||
*/
|
||||
@Test
|
||||
fun `a crowded page`() {
|
||||
capture(
|
||||
"my-alerts-paged-crowded",
|
||||
manyAlerts.take(AlertsPageSize + 2).map {
|
||||
it.copy(
|
||||
message = "Season 4 of this show returns on Thursday, and the first two " +
|
||||
"episodes will be in Emby that morning if the download lands.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half, reached the way a viewer reaches it — by pressing the tab. The strip is
|
||||
* the only thing on the page saying which half is open, so a capture that set the pane
|
||||
* some other way would not be a picture of what a television shows.
|
||||
*/
|
||||
@Test
|
||||
fun `the seen half`() {
|
||||
capture("my-alerts-seen", manyAlerts) {
|
||||
compose.onNodeWithContentDescription("Seen, 3").performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing waiting`() {
|
||||
capture("my-alerts-empty", emptyList())
|
||||
}
|
||||
|
||||
/** Alerts switched off has its own empty wording — and both toggles read as off. */
|
||||
/**
|
||||
* Notifications switched off for the profile. The page has no switch for it any more, so
|
||||
* this is the capture that the empty state still explains why nothing is arriving — the
|
||||
* only place a viewer can now be told.
|
||||
*/
|
||||
@Test
|
||||
fun `alerts switched off`() {
|
||||
capture(
|
||||
@@ -125,24 +188,37 @@ class AlertsPageScreenshotTest {
|
||||
name: String,
|
||||
notifications: List<UserNotification>,
|
||||
preferences: NotificationPreferences = NotificationPreferences(),
|
||||
act: () -> Unit = {},
|
||||
) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
MyAlertsPage(
|
||||
notifications = notifications,
|
||||
preferences = preferences,
|
||||
onToggleEnabled = {},
|
||||
onToggleShowReturns = {},
|
||||
onRead = {},
|
||||
onDismiss = {},
|
||||
onDismissAll = {},
|
||||
onClose = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
act()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* Eleven alerts: three pages, the last of them part-filled. Built from the samples so the
|
||||
* paged captures and the single-page ones cannot drift into looking like different pages.
|
||||
*/
|
||||
private val manyAlerts: List<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(
|
||||
UserNotification(
|
||||
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_episode).apply {
|
||||
text = "S02E04 · The Other You"
|
||||
text = "S02E04 — The Other You"
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
root.addView(identity)
|
||||
@@ -100,6 +100,60 @@ class PlaybackIdentityScreenshotTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a film shows its logo and nothing beneath it`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = backdropRoot(activity)
|
||||
val identity = LayoutInflater.from(activity)
|
||||
.inflate(R.layout.player_playback_identity, root, false)
|
||||
.apply {
|
||||
visibility = View.VISIBLE
|
||||
alpha = 1f
|
||||
}
|
||||
identity.findViewById<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
|
||||
fun `loading keeps the selected backdrop visible`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
@@ -120,7 +174,7 @@ class PlaybackIdentityScreenshotTest {
|
||||
@Test
|
||||
fun `episode ident separates the series from the episode`() {
|
||||
assertEquals(
|
||||
"S02E04 · The Other You",
|
||||
"S02E04 — The Other You",
|
||||
playbackIdentityEpisodeLabel(
|
||||
title = "Dark Matter – The Other You",
|
||||
seriesName = "Dark Matter",
|
||||
@@ -130,6 +184,95 @@ class PlaybackIdentityScreenshotTest {
|
||||
assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode named after its show is announced by its code alone`() {
|
||||
assertEquals(
|
||||
"S01E01",
|
||||
playbackIdentityEpisodeLabel(
|
||||
title = "Dark Matter",
|
||||
seriesName = "Dark Matter",
|
||||
episodeCode = "s01e01",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long episode title survives to the view, which ellipsises it`() {
|
||||
val label = playbackIdentityEpisodeLabel(
|
||||
title = "The One Where They All Go To A Wedding And Nobody Says Anything",
|
||||
seriesName = "Friends",
|
||||
episodeCode = "S04E08",
|
||||
)
|
||||
assertEquals(
|
||||
"S04E08 — The One Where They All Go To A Wedding And Nobody Says Anything",
|
||||
label,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the transport owns the corner while it is up, and pause owns it outright`() {
|
||||
// The ident and the transport's now-playing block are the same information in the
|
||||
// same place: exactly one of them may draw.
|
||||
assertEquals(
|
||||
PlayerIdentitySlot.IDENT,
|
||||
playerIdentitySlot(identWindowOpen = true, transportVisible = false, paused = false),
|
||||
)
|
||||
assertEquals(
|
||||
PlayerIdentitySlot.TRANSPORT,
|
||||
playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = false),
|
||||
)
|
||||
assertEquals(
|
||||
PlayerIdentitySlot.NONE,
|
||||
playerIdentitySlot(identWindowOpen = true, transportVisible = true, paused = true),
|
||||
)
|
||||
assertEquals(
|
||||
PlayerIdentitySlot.NONE,
|
||||
playerIdentitySlot(identWindowOpen = false, transportVisible = false, paused = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ident opens once and is never raised a second time`() {
|
||||
assertTrue(
|
||||
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = false),
|
||||
)
|
||||
// A re-prepare, a recovery retry or a second report of "playback started" arrives
|
||||
// with the ident already spent, and must not open another.
|
||||
assertFalse(
|
||||
shouldRaiseIdent(PlaybackIdentityPhase.SHOWING, transportVisible = false, paused = false),
|
||||
)
|
||||
assertFalse(
|
||||
shouldRaiseIdent(PlaybackIdentityPhase.DONE, transportVisible = false, paused = false),
|
||||
)
|
||||
// And it is withheld outright where something else already answers the question.
|
||||
assertFalse(
|
||||
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = true, paused = false),
|
||||
)
|
||||
assertFalse(
|
||||
shouldRaiseIdent(PlaybackIdentityPhase.PENDING, transportVisible = false, paused = true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun backdropRoot(activity: Activity): FrameLayout {
|
||||
val root = FrameLayout(activity)
|
||||
val backdrop = ImageView(activity).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageBitmap(
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream),
|
||||
)
|
||||
}
|
||||
root.addView(
|
||||
backdrop,
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.rgb(82, 181, 75))
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
@@ -262,7 +263,12 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
// takes all three: a handler that could not publish would have to check for nil at
|
||||
// every call site, which is exactly how an event comes to be silently dropped.
|
||||
adminBus := adminevents.New(st, log)
|
||||
dispatcher := integrations.New(st, log, adminBus)
|
||||
// The notification service is built before both the dispatcher and the server, because
|
||||
// both write into it: the dispatcher records what it posted to Discord, and the server
|
||||
// registers the in-app and broadcast providers on it. The store is its recorder, which
|
||||
// is the whole audit trail.
|
||||
notifier := notify.New(st, log)
|
||||
dispatcher := integrations.New(st, log, adminBus, notifier)
|
||||
adminBus.AddSink(dispatcher)
|
||||
sched := scheduler.New(st, log, adminBus)
|
||||
|
||||
@@ -286,6 +292,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
AdminEvents: adminBus,
|
||||
Scheduler: sched,
|
||||
Integrations: dispatcher,
|
||||
Notify: notifier,
|
||||
LogLevel: logLevel,
|
||||
})
|
||||
if err := server.LoadQuietTime(ctx); err != nil {
|
||||
|
||||
@@ -88,6 +88,11 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices))
|
||||
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail))
|
||||
|
||||
// The outbound notification history. Distinct from the feed below it: that is the
|
||||
// operator's own activity bell, this is the record of what Memby sent to viewers and
|
||||
// to external services.
|
||||
mux.Handle("GET /admin/api/notification-log", s.adminAuth(s.handleAdminNotificationLog))
|
||||
|
||||
// The administrative feed behind the notification bell.
|
||||
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
|
||||
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
|
||||
|
||||
@@ -35,6 +35,10 @@ type adminMembyAccount struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Initials string `json:"initials"`
|
||||
// ShortName is the friendly name the launcher greets this person by, and is blank far
|
||||
// more often than not — the directory reads it as "their account name" rather than as
|
||||
// something missing.
|
||||
ShortName string `json:"shortName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Devices []store.MembyDevice `json:"devices"`
|
||||
@@ -154,6 +158,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
WatchTime: summariseWatchTime(watched, matchedWatchTime),
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
ShortName: stringPreference(accountSettings.Preferences, "shortName"),
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Notifications: notificationPrefs,
|
||||
|
||||
@@ -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
|
||||
// produced it — an import, a library sync, a health probe — none of which the viewer
|
||||
// would want retried for the sake of a notice.
|
||||
//
|
||||
// Producers no longer call this directly: they call Server.broadcast, which carries the
|
||||
// same alert through the notification service so it lands in the audit trail beside every
|
||||
// other thing Memby sent. This remains the delivery half of that path.
|
||||
func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) {
|
||||
if err := s.publishAlertNow(ctx, alert, window); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert publish failed", "id", alert.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishAlertNow is publishAlert with the failure returned rather than swallowed.
|
||||
//
|
||||
// The notification log needs the error — a banner nobody received is exactly the row an
|
||||
// operator opens this feature to find — and swallowing it here would leave the audit trail
|
||||
// reporting a success the cache never gave. Everything above still treats the answer as
|
||||
// advisory; nothing retries on it.
|
||||
func (s *Server) publishAlertNow(ctx context.Context, alert clientAlert, window time.Duration) error {
|
||||
if window <= 0 || alert.ID == "" || s.cache == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
// Read-modify-write on one key, so producers running on their own schedules need
|
||||
// serialising against each other. They are rare enough that a mutex is the whole
|
||||
@@ -113,14 +129,14 @@ func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window tim
|
||||
stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now)
|
||||
body, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("alert encode failed", "error", err)
|
||||
return
|
||||
return fmt.Errorf("alert encode failed: %w", err)
|
||||
}
|
||||
// The key's own TTL is a floor sweep for a gateway that stops producing events; the
|
||||
// per-entry expiry is what actually decides what a client sees.
|
||||
if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil {
|
||||
s.loggerFor(ctx).Warn("alert store failed", "error", err)
|
||||
return fmt.Errorf("alert store failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendAlert is the pure half of publishing: prune what has expired, replace any earlier
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/integrations"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
@@ -71,6 +72,10 @@ type Server struct {
|
||||
adminEvents *adminevents.Bus
|
||||
scheduler *scheduler.Scheduler
|
||||
integrations *integrations.Dispatcher
|
||||
// notify is the one door every outbound notification leaves through, and the only
|
||||
// thing that writes the notification log. Producers never call a delivery provider
|
||||
// directly any more — see internal/api/notifications.go.
|
||||
notify *notify.Service
|
||||
sonarrMu sync.Mutex
|
||||
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
|
||||
// to My Shows never waits behind a launcher rebuilding the schedule row.
|
||||
@@ -143,6 +148,10 @@ type Deps struct {
|
||||
AdminEvents *adminevents.Bus
|
||||
Scheduler *scheduler.Scheduler
|
||||
Integrations *integrations.Dispatcher
|
||||
// Notify is optional. A server built without one still delivers every notification —
|
||||
// Send falls through to the providers regardless — it simply records nothing, which is
|
||||
// what every unit test in this package wants.
|
||||
Notify *notify.Service
|
||||
// LogLevel is the live level of the process's own logger, so the console can turn
|
||||
// debug on and watch the thing it turned it on for. Nil is allowed and means the
|
||||
// level is fixed at whatever the container was started with.
|
||||
@@ -150,7 +159,7 @@ type Deps struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, deps Deps) *Server {
|
||||
return &Server{
|
||||
server := &Server{
|
||||
cfg: cfg,
|
||||
emby: deps.Emby,
|
||||
store: deps.Store,
|
||||
@@ -172,9 +181,16 @@ func New(cfg config.Config, deps Deps) *Server {
|
||||
scheduler: deps.Scheduler,
|
||||
integrations: deps.Integrations,
|
||||
|
||||
notify: deps.Notify,
|
||||
|
||||
logLevel: deps.LogLevel,
|
||||
deployedLogLevel: deployedLevel(deps.LogLevel),
|
||||
}
|
||||
// The providers are installed here rather than by the caller so a producer can assume
|
||||
// the channels it uses exist: a channel with no provider is a configuration fault the
|
||||
// audit trail would faithfully record on every single notification.
|
||||
server.registerNotifiers()
|
||||
return server
|
||||
}
|
||||
|
||||
func deployedLevel(level *slog.LevelVar) slog.Level {
|
||||
@@ -292,6 +308,7 @@ func (s *Server) Routes() http.Handler {
|
||||
// token arrives in the query string, the way artwork's does, because a media player
|
||||
// fetching a sidecar sends none of Memby's headers.
|
||||
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
|
||||
v1.Handle("GET /v1/radarr/movies/{id}", s.authed(s.handleRadarrMovie))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
||||
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
||||
|
||||
@@ -142,7 +142,7 @@ var featureCatalogue = []featureDefinition{
|
||||
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
|
||||
Description: "Tell each viewer how long they watched this week and this month, on " +
|
||||
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
|
||||
"Tracearr; a household running none never sends one.",
|
||||
"Tracearr; a server running none never sends one.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1,
|
||||
Recovery: "Server-enforced; takes effect before the next summary is due.",
|
||||
},
|
||||
|
||||
@@ -51,6 +51,22 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
||||
},
|
||||
})
|
||||
|
||||
// The outbound notification history, which is a different table from the one above:
|
||||
// that prunes the operator's activity feed, this prunes the record of what Memby sent
|
||||
// to viewers and to external services.
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "notification-log-retention",
|
||||
Name: "Notification history retention",
|
||||
Group: "Housekeeping",
|
||||
Description: fmt.Sprintf("Removes outbound notification records older than %d days.",
|
||||
int(store.NotificationRetention/(24*time.Hour))),
|
||||
Interval: 24 * time.Hour,
|
||||
Run: func(ctx context.Context) (string, error) {
|
||||
removed, err := s.store.PruneNotificationLog(ctx, store.NotificationRetention)
|
||||
return countDetail(removed, "notification record"), err
|
||||
},
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "device-activity-cleanup",
|
||||
Name: "Device activity cleanup",
|
||||
|
||||
@@ -57,10 +57,14 @@ func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.Inges
|
||||
// episode. Neither is a title somebody can watch, and the episode that follows is.
|
||||
}
|
||||
|
||||
// The zero-window case is deliberately not short-circuited here any more. An operator who
|
||||
// has switched movie import banners off is a reason nobody was told, and the notification
|
||||
// log is where that answer belongs — deliverBroadcast records it as a skip rather than the
|
||||
// producer returning in silence.
|
||||
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
|
||||
window := s.radarrAlertWindow()
|
||||
title := strings.TrimSpace(result.Name)
|
||||
if window <= 0 || title == "" || result.ItemID == "" {
|
||||
if title == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -68,7 +72,7 @@ func (s *Server) announceImportedMovie(ctx context.Context, result library.Inges
|
||||
if result.Year > 0 {
|
||||
name = fmt.Sprintf("%s (%d)", title, result.Year)
|
||||
}
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
|
||||
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
|
||||
ID: "ingest:movie:" + result.ItemID,
|
||||
@@ -90,7 +94,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
// as it does the "aired, coming soon" one, without touching films.
|
||||
window := s.sonarrAlertWindow()
|
||||
series := strings.TrimSpace(result.SeriesName)
|
||||
if window <= 0 || series == "" || result.ItemID == "" {
|
||||
if series == "" || result.ItemID == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
@@ -98,7 +102,7 @@ func (s *Server) announceImportedEpisode(ctx context.Context, result library.Ing
|
||||
seasonRunKey(series, result.Season), result.ItemID,
|
||||
episodeSummary(result), now, ingestRunWindow,
|
||||
)
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibraryIngest, clientAlert{
|
||||
// The run's *first* episode anchors the id, so every later arrival in the same
|
||||
// season pack replaces one banner rather than stacking another — and next week's
|
||||
// episode, arriving after the window has closed, starts a run of its own and is
|
||||
|
||||
@@ -84,7 +84,7 @@ func buildSonarrRowForTest(t *testing.T, episode sonarr.Episode, now time.Time)
|
||||
|
||||
func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem {
|
||||
t.Helper()
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC)
|
||||
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRadarrRow: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -213,10 +214,18 @@ func (s *Server) syncReturnNotifications(
|
||||
message = show.Title + " returns next week."
|
||||
}
|
||||
sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02")
|
||||
_ = s.store.UpsertNotification(
|
||||
r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID,
|
||||
"New episode coming", message, series.NextAiring,
|
||||
)
|
||||
s.notifyUser(r.Context(), notify.Notification{
|
||||
Kind: "show-return",
|
||||
Source: notifySourceShowReturn,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "New episode coming",
|
||||
Body: message,
|
||||
ItemID: show.ItemID,
|
||||
SourceKey: sourceKey,
|
||||
EventAt: series.NextAiring,
|
||||
Metadata: map[string]any{"show": show.Title, "leadDays": prefs.LeadDays},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +240,12 @@ func (s *Server) handleNotificationAction(
|
||||
switch r.PathValue("action") {
|
||||
case "read":
|
||||
err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id)
|
||||
// Marking a notification back to new is the viewer's own action, where "read" is set by
|
||||
// the page merely focusing a row. That is why the two are separate routes rather than one
|
||||
// carrying a boolean: an automatic mark and a deliberate one are different events, and only
|
||||
// this one is ever a decision somebody made with the remote.
|
||||
case "unread":
|
||||
err = s.store.MarkNotificationUnread(r.Context(), sess.EmbyUserID, id)
|
||||
case "dismiss":
|
||||
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
|
||||
default:
|
||||
|
||||
@@ -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"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -950,15 +951,30 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
|
||||
return ""
|
||||
}
|
||||
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
|
||||
_ = s.store.UpsertNotification(
|
||||
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
|
||||
episode.SeriesID, "Added to My Shows",
|
||||
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
|
||||
)
|
||||
return seriesItem.Name
|
||||
notification := notify.Notification{
|
||||
Kind: "auto-follow",
|
||||
Source: notifySourceAutoFollow,
|
||||
UserID: sess.EmbyUserID,
|
||||
Username: sess.Username,
|
||||
Title: "Added to My Shows",
|
||||
Body: seriesItem.Name + " was added because you started watching it and it is still continuing.",
|
||||
ItemID: episode.SeriesID,
|
||||
SourceKey: "auto-follow:" + episode.SeriesID,
|
||||
Metadata: map[string]any{"series": seriesItem.Name},
|
||||
}
|
||||
// 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 {
|
||||
|
||||
@@ -61,6 +61,14 @@ type preferenceDefinition struct {
|
||||
// number reads as minutes, which is what the first one to exist happened to be.
|
||||
Unit string `json:"unit,omitempty"`
|
||||
MaxLength int `json:"maxLength,omitempty"`
|
||||
// Uppercase folds a text value to capitals. It belongs to the definition rather than
|
||||
// to the kind: initials are read as capitals, and a person's name is not — folding
|
||||
// every text setting would have the launcher greeting somebody as MATT.
|
||||
Uppercase bool `json:"uppercase,omitempty"`
|
||||
// Placeholder is what the console shows in an empty field, which for these settings is
|
||||
// what happens when nobody fills it in. Blank is a legal value for both of them, so the
|
||||
// field has to say what blank means or it reads as a setting that was never finished.
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
AdminOnly bool `json:"adminOnly,omitempty"`
|
||||
Default any `json:"default"`
|
||||
}
|
||||
@@ -74,6 +82,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Key: "profileInitials", Name: "Profile initials", Area: "Profile",
|
||||
Description: "Up to two characters shown in this person's user-switcher avatar. Leave blank to generate them from their name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: 2, AdminOnly: true,
|
||||
Uppercase: true, Placeholder: "Generated from their name",
|
||||
},
|
||||
{
|
||||
// The friendly name Memby addresses somebody by, and nothing more: it is not a
|
||||
// second username and nothing is keyed on it. Blank is the ordinary state — the
|
||||
// television falls back to the account name — so this is only worth setting where
|
||||
// the account name is not what anybody would call the person.
|
||||
Key: "shortName", Name: "Short name", Area: "Profile",
|
||||
Description: "The friendly name Memby greets this person by. Leave blank to use their account name.",
|
||||
Kind: preferenceText, Default: "", MaxLength: shortNameMaxLength, AdminOnly: true,
|
||||
Placeholder: "Their account name",
|
||||
},
|
||||
{
|
||||
Key: "homeSections", Name: "Home rows", Area: "Home",
|
||||
@@ -237,6 +256,10 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
},
|
||||
}
|
||||
|
||||
// shortNameMaxLength bounds the friendly name. It is a first name on a launcher, not a
|
||||
// field to write a sentence in, and the greeting it lands in shares its line with a clock.
|
||||
const shortNameMaxLength = 24
|
||||
|
||||
// maxListEntries bounds the free-form id lists. They come from a television, and a row
|
||||
// list long enough to matter is already a bug on that end.
|
||||
const maxListEntries = 200
|
||||
@@ -334,8 +357,11 @@ func normalizePreference(definition preferenceDefinition, value any) any {
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if !strings.ContainsAny(trimmed, "\n\r") &&
|
||||
(definition.MaxLength <= 0 || len([]rune(trimmed)) <= definition.MaxLength) {
|
||||
if definition.Uppercase {
|
||||
return strings.ToUpper(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue(definition)
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,37 @@ func TestNormalizePreferencesBoundsAndNormalisesProfileInitials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A short name is a person's name, so unlike the initials beside it in the catalogue it
|
||||
// keeps the case it was typed in. Folding it would have the launcher greeting somebody as
|
||||
// MATT, which is the whole reason Uppercase is per-definition rather than per-kind.
|
||||
func TestNormalizePreferencesKeepsShortNameCaseAndBoundsIt(t *testing.T) {
|
||||
if got := normalizePreferences(map[string]any{"shortName": " Matt "})["shortName"]; got != "Matt" {
|
||||
t.Errorf("shortName = %v, want Matt", got)
|
||||
}
|
||||
long := strings.Repeat("a", shortNameMaxLength+1)
|
||||
for _, value := range []any{long, "Ma\ntt", 12} {
|
||||
if got := normalizePreferences(map[string]any{"shortName": value})["shortName"]; got != "" {
|
||||
t.Errorf("shortName for %v = %v, want the account-name fallback", value, got)
|
||||
}
|
||||
}
|
||||
if got := normalizePreferences(nil)["shortName"]; got != "" {
|
||||
t.Errorf("default shortName = %v, want blank", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The short name is admin-owned like the initials, so a television saving an unrelated
|
||||
// setting must not be what quietly clears it.
|
||||
func TestDevicePreferenceWritePreservesAdminShortName(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"shortName": "Matt"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged := preserveAdminPreferences(map[string]any{"showTitleLogo": false}, stored)
|
||||
if normalizePreferences(merged)["shortName"] != "Matt" {
|
||||
t.Errorf("shortName = %v, want preserved Matt", normalizePreferences(merged)["shortName"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicePreferenceWritePreservesAdminInitials(t *testing.T) {
|
||||
stored, err := json.Marshal(normalizePreferences(map[string]any{"profileInitials": "MC"}))
|
||||
if err != nil {
|
||||
|
||||
@@ -56,6 +56,13 @@ type radarrScheduleItem struct {
|
||||
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
|
||||
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
|
||||
MembyPlayable bool `json:"MembyPlayable"`
|
||||
// The Emby film this card stands for, when the library already holds it — the
|
||||
// MembySeriesItemId arrangement, and for the same reason: it is what decides whether
|
||||
// pressing the card opens the ordinary Memby page or the Radarr-only one. A film the
|
||||
// household has not downloaded carries none. The detail route resolves it again from
|
||||
// live data, because this row is cached for the day and a film imported at lunchtime
|
||||
// must not be stuck behind a cache until midnight.
|
||||
MembyMovieItemID string `json:"MembyMovieItemId,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
|
||||
@@ -90,7 +97,7 @@ func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := buildRadarrRow(movies, now, location)
|
||||
row, err := buildRadarrRow(movies, now, location, s.embyMovieIndex(ctx, movies))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +121,37 @@ func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row
|
||||
return &row
|
||||
}
|
||||
|
||||
func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) {
|
||||
// embyMovieIndex answers which of these films Emby already holds, keyed by TMDb id.
|
||||
//
|
||||
// Films are matched on the id both systems record rather than on their titles, which is
|
||||
// what the Sonarr schedule row has to fall back on: Radarr writes a TMDb id and the
|
||||
// library import asks Emby for ProviderIds, so there is nothing here to guess at. A
|
||||
// failure is not fatal — the row is about what is coming, and losing the link only costs a
|
||||
// downloaded card its ordinary detail page.
|
||||
func (s *Server) embyMovieIndex(ctx context.Context, movies []radarr.Movie) map[int]string {
|
||||
if s.store == nil || len(movies) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int, 0, len(movies))
|
||||
for _, movie := range movies {
|
||||
if movie.TMDBID > 0 {
|
||||
ids = append(ids, movie.TMDBID)
|
||||
}
|
||||
}
|
||||
found, err := s.store.LibraryProviderItemIDs(ctx, "Tmdb", ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("emby movie index unavailable for schedule row", "error", err)
|
||||
return nil
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func buildRadarrRow(
|
||||
movies []radarr.Movie,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
embyItems map[int]string,
|
||||
) (*recommend.Row, error) {
|
||||
sort.SliceStable(movies, func(i, j int) bool {
|
||||
left, leftOK := effectiveRadarrRelease(movies[i])
|
||||
right, rightOK := effectiveRadarrRelease(movies[j])
|
||||
@@ -139,7 +176,9 @@ func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Locatio
|
||||
if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location))
|
||||
item := toRadarrScheduleItem(movie, release, now, location)
|
||||
item.MembyMovieItemID = embyItems[movie.TMDBID]
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
|
||||
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
|
||||
}, now, location)
|
||||
}, now, location, map[int]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ const (
|
||||
// Nothing here waits for the deployment to finish. The gateway that publishes this is the
|
||||
// one being retired; the one that comes back has no memory of having said it.
|
||||
func (s *Server) AnnounceDeployment(ctx context.Context) {
|
||||
s.publishAlert(ctx, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
s.broadcast(ctx, notifySourceDeployment, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
|
||||
}
|
||||
|
||||
func deploymentAlert(now time.Time) clientAlert {
|
||||
@@ -84,7 +84,7 @@ func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.publishAlert(ctx, clientAlert{
|
||||
s.broadcast(ctx, notifySourceLibrarySync, clientAlert{
|
||||
// Keyed on the minute the sync finished: two runs are two pieces of news, but a
|
||||
// retried publish of the same run is not.
|
||||
ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()),
|
||||
@@ -153,13 +153,13 @@ func (s *Server) WatchEmbyReachability(ctx context.Context) {
|
||||
reachable = false
|
||||
s.log.Warn("emby unreachable, announcing",
|
||||
"component", "emby-health", "failures", failures, "error", err)
|
||||
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(false), reachabilityAlertWindow)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !reachable {
|
||||
s.log.Info("emby reachable again, announcing", "component", "emby-health")
|
||||
s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
s.broadcast(ctx, notifySourceEmbyHealth, s.reachabilityAlert(true), reachabilityAlertWindow)
|
||||
}
|
||||
reachable = true
|
||||
failures = 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -95,23 +96,28 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-added:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " was added to Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-added", "",
|
||||
"Show added", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr addition notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-added",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show added",
|
||||
Body: change.Current.Title + " was added to Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range cancellations {
|
||||
for _, user := range users {
|
||||
prefs, ok := preferences[user.ID]
|
||||
@@ -125,23 +131,28 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
preferences[user.ID] = prefs
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
continue
|
||||
}
|
||||
eventAt := change.Current.ObservedAt
|
||||
sourceKey := fmt.Sprintf("show-cancelled:%s:%d", change.Current.SeriesKey, change.HistoryID)
|
||||
message := change.Current.Title + " is now listed as cancelled in Sonarr."
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, user.ID, sourceKey, "show-cancelled", "",
|
||||
"Show cancelled", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("Sonarr cancellation notification failed",
|
||||
"user", user.ID, "show", change.Current.Title, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: "show-cancelled",
|
||||
Source: notifySourceSonarrLifecycle,
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Title: "Show cancelled",
|
||||
Body: change.Current.Title + " is now listed as cancelled in Sonarr.",
|
||||
SourceKey: sourceKey,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"series": change.Current.Title, "status": change.Current.Status},
|
||||
}
|
||||
if preferenceErrors[user.ID] || !prefs.Enabled || !prefs.SonarrAlerts {
|
||||
s.declineUser(ctx, notification, sonarrDeclineReason(prefs, preferenceErrors[user.ID]))
|
||||
continue
|
||||
}
|
||||
if s.notifyUser(ctx, notification) {
|
||||
notifications++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Info("Sonarr lifecycle scan complete",
|
||||
"series", len(observations), "changes", len(changes),
|
||||
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
|
||||
@@ -176,3 +187,21 @@ func sonarrBecameCancelled(previous, current string) bool {
|
||||
current == "cancelled" || current == "canceled"
|
||||
return active && cancelled
|
||||
}
|
||||
|
||||
// sonarrDeclineReason is the sentence the console prints beside a skipped row.
|
||||
//
|
||||
// The three refusals are genuinely different answers to "why was I not told", and a page
|
||||
// that collapsed them into "skipped" would send an operator to change a setting that was
|
||||
// never the problem. A preference that would not load is its own case: it is read as "not
|
||||
// now" rather than as consent, and that is a fact about the gateway rather than about the
|
||||
// viewer.
|
||||
func sonarrDeclineReason(prefs store.NotificationPreferences, unreadable bool) string {
|
||||
switch {
|
||||
case unreadable:
|
||||
return "this viewer's notification preferences could not be read"
|
||||
case !prefs.Enabled:
|
||||
return "this viewer has notifications switched off"
|
||||
default:
|
||||
return "this viewer has Sonarr alerts switched off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,13 @@ func (s *Server) resolveLocalTrailer(
|
||||
}
|
||||
|
||||
func (s *Server) trailerManifest(ctx context.Context, sess store.Session, itemID string) (trailerManifest, error) {
|
||||
// A film Radarr is tracking has no Emby record to ask about local or remote trailers,
|
||||
// so its chain is built from what Radarr knows. It joins here rather than beside the
|
||||
// detail route because everything downstream — availability, resolve, report, the
|
||||
// player's walk through the candidates — is then unchanged for both kinds of subject.
|
||||
if movieID, ok := radarrMovieID(itemID); ok && strings.HasPrefix(itemID, radarrItemPrefix) {
|
||||
return s.radarrTrailerManifest(ctx, itemID, movieID)
|
||||
}
|
||||
key := cache.UserKey(sess.EmbyUserID, "trailers:v2:"+itemID)
|
||||
if s.cache != nil {
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
@@ -138,20 +139,32 @@ func (s *Server) sendWeeklyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
|
||||
message := weeklyDigestMessage(
|
||||
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeWeeklyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: "Your week in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle},
|
||||
}
|
||||
// The floor above is a judgement about the news; this is a judgement about the
|
||||
// person, and only the second one is worth recording. "You have summaries switched
|
||||
// off" is the answer to somebody reporting that they never get one, and it is not
|
||||
// findable anywhere else.
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location))
|
||||
}
|
||||
@@ -183,19 +196,26 @@ func (s *Server) sendMonthlyWatchTime(
|
||||
if total < watchTimeDigestFloor {
|
||||
continue
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
continue
|
||||
}
|
||||
message := monthlyDigestMessage(total, monthName, watched.TopTitle)
|
||||
if err := s.store.UpsertNotification(
|
||||
ctx, account.ID, key, watchTimeMonthlyKind,
|
||||
"", monthName+" in Memby", message, &eventAt,
|
||||
); err != nil {
|
||||
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err)
|
||||
notification := notify.Notification{
|
||||
Kind: watchTimeMonthlyKind,
|
||||
Source: notifySourceWatchTime,
|
||||
UserID: account.ID,
|
||||
Username: account.Username,
|
||||
Title: monthName + " in Memby",
|
||||
Body: message,
|
||||
SourceKey: key,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle, "month": monthID},
|
||||
}
|
||||
if !s.watchTimeDigestWanted(ctx, account.ID) {
|
||||
s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off")
|
||||
continue
|
||||
}
|
||||
if s.notifyUser(ctx, notification) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent > 0 {
|
||||
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"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,11 @@ type Dispatcher struct {
|
||||
log *slog.Logger
|
||||
client *http.Client
|
||||
events *adminevents.Bus
|
||||
// notify is the audit trail every outbound notification lands in. This package is the
|
||||
// one producer that reports to it rather than being driven by it: the dispatcher has
|
||||
// its own queue, pacing and transport registry, and routing deliveries through
|
||||
// notify.Send would make the audit trail the thing deciding what Discord receives.
|
||||
notify *notify.Service
|
||||
|
||||
transports map[string]Transport
|
||||
queue chan job
|
||||
@@ -80,9 +86,12 @@ type Dispatcher struct {
|
||||
// SetPaused installs the server-wide quiet-time gate before Start is called.
|
||||
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused }
|
||||
|
||||
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher {
|
||||
func New(
|
||||
st *store.Store, log *slog.Logger, events *adminevents.Bus, notifier *notify.Service,
|
||||
) *Dispatcher {
|
||||
dispatcher := &Dispatcher{
|
||||
store: st, log: log.With("component", "integrations"), events: events,
|
||||
notify: notifier,
|
||||
client: &http.Client{Timeout: requestTimeout},
|
||||
transports: map[string]Transport{},
|
||||
queue: make(chan job, queueDepth),
|
||||
@@ -211,11 +220,12 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
took := time.Since(started)
|
||||
if d.store != nil {
|
||||
record := store.IntegrationDelivery{
|
||||
IntegrationID: integration.ID, EventType: event.Type,
|
||||
Success: err == nil, StatusCode: status,
|
||||
DurationMS: time.Since(started).Milliseconds(), Error: message,
|
||||
DurationMS: took.Milliseconds(), Error: message,
|
||||
}
|
||||
if writeErr := d.store.RecordIntegrationDelivery(
|
||||
context.WithoutCancel(ctx), record,
|
||||
@@ -223,9 +233,53 @@ func (d *Dispatcher) post(ctx context.Context, integration store.Integration, ev
|
||||
d.log.Warn("delivery not recorded", "integration", integration.ID, "error", writeErr)
|
||||
}
|
||||
}
|
||||
// The per-integration delivery history above answers "is this destination healthy",
|
||||
// which is what the integrations page asks. This is the other question — "did Memby
|
||||
// tell anybody about that event" — and it is answered in one place for every channel,
|
||||
// which is the whole reason the notification log exists.
|
||||
d.notify.Log(ctx, notify.Notification{
|
||||
Channel: notify.ChannelWebhook,
|
||||
Kind: event.Type,
|
||||
Source: "integrations",
|
||||
Title: event.Title,
|
||||
Body: event.Summary,
|
||||
// The destination's NAME, never its address: a Discord webhook URL is the
|
||||
// credential, and this row is rendered in the console.
|
||||
Target: integration.Name,
|
||||
SourceKey: integration.ID,
|
||||
Metadata: map[string]any{
|
||||
"integrationId": integration.ID,
|
||||
"kind": integration.Kind,
|
||||
"statusCode": status,
|
||||
},
|
||||
}, deliveryOutcome(status, err), took)
|
||||
return err
|
||||
}
|
||||
|
||||
// deliveryOutcome turns a transport's answer into an audit status.
|
||||
//
|
||||
// A webhook is the one channel that gets Delivered rather than Sent: somebody else's
|
||||
// service actually acknowledged this, where writing a row into a viewer's list is
|
||||
// finished the moment it returns with nobody to confirm it. The status code is kept in
|
||||
// the detail because "failed" on its own sends an operator to the wrong place — a 404 is
|
||||
// a webhook that has been deleted, a 429 is one that is merely busy.
|
||||
func deliveryOutcome(status int, err error) notify.Outcome {
|
||||
if err != nil {
|
||||
if status > 0 {
|
||||
return notify.Outcome{
|
||||
Status: notify.StatusFailed,
|
||||
Detail: fmt.Sprintf("HTTP %d: %s", status, err.Error()),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return notify.Failed(err)
|
||||
}
|
||||
if status > 0 {
|
||||
return notify.Delivered(fmt.Sprintf("HTTP %d", status))
|
||||
}
|
||||
return notify.Delivered("")
|
||||
}
|
||||
|
||||
// announceFailure puts a failed delivery back into the feed the operator is reading.
|
||||
//
|
||||
// It publishes a *different* type from the event that failed, and integration.failed is
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ type Movie struct {
|
||||
// Radarr's own lifecycle word for the title: tba, announced, inCinemas, released,
|
||||
// deleted. It is what the schedule card's lifecycle tag says.
|
||||
Status string `json:"status"`
|
||||
// Metadata Radarr carries for a film the household does not hold yet, and which
|
||||
// therefore has no Emby record to read it from. It is the whole substance of the
|
||||
// Radarr-only detail page; the schedule card itself uses none of it.
|
||||
OriginalTitle string `json:"originalTitle,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Certification string `json:"certification,omitempty"`
|
||||
YouTubeTrailerID string `json:"youTubeTrailerId,omitempty"`
|
||||
IMDBID string `json:"imdbId,omitempty"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MovieFile *MovieFile `json:"movieFile"`
|
||||
@@ -139,6 +147,21 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, e
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// Movie is one tracked film, for the case the cached catalogue cannot answer: a title
|
||||
// added to Radarr since the catalogue was last read. The catalogue is still tried first —
|
||||
// this is the fallback, not the ordinary path, because a detail page opening must not cost
|
||||
// a round trip Radarr has already answered once for the whole household.
|
||||
func (c *Client) Movie(ctx context.Context, movieID int) (Movie, error) {
|
||||
if movieID <= 0 {
|
||||
return Movie{}, fmt.Errorf("radarr: invalid movie id")
|
||||
}
|
||||
var movie Movie
|
||||
if err := c.get(ctx, "/api/v3/movie/"+strconv.Itoa(movieID), &movie); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
return movie, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
|
||||
req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}})
|
||||
if err != nil {
|
||||
|
||||
@@ -281,16 +281,26 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertNotification writes one notification into a viewer's own list.
|
||||
//
|
||||
// It reports whether a row was actually inserted, which is what separates the two answers
|
||||
// the source key produces: a genuine delivery, and a repeat of one already sitting in
|
||||
// somebody's list. Both are ordinary — the digest job runs hourly and re-sends the same
|
||||
// weekly key all evening on purpose — but the notification log has to be able to tell them
|
||||
// apart, or every catch-up run would read as a second summary nobody received.
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notifications
|
||||
(emby_user_id, source_key, kind, item_id, title, message, event_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
|
||||
userID, sourceKey, kind, itemID, title, message, eventAt)
|
||||
return err
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
|
||||
@@ -325,6 +335,19 @@ func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int6
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkNotificationUnread puts a notification back to new.
|
||||
//
|
||||
// The counterpart to MarkNotificationRead, and deliberately a plain assignment rather than
|
||||
// that one's COALESCE: read is sticky because it is set by merely looking at a row, so a
|
||||
// second glance must not move the timestamp, while unread is only ever the viewer saying so
|
||||
// and means exactly one thing.
|
||||
func (s *Store) MarkNotificationUnread(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET read_at = NULL
|
||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET dismissed_at = now()
|
||||
|
||||
@@ -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';
|
||||
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
|
||||
ON library_ingest_queue (updated_at DESC);
|
||||
|
||||
-- The outbound notification log: what Memby sent, to whom, over which channel, and what
|
||||
-- became of it. Written only by internal/notify, which every producer now goes through,
|
||||
-- so this is one audit trail rather than a per-feature guess.
|
||||
--
|
||||
-- Deliberately separate from user_notifications. That table is one viewer's undismissed
|
||||
-- list — state they empty — where this is history: it keeps the row for a notification
|
||||
-- that was dismissed, for one that was deliberately skipped, and for a broadcast that
|
||||
-- belongs to no viewer at all, none of which the other table can represent.
|
||||
--
|
||||
-- emby_user_id is '' rather than NULL for a household broadcast, so every filter is an
|
||||
-- equality test and no query needs a NULL case.
|
||||
CREATE TABLE IF NOT EXISTS notification_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
channel TEXT NOT NULL, -- in-app | broadcast | webhook
|
||||
kind TEXT NOT NULL DEFAULT '', -- show-return, watch-time-week, …
|
||||
source TEXT NOT NULL DEFAULT '', -- the service that decided to send
|
||||
emby_user_id TEXT NOT NULL DEFAULT '', -- '' is the whole household
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
target TEXT NOT NULL DEFAULT '', -- a destination's NAME, never its address
|
||||
source_key TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL, -- sent | delivered | failed | pending | skipped
|
||||
detail TEXT NOT NULL DEFAULT '', -- the failure, or why it was skipped
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||
event_at TIMESTAMPTZ,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- The page's default read is the whole log newest-first, and every filtered read still
|
||||
-- bounds on the date; the remaining three cover the columns the filter bar offers.
|
||||
CREATE INDEX IF NOT EXISTS notification_log_time_idx ON notification_log (occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notification_log_user_idx
|
||||
ON notification_log (emby_user_id, occurred_at DESC) WHERE emby_user_id <> '';
|
||||
CREATE INDEX IF NOT EXISTS notification_log_status_idx ON notification_log (status, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS notification_log_kind_idx ON notification_log (kind, occurred_at DESC);
|
||||
|
||||
Reference in New Issue
Block a user