0.3.07
This commit is contained in:
@@ -362,6 +362,9 @@ func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
|
||||
if _, ok := body["preferencesRevision"]; !ok {
|
||||
t.Fatalf("status response carried no preferences revision: %v", body)
|
||||
}
|
||||
if _, ok := body["seriesStatusRevision"]; !ok {
|
||||
t.Fatalf("status response carried no series status revision: %v", body)
|
||||
}
|
||||
metadataOrder, ok := body["metadataHeroContentOrder"].([]any)
|
||||
if !ok || len(metadataOrder) == 0 {
|
||||
t.Fatalf("status response carried no global metadata hero order: %v", body)
|
||||
|
||||
@@ -38,7 +38,7 @@ const (
|
||||
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
|
||||
// call is made once, after D-pad focus has settled on a card, and every one of them
|
||||
// would be a per-card cost on a home row.
|
||||
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
||||
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ProviderIds,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
|
||||
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
|
||||
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
|
||||
@@ -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(viewerKeyOf(ctx, sess), itemID)); err == nil {
|
||||
if raw, err := s.cache.Get(ctx, s.itemDetailKey(ctx, viewerKeyOf(ctx, sess), itemID)); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
@@ -65,8 +65,16 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
|
||||
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
|
||||
// cannot hide newly requested fields such as People or the stored ratings.
|
||||
func itemDetailKey(userID, itemID string) string {
|
||||
return cache.UserKey(userID, "item:v6:"+itemID)
|
||||
func (s *Server) itemDetailKey(ctx context.Context, userID, itemID string) string {
|
||||
revision := int64(0)
|
||||
if s.store != nil {
|
||||
if stored, err := s.store.SonarrSeriesStatusRevision(ctx); err == nil {
|
||||
revision = stored
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("series status revision unavailable for item cache", "error", err)
|
||||
}
|
||||
}
|
||||
return cache.UserKey(userID, "item:v7:r"+strconv.FormatInt(revision, 10)+":"+itemID)
|
||||
}
|
||||
|
||||
// detailItem is the full record for one item, decorated and kept.
|
||||
@@ -77,7 +85,7 @@ func itemDetailKey(userID, itemID string) string {
|
||||
func (s *Server) detailItem(
|
||||
ctx context.Context, sess store.Session, itemID string,
|
||||
) (json.RawMessage, error) {
|
||||
item, _, err := s.cachedRead(ctx, itemDetailKey(sess.EmbyUserID, itemID), s.cfg.ItemTTL,
|
||||
item, _, err := s.cachedRead(ctx, s.itemDetailKey(ctx, sess.EmbyUserID, itemID), s.cfg.ItemTTL,
|
||||
func(ctx context.Context) (json.RawMessage, error) {
|
||||
raw, err := s.emby.Item(
|
||||
timing.WithLabel(ctx, "emby.item"), credentials(sess), itemID, fieldsDetail)
|
||||
|
||||
@@ -26,6 +26,8 @@ func seriesLifecycleTag(status string) lifecycleTag {
|
||||
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
|
||||
case "ended":
|
||||
return lifecycleTag{Status: "ended", Label: "ENDED"}
|
||||
case "cancelled", "canceled":
|
||||
return lifecycleTag{Status: "cancelled", Label: "CANCELLED"}
|
||||
case "deleted":
|
||||
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
|
||||
default:
|
||||
|
||||
@@ -15,6 +15,8 @@ func TestSeriesLifecycleTag(t *testing.T) {
|
||||
"Continuing": {Status: "continuing", Label: "CONTINUING"},
|
||||
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
|
||||
"ended": {Status: "ended", Label: "ENDED"},
|
||||
"cancelled": {Status: "cancelled", Label: "CANCELLED"},
|
||||
"canceled": {Status: "cancelled", Label: "CANCELLED"},
|
||||
"deleted": {Status: "deleted", Label: "REMOVED"},
|
||||
// No tag at all rather than an invented one.
|
||||
"": {},
|
||||
|
||||
@@ -132,6 +132,14 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
compatible, compatibilityMessage := compatibilityFor(r)
|
||||
featurePolicy := s.currentFeaturePolicy(r.Context())
|
||||
metadataHero := s.metadataHeroSettings.get()
|
||||
seriesStatusRevision := int64(0)
|
||||
if s.store != nil {
|
||||
if revision, err := s.store.SonarrSeriesStatusRevision(r.Context()); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("series status revision unavailable", "error", err)
|
||||
} else {
|
||||
seriesStatusRevision = revision
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"maintenance": state.Enabled,
|
||||
"quietTime": quiet.Active,
|
||||
@@ -156,6 +164,9 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||
// delivery channel for an operator pushing someone's settings.
|
||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||
// The TV keeps full item details in memory. This revision lets a daily Sonarr
|
||||
// transition evict that local copy before the same show is opened again.
|
||||
"seriesStatusRevision": seriesStatusRevision,
|
||||
// The small metadata hero presentation document rides the existing poll so a saved
|
||||
// layout or colour reaches an open launcher immediately, without manufacturing a
|
||||
// per-user preference revision for a global change.
|
||||
|
||||
@@ -236,11 +236,12 @@ func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
|
||||
|
||||
// decorateItems is the one door items leave the gateway through.
|
||||
//
|
||||
// It attaches both of the things Memby knows about a title that Emby's payload does not
|
||||
// carry: the stored review scores, and — for a shadow viewer — whose progress this is. The
|
||||
// two are separate concerns and stayed separate functions, but every call site wanted both,
|
||||
// and a decoration added at seven sites is a decoration missing from the eighth.
|
||||
// It attaches the facts Memby knows about a title beyond Emby's payload: the latest stored
|
||||
// Sonarr series status, stored review scores, and — for a shadow viewer — whose progress
|
||||
// this is. The concerns stay in separate functions, but every call site needs one door;
|
||||
// a decoration added at seven sites is a decoration missing from the eighth.
|
||||
func (s *Server) decorateItems(ctx context.Context, collections ...[]json.RawMessage) {
|
||||
s.decorateSeriesStatuses(ctx, collections...)
|
||||
s.decorateItemRatings(ctx, collections...)
|
||||
s.decorateViewerState(ctx, collections...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decorateSeriesStatuses makes Status on an ordinary Emby series the canonical detail
|
||||
// answer. Sonarr's daily observation wins when it is recognised; otherwise Emby's field
|
||||
// is left untouched as the fallback.
|
||||
func (s *Server) decorateSeriesStatuses(ctx context.Context, collections ...[]json.RawMessage) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
tvdbIDs := []int{}
|
||||
seen := map[int]bool{}
|
||||
identities := make([]map[int]int, len(collections))
|
||||
for collectionIndex, items := range collections {
|
||||
identities[collectionIndex] = map[int]int{}
|
||||
for itemIndex, raw := range items {
|
||||
tvdbID := seriesTVDBID(raw)
|
||||
if tvdbID <= 0 {
|
||||
continue
|
||||
}
|
||||
identities[collectionIndex][itemIndex] = tvdbID
|
||||
if !seen[tvdbID] {
|
||||
seen[tvdbID] = true
|
||||
tvdbIDs = append(tvdbIDs, tvdbID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tvdbIDs) == 0 {
|
||||
return
|
||||
}
|
||||
statuses, err := s.store.LatestSonarrSeriesStatuses(ctx, tvdbIDs)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("stored Sonarr series statuses unavailable", "error", err)
|
||||
return
|
||||
}
|
||||
for collectionIndex, itemIndexes := range identities {
|
||||
for itemIndex, tvdbID := range itemIndexes {
|
||||
status := canonicalSonarrSeriesStatus(statuses[tvdbID])
|
||||
if status != "" {
|
||||
collections[collectionIndex][itemIndex] = injectSeriesStatus(
|
||||
collections[collectionIndex][itemIndex], status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seriesTVDBID(raw json.RawMessage) int {
|
||||
var item struct {
|
||||
Type string `json:"Type"`
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
if json.Unmarshal(raw, &item) != nil || !strings.EqualFold(item.Type, "Series") {
|
||||
return 0
|
||||
}
|
||||
id, _ := strconv.Atoi(providerID(item.ProviderIDs, "tvdb"))
|
||||
return id
|
||||
}
|
||||
|
||||
// canonicalSonarrSeriesStatus is deliberately conservative. Unknown values are not
|
||||
// copied over a usable Emby status, and American spelling is normalised at the render
|
||||
// boundary before it can become viewer-facing text.
|
||||
func canonicalSonarrSeriesStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "continuing":
|
||||
return "Continuing"
|
||||
case "ended":
|
||||
return "Ended"
|
||||
case "cancelled", "canceled":
|
||||
return "Cancelled"
|
||||
case "upcoming":
|
||||
return "Upcoming"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func injectSeriesStatus(raw json.RawMessage, status string) json.RawMessage {
|
||||
if status == "" {
|
||||
return raw
|
||||
}
|
||||
var members map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &members) != nil || members == nil {
|
||||
return raw
|
||||
}
|
||||
encoded, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
members["Status"] = encoded
|
||||
out, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalSonarrSeriesStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" continuing ": "Continuing",
|
||||
"ended": "Ended",
|
||||
"cancelled": "Cancelled",
|
||||
"canceled": "Cancelled",
|
||||
"upcoming": "Upcoming",
|
||||
"deleted": "",
|
||||
"unknown": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := canonicalSonarrSeriesStatus(input); got != want {
|
||||
t.Errorf("canonicalSonarrSeriesStatus(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSeriesStatusReplacesEmbyWithoutDroppingFields(t *testing.T) {
|
||||
raw := json.RawMessage(`{"Id":"series-1","Status":"Continuing","FutureField":true}`)
|
||||
decorated := injectSeriesStatus(raw, "Ended")
|
||||
var item struct {
|
||||
Status string `json:"Status"`
|
||||
FutureField bool `json:"FutureField"`
|
||||
}
|
||||
if err := json.Unmarshal(decorated, &item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Status != "Ended" || !item.FutureField {
|
||||
t.Fatalf("decorated item = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesTVDBIDOnlyAcceptsSeries(t *testing.T) {
|
||||
series := json.RawMessage(`{"Type":"Series","ProviderIds":{"Tvdb":"1234"}}`)
|
||||
if got := seriesTVDBID(series); got != 1234 {
|
||||
t.Fatalf("series TVDB id = %d, want 1234", got)
|
||||
}
|
||||
movie := json.RawMessage(`{"Type":"Movie","ProviderIds":{"Tvdb":"1234"}}`)
|
||||
if got := seriesTVDBID(movie); got != 0 {
|
||||
t.Fatalf("movie TVDB id = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user