This commit is contained in:
ponzischeme89
2026-08-23 13:20:54 +12:00
parent 766cea2199
commit 89ebe21201
35 changed files with 1000 additions and 195 deletions
+3
View File
@@ -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)
+1 -1
View File
@@ -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"
+12 -4
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(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)
+2
View File
@@ -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:
+2
View File
@@ -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.
"": {},
+11
View File
@@ -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.
+5 -4
View File
@@ -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...)
}
+102
View File
@@ -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)
}
}
+40
View File
@@ -68,6 +68,46 @@ type SonarrSeriesStatusChange struct {
Current SonarrSeriesStatus
}
// LatestSonarrSeriesStatuses returns Sonarr's most recently observed lifecycle for each
// TVDB id. The history table is change-only, so the newest row is also the current value.
func (s *Store) LatestSonarrSeriesStatuses(
ctx context.Context, tvdbIDs []int,
) (map[int]string, error) {
statuses := map[int]string{}
if len(tvdbIDs) == 0 {
return statuses, nil
}
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (tvdb_id) tvdb_id, status
FROM sonarr_series_status_history
WHERE tvdb_id = ANY($1) AND tvdb_id > 0
ORDER BY tvdb_id, observed_at DESC, id DESC`, tvdbIDs)
if err != nil {
return nil, fmt.Errorf("store: read latest Sonarr series statuses: %w", err)
}
defer rows.Close()
for rows.Next() {
var tvdbID int
var status string
if err := rows.Scan(&tvdbID, &status); err != nil {
return nil, fmt.Errorf("store: scan latest Sonarr series status: %w", err)
}
statuses[tvdbID] = status
}
return statuses, rows.Err()
}
// SonarrSeriesStatusRevision is an opaque, monotonic version of the stored lifecycle
// catalogue. It changes whenever a scan records a first sighting or a transition.
func (s *Store) SonarrSeriesStatusRevision(ctx context.Context) (int64, error) {
var revision int64
if err := s.pool.QueryRow(ctx,
`SELECT COALESCE(max(id), 0) FROM sonarr_series_status_history`).Scan(&revision); err != nil {
return 0, fmt.Errorf("store: read Sonarr series status revision: %w", err)
}
return revision, nil
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)