This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+113
View File
@@ -488,6 +488,55 @@ 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.
**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 —
and the console's figure and the viewer's summary are both queries over `tracearr_sessions`
rather than a second table of minutes. A table counting watching separately would be a copy
of a copy, wrong the moment Tracearr corrects a session. It is read two ways:
- **The console reads it beside the person.** `/admin/api/accounts` carries a `watchTime`
per account — week, month, lifetime, and when they last watched — from one grouped query
for the whole household, because that page grows with the family. `matched` is the
load-bearing field: a household running no Tracearr and a viewer Tracearr has never seen
both arrive as zeroes, and a console drawing those as "0 min this week" would have an
operator investigating a person rather than an integration. A watch-time read that fails
costs the figures and never the account list.
- **`attributeWatchTime` joins the two systems on the username**, which is the identity they
genuinely share, and prefers the Tracearr id `recommendation_user_profiles` recorded where
there is one — so a viewer renamed in one system keeps their figures instead of silently
reporting zero. It is pure, so the console and the digest cannot attribute the same rows
differently.
- **The weekly summary is a personal notification, not a service alert.** A service alert is
the house being told something; how long somebody watched is nobody else's news, so it
lands in My Alerts (`watch-time-week` / `watch-time-month`, which an app that predates them
renders with the fallback icon) and follows the person to every set. `RegisterWatchTimeTasks`
registers it as an ordinary scheduler job, so an operator can see when it last ran and send
one by hand — which for a job that fires once a week is the difference between "it has sent
nothing" and "it has not run".
- **The source key is the only thing preventing a repeat.** It runs hourly and sends from the
appointed hour to the end of that day, because `watch-time:weekly:2026-W33` is written
`ON CONFLICT DO NOTHING`: a container restarted three times on a Sunday evening delivers one
summary, and a gateway that was off all evening still delivers it the next hour it is up.
The monthly summary is the same trick over a `YYYY-MM` key, which is why it catches up
rather than being skipped for ever by a gateway that was down on the first.
- **Sunday evening, not Monday morning**, because the figure sent is week-to-date: on a Monday
it would summarise almost nothing. Every boundary is a household-local *calendar* date
(`weekStartIn`, `monthStartIn`, `previousMonth`), never `now.Add(-7*24*time.Hour)` — a week
containing a daylight-saving change is 23 or 25 hours short or long, and subtracting hours
puts the boundary an hour inside the previous Sunday twice a year. `watch_time_test.go` pins
the clock-change week.
- **Two switches, and they answer different questions.** `watch_time_digest` in the
`featureCatalogue` is the household's and carries no capability — nothing on the television
has to understand this. `NotificationPreferences.WatchTimeDigest` is the viewer's own, kept
apart from `SystemAlerts` because this is the only notification there that is about *them*.
Turning it off also withdraws the summaries already sitting in their list
(`filterStoredNotifications`): switching a weekly notice off is a statement about the ones
already there as much as about the next one.
- **Nothing under `watchTimeDigestFloor` is sent**, and a preference that will not load is
read as "not now" rather than as consent. A digest reporting four minutes is a notification
about a title somebody abandoned, and a feed carrying those is one nobody opens.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` →
@@ -1424,6 +1473,70 @@ but the address. Four things to preserve:
than none, since it opens a connection nothing will use and leaves the one that matters
cold.
**An advance opens the next episode's stream before anybody asks for it.** `StreamWarmer`
warms the *host*; this warms the title, which it can only do here because an advance is the
one case where the app knows what is next minutes ahead — `NextUpResolver` has the answer
five minutes before the credits (`NEXT_UP_STREAM_WARM_LEAD_MS`). Media3's
`DefaultPreloadManager` does the work; `ui/player/NextEpisodePreloader.kt` owns the two
decisions it cannot make for itself, and `PreloadPlan.kt` holds them as pure functions so
they can be pinned by plain JUnit. Things to preserve:
- **The player and the manager are built from one builder.**
`DefaultPreloadManager.Builder.buildExoPlayer` *overwrites* the media source factory,
renderers, load control, bandwidth meter, track selector and playback looper on whatever
`ExoPlayer.Builder` it is handed, so everything shared is set on the manager's builder and
the player's carries only what the manager has no opinion about. The looper is the one
that would actually break: a source prepared on one playback thread and played on another
is a crash, not a slow start.
- **Ranking data is a position in the journey, not a playlist index.** Nothing here is a
playlist — the player is handed one episode at a time and the next is discovered while it
plays — so a rank is assigned when an answer arrives and only ever moves forward. Exactly
one episode ahead is preloaded (`preloadTargetFor`); two would double the cost for a
viewer who has two episodes' worth of time to walk away.
- **Eviction never touches the episode playing.** On an advance the player has just been
handed that episode's `MediaSource`, and `remove` releases the source underneath the
decoder using it. `obsoletePreloadRanks` is strictly-behind for that reason, `advanceTo`
is called *after* `startMedia` and not before it, and the entry for the playing episode is
left in the manager to be quietened by its target status turning to
`PRELOAD_STATUS_NOT_PRELOADED` on the next `invalidate`.
- **The bound is a memory ceiling first.** `PRELOAD_RANGE_MS` is five seconds because the
expensive half of starting a stream is the connection, the container header and the seek
index — which `specifiedRangeLoaded` pays by preparing the source and selecting tracks —
not the bytes. A 4K direct play runs past 30 Mbps, so every second held ahead is megabytes
on a box with none spare, for a title the viewer may not go on to.
- **Registration hangs off the resolver, not off one call site.** `NextUpResolver`'s
`onResolved` fires for the first lookup *and* for every re-negotiation of a stale stream,
and a re-negotiation is exactly when preloaded work stops matching the URL the player will
be handed. It fires only for the episode still playing, or an answer that arrived after
the viewer moved on would have the preloader open a connection for a journey that no
longer exists.
- **Every part of it degrades to what came before.** A manager that will not build leaves
the preloader unattached, `sourceFor` answers null and `startMedia` takes the ordinary
`setMediaItem` path — which is also what a cold start, the direct-to-Emby path, a retry
that re-negotiated, and an unfinished preload all take. That fallback is *logged*
(`event=preload_unavailable`), because it is invisible from the viewer's side and a set
that never preloads anything otherwise looks exactly like one where the feature works and
never happens to save time.
- **`PRELOADING_ENABLED` and `DYNAMIC_SCHEDULING_ENABLED` are separate switches** in
`PlayerEngine`, the `THEME_PICKER_ENABLED` precedent, so a television that misbehaves on
Media3's experimental scheduling can have that taken away without losing preloading or the
version bump underneath both.
- **`event=first_frame` says which start it is measuring** (`start=cold` / `start=preloaded`).
The whole feature is a claim about one of two latencies, and a log that could not separate
them could not show whether it worked. `event=preload_ready` carries how long the preload
itself took and the range it was bounded to.
**Media3 is one version across every artifact, and the Jellyfin FFmpeg extension pins which
one that can be.** That extension is compiled against `media3-exoplayer` and reached
*reflectively* through `EXTENSION_RENDERER_MODE_ON`, so a core from a different minor line
fails at renderer construction rather than at compile time — and `PlayerEngine`'s
`LinkageError` fallback would swallow it, silently withdrawing surround software decode with
nothing in the log to say why. Jellyfin publishes up to the 1.9 line, so `media3Version` in
`app/build.gradle.kts` is on it. `enablePerStreamMediaProgression` arrived in 1.11 and is
therefore not available here; `experimentalSetDynamicSchedulingEnabled` is the part of that
same work which is. Moving the core past 1.9 means finding a matching extension first, or
deciding to do without DTS.
**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.