173 lines
6.2 KiB
Go
173 lines
6.2 KiB
Go
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
|
|
}
|