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