Playback history & watch statistics #136
Shadowghost
started this conversation in
Proposals
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
Introduce a first-class, append-only playback history: one row per playback session, attached to a stable logical-item identity that survives library rescans and delete/re-add cycles. Demote the
UserDataplayed columns (Played,PlayCount,LastPlayedDate) to a projection of that history, kept for query performance rather than as the record of truth. Express "mark as played/unplayed" as an explicit override instead of by fabricating or destroying plays.This is the foundation for playback statistics in core, and it fixes several long-standing watch-state bugs as a side effect.
Problem
Watch state is a lossy aggregate
Jellyfin remembers that you watched something n times and roughly when the last one was. It does not remember any individual session - not when, not for how long, not on what, not which audio track, not whether it transcoded. Every question users actually ask ("how much did I watch this month", "which library is worth the disk", "why is my server always transcoding", "what did the kids watch") is unanswerable from the data model. The
playbackreportingplugin answers them by maintaining its own parallel schema, which means the data only exists if you installed the plugin before you needed it, and it does not participate in any of the server's own watch-state handling.Watch state is attached to the wrong thing
UserDatais keyed by data key, and data keys are derived per-item. That produces several failure modes that get reported repeatedly:"Mark as played" and "mark as unplayed" corrupt the record
MarkPlayedincrementsPlayCountand stampsLastPlayedDate = UtcNow: pressing a button becomes indistinguishable from watching the thing.MarkUnplayedzeroesPlayCountand clearsLastPlayedDate: it destroys the record of plays that genuinely happened. Once you introduce a history, neither is expressible - history is append-only, and marking unplayed cannot un-happen a play.Continue Watching is all-or-nothing
The only way to get something off the Continue Watching row is to mark it played or reset its position, both of which destroy real state. There is no "I'm not going to finish this, stop showing it to me".
Proposal
1. New schema - history decoupled from the library
Streams are stored as descriptive attributes, not stream indices, because indices are meaningless once the file is replaced - and because the interesting queries are "how often is TrueHD transcoded" and "how much HDR content is being tone-mapped", which aggregate over attributes.
UserIdandItemIdare deliberately not foreign keys: the history outlives both. Library add/remove events reattach and detach identities by key set (PlaybackHistorySync), and deleting a user purges that user's history explicitly.2.
UserData.Played/PlayCount/LastPlayedDatebecome a projectionThe history is what happened; those three columns are a materialized aggregate of it. They stay as stored columns because every played/unplayed filter, sort and folder count in the item queries reads them, and none of those can afford an aggregate over a table with one row per play.
They are recomputed after every session stop (
IUserDataManager.ApplyPlaybackStats), and can be rebuilt wholesale by a potential "Rebuild Played State From History" scheduled task. The rebuild is idempotent and writes the logical item's totals to every data key the item answers to, which is what stops the multi-key disagreement described above.The resume position is explicitly not part of the projection: it is mutable state governed by the resume thresholds and has no meaning in an append-only record.
3.
PlayedOverride- user intent, separate from observationPlayedOverride = true. It does not invent a play, and no longer inflatesPlayCountor stamps a fakeLastPlayedDate. An explicitly supplied date (a metadata import, a plugin syncing from elsewhere) is still honoured, because that is a caller asserting evidence of a real play.PlayedOverride = falseand clears the resume position.PlayCountandLastPlayedDateare left alone - they describe plays that happened.This keeps the history a truthful record of observed playback while leaving the user in full control of what the library displays.
4.
ExcludedFromResume- dismiss from Continue WatchingHides an item from Continue Watching without touching the resume position - play it again and it picks up where you left off. Applied inside the
IsResumablequery translation so it takes effect everywhere resumability is asked about, rather than as a post-filter on one endpoint.Series and Seasons are dismissed on their own row, because a container can be resumable purely by holding a mix of played and unplayed episodes with no descendant carrying a resume position. Playing any episode of a dismissed series clears the dismissal - the one thing dismissal must not do is hide a show forever once you resume it.
5. Backfill from existing watch state
A core-initialisation migration seeds the store from existing
UserData: each played(user, item)becomesPlayCountentries markedSource = Imported. Rows an item holds under different data keys are folded into the fullest one first.Imported entries carry no device, client, bitrate or stream detail, and - bar the most recent entry for each item - no real date either. They are stamped with a sentinel
UnknownDate(Unix epoch) rather than "now", so upgrade day doesn't appear as the biggest viewing day in the server's history, and so they fall outside every statistics window rather than polluting it. Statistics that describe when or how something was watched exclude them; a user reading back what they have watched sees them.6. Retention
Folded into the existing
CleanupUserDataTask(90 days), because a deleted item leaves two records of having been watched - the parkedUserDatarows and the detached playback identity - and they must expire together. If one half is purged while the other survives, re-adding the item restores a play count that the next playback then overwrites with a count of one.The task keeps its deliberate lack of a default trigger: watch state is the one thing a media server cannot regenerate, so discarding it stays an administrator's decision.
7. Measured bandwidth
A stream-observer wrapper around the delivery path (
ObservableBlobActionResult/StreamObserverService) measures the bytes actually written to the client and folds them into the session'sActualBytesTransferred, debounced to one update per second per stream. Where it isn't available (segmented HLS paths), consumers fall back to the bitrate × watch-time estimate. This is what makes "how much data did my server actually push" answerable rather than modelled.API surface
User-facing (
[Authorize]):Admin (
RequiresElevation,/Playback/Statistics):Every windowed endpoint takes
utcOffsetMinutes; without it the day boundaries are UTC, which misplaces evening viewing for most of the world.UserItemDataDtogainsExcludedFromResume(additive).Compatibility
UserDatacolumns. Nothing is dropped.MarkPlayed/MarkUnplayed, which no longer fabricate/destroyPlayCountandLastPlayedDate. Clients that read those fields keep working; the values just become honest. This is the one change that needs a release note.Client work
/Playback/Statistics/*- the timeline, heatmap, top items and stream breakdown are the interesting ones. Charts should exclude imported entries where the endpoint already does./UserPlaybackHistory.Risks and open questions
PlayedDurationTicksaccumulates from progress reports, takingmin(position delta, wall-clock delta)while unpaused - so seeks don't inflate it and a stalled client doesn't either. It is still only as good as the client's progress reporting.All reactions