This commit is contained in:
ponzischeme89
2026-08-20 15:06:00 +12:00
parent 549f9c5eed
commit f1164db2c5
52 changed files with 5441 additions and 158 deletions
+66 -12
View File
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil {
if raw, err := s.cache.Get(ctx, itemDetailKey(viewerKeyOf(ctx, sess), itemID)); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
@@ -88,7 +88,7 @@ func (s *Server) detailItem(
// than after a second request. Anything not yet stored still arrives on
// /ratings.
decorated := []json.RawMessage{raw}
s.decorateItemRatings(ctx, decorated)
s.decorateItems(ctx, decorated)
return decorated[0], nil
})
return item, err
@@ -255,7 +255,7 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
// Watching card is focused, and the page asks again when somebody presses. A
// long-running show is a thousand records, so two of them is a real cost on the one
// press that must feel free.
key := cache.UserKey(sess.EmbyUserID, "series-episodes:"+seriesID)
key := cache.UserKey(viewerKeyOf(ctx, sess), "series-episodes:"+seriesID)
body, hit, err := s.cachedRead(ctx, key, s.cfg.ItemTTL,
func(ctx context.Context) (json.RawMessage, error) {
result, err := s.emby.Episodes(
@@ -278,6 +278,11 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
if items == nil {
items = []json.RawMessage{}
}
// The ticks down the episode list are the clearest statement this app makes
// about what somebody has seen, so they are the last place the account's
// answer may be left standing. The cache key is the viewer's, so this is
// stored per person rather than decorated on the way out.
s.decorateViewerState(ctx, items)
return json.Marshal(seriesEpisodesResponse{Items: items})
})
if err != nil {
@@ -307,33 +312,78 @@ func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess stor
writeRaw(w, http.StatusOK, result.Items[0])
}
// The four routes below are the whole of what Memby writes back to Emby about a person:
// a favourite, a watched flag, a hidden resume item and a playback report. Each one now
// asks who is watching first, because that is the entire promise of a shadow viewer — the
// Emby account lends them the library and never learns what they did with it.
func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
s.setFlag(w, r, sess, func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) {
if !viewer.IsMain() {
return s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.SetViewerFavourite(r.Context(), viewer.ID, itemID, value)
})
}
return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value)
})
}
func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) {
s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) {
s.setFlag(w, r, sess, func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error) {
if !viewer.IsMain() {
return s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.SetViewerPlayed(r.Context(), viewer.ID, itemID, value)
})
}
return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value)
})
}
// setShadowFlag applies a Memby-side mutation and answers in the shape Emby would have.
//
// Re-reading the row rather than describing the write is deliberate: the response is what
// the television draws the card from, and a favourite pressed on a title that is also part
// way through has to come back carrying the position as well as the heart.
func (s *Server) setShadowFlag(
ctx context.Context, viewer store.Viewer, itemID string, apply func() error,
) (json.RawMessage, error) {
if err := apply(); err != nil {
return nil, err
}
state, err := s.store.ViewerStateFor(ctx, viewer.ID, itemID)
if err != nil {
return nil, err
}
return viewerUserData(state), nil
}
func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
viewer := s.activeViewer(r.Context(), sess, r)
var userData json.RawMessage
var err error
if viewer.IsMain() {
userData, err = s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
} else {
userData, err = s.setShadowFlag(r.Context(), viewer, itemID, func() error {
return s.store.HideViewerFromResume(r.Context(), viewer.ID, itemID)
})
}
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
// For You is built from the Emby account's own history, so a shadow viewer's press
// says nothing about it. Marking it dirty would rebuild the main viewer's row out of
// somebody else's choice.
if s.forYou != nil && viewer.IsMain() {
s.forYou.MarkDirty(r.Context(), sess)
}
writeRaw(w, http.StatusOK, userData)
@@ -345,8 +395,9 @@ func (s *Server) setFlag(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
apply func(itemID string, value bool) (json.RawMessage, error),
apply func(viewer store.Viewer, itemID string, value bool) (json.RawMessage, error),
) {
viewer := s.activeViewer(r.Context(), sess, r)
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
@@ -358,15 +409,18 @@ func (s *Server) setFlag(
return
}
userData, err := apply(itemID, req.Value)
userData, err := apply(viewer, itemID, req.Value)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not update the item")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
if err := s.cache.InvalidateUser(r.Context(), viewer.ID); err != nil {
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
// For You is built from the Emby account's own history, so a shadow viewer's press
// says nothing about it. Marking it dirty would rebuild the main viewer's row out of
// somebody else's choice.
if s.forYou != nil && viewer.IsMain() {
s.forYou.MarkDirty(r.Context(), sess)
}
writeRaw(w, http.StatusOK, userData)