0.2.77
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user