This commit is contained in:
ponzischeme89
2026-08-19 06:57:59 +12:00
parent 8c847c59b8
commit 2b43b9ef12
94 changed files with 6359 additions and 778 deletions
+237 -4
View File
@@ -550,14 +550,61 @@ behind it. Things to preserve:
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
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