Files
memby/server/internal/api/viewer_state_attach.go
T

173 lines
6.2 KiB
Go
Raw Normal View History

2026-08-20 15:06:00 +12:00
package api
import (
"context"
"encoding/json"
"github.com/ponzischeme89/memby/server/internal/store"
)
// userDataItemField is the block a television draws a progress bar, a tick and a heart
// from. For a shadow viewer it is written here rather than by Emby.
const userDataItemField = "UserData"
// A launcher is a few hundred cards. This matches the ratings attach limit for the same
// reason: it guards against a future row type asking for a thousand, not against anything
// reached today.
const viewerStateItemLimit = 600
// decorateViewerState replaces the UserData on every item with this viewer's own.
//
// It is the read half of what the four gated mutations are the write half of, and it rides
// exactly where decorateItemRatings rides — one indexed read for a whole launcher, at every
// point items leave the gateway. A card then draws the right progress bar as the row
// appears, and nothing above this line has to know which viewer it is drawing for.
//
// Three things to preserve:
//
// A main viewer returns immediately. Their state is Emby's and is already on the payload,
// so a household running no viewers pays one comparison for the whole launcher.
//
// **Every item is rewritten, not only the ones with something stored.** The UserData that
// arrived from Emby is the *account's*, and leaving it in place on a title this viewer has
// never touched is precisely the leak this feature exists to prevent: Alessandra would see
// Matt's progress bars on everything neither of them had watched together. A title with no
// row gets the zero state, which is the truth about it.
//
// A series and a season are aggregates, so they are rewritten from a *count* rather than
// from a row of their own: Emby fills their block in from their children, and a shadow
// viewer has no children Emby knows about. This was the last place they were still shown
// the account's answer — a series ticked because somebody else had finished it.
func (s *Server) decorateViewerState(ctx context.Context, collections ...[]json.RawMessage) {
// The viewer is read from the context alone. Outside a request there is none, and the
// zero session resolves to a main viewer, so a scheduled task or a test decorates
// nothing rather than blanking what it was given.
viewer := viewerOf(ctx, store.Session{})
if viewer.IsMain() || s.store == nil {
return
}
ids := itemIDsIn(collections, viewerStateItemLimit)
if len(ids) == 0 {
return
}
states, err := s.store.ViewerStates(ctx, viewer.ID, ids)
if err != nil {
// A state read that fails must not hand the viewer the account's watched state,
// so every item is blanked rather than left as it arrived. A launcher with no
// progress bars is a poor answer; one showing somebody else's is a wrong one.
s.loggerFor(ctx).Warn("viewer state read failed", "error", err)
states = map[string]store.ViewerState{}
}
// The aggregate half is a second read and is only paid for by a response that
// actually carries a series or a season card. Its failure is the same failure the leaf
// read has: an empty map, so every container is blanked rather than left carrying
// somebody else's progress.
seriesIDs, seasonIDs := containerIDsIn(collections)
containers := map[string]store.ViewerAggregate{}
if len(seriesIDs) > 0 || len(seasonIDs) > 0 {
found, err := s.store.ViewerContainerStates(ctx, viewer.ID, seriesIDs, seasonIDs)
if err != nil {
s.loggerFor(ctx).Warn("viewer container state read failed", "error", err)
} else {
containers = found
}
}
for _, items := range collections {
for index, raw := range items {
id := itemIDOf(raw)
if id == "" {
continue
}
if isAggregateItem(raw) {
items[index] = injectItemUserData(
raw, viewerAggregateUserData(states[id], containers[id]),
)
continue
}
items[index] = injectItemUserData(raw, viewerUserData(states[id]))
}
}
}
// isAggregateItem reports whether an item's UserData describes its children rather than
// itself. Emby fills a series' and a season's block in from their episodes.
func isAggregateItem(raw json.RawMessage) bool {
return itemTypeOf(raw) == "Series" || itemTypeOf(raw) == "Season"
}
func itemTypeOf(raw json.RawMessage) string {
var item struct {
Type string `json:"Type"`
}
if json.Unmarshal(raw, &item) != nil {
return ""
}
return item.Type
}
// containerIDsIn collects the series and seasons a response is carrying.
//
// A **series** is keyed by its own id, because that is what its episodes carry as their
// series id. A **season** cannot be: its episodes carry its id in their payload, but the
// catalogue's indexed column is the series, so a season is looked up by its own id *and*
// its series is asked for alongside — which is what makes one query answer for a season
// card sitting on a page about a show the response also carries.
func containerIDsIn(collections [][]json.RawMessage) (seriesIDs, seasonIDs []string) {
seenSeries := map[string]bool{}
seenSeasons := map[string]bool{}
for _, items := range collections {
for _, raw := range items {
id := itemIDOf(raw)
if id == "" {
continue
}
switch itemTypeOf(raw) {
case "Series":
if !seenSeries[id] {
seenSeries[id] = true
seriesIDs = append(seriesIDs, id)
}
case "Season":
if !seenSeasons[id] {
seenSeasons[id] = true
seasonIDs = append(seasonIDs, id)
}
if parent := seriesIDOf(raw); parent != "" && !seenSeries[parent] {
seenSeries[parent] = true
seriesIDs = append(seriesIDs, parent)
}
}
}
}
return seriesIDs, seasonIDs
}
func seriesIDOf(raw json.RawMessage) string {
var item struct {
SeriesID string `json:"SeriesId"`
}
if json.Unmarshal(raw, &item) != nil {
return ""
}
return item.SeriesID
}
// injectItemUserData replaces one item's UserData block.
//
// It rewrites rather than merges: a partial overlay would leave whichever fields Memby had
// nothing to say about carrying the account's values, which is the same leak from a
// narrower angle.
func injectItemUserData(raw, userData json.RawMessage) json.RawMessage {
var members map[string]json.RawMessage
if json.Unmarshal(raw, &members) != nil || members == nil {
return raw
}
members[userDataItemField] = userData
out, err := json.Marshal(members)
if err != nil {
return raw
}
return out
}