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 }