0.2.67 - Detail pages improvements

This commit is contained in:
ponzischeme89
2026-08-16 09:02:17 +12:00
parent 4bc4b075ec
commit bd3732fba5
31 changed files with 2479 additions and 562 deletions
+1
View File
@@ -245,6 +245,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
v1.Handle("GET /v1/items/{id}/extras", s.authed(s.handleExtras))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume))
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
// extrasResponse is the Extras tab: featurettes, deleted scenes, interviews and the local
// trailer, as Emby's own item JSON so the television's single BaseItem model decodes it.
type extrasResponse struct {
Items []json.RawMessage `json:"items"`
}
// handleExtras joins the two lists Emby keeps an item's supplementary video in.
//
// It has to be a join, because Emby answers the question in two places and neither includes
// the other: `SpecialFeatures` holds the featurettes and deleted scenes, `LocalTrailers`
// holds the trailer, and a detail page asking only one of them shows an Extras tab missing
// the thing most titles that have anything actually have.
//
// Done here rather than on the television for the ordinary reason: in gateway mode the set
// holds no Emby credential. Doing it here also means one request instead of two on the path
// that matters — the tab is probed on every detail page open.
func (s *Server) handleExtras(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "extras:v1:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
cred := credentials(sess)
// Neither list failing is fatal, and only both failing is a failure worth reporting: a
// title can perfectly well have a trailer and no special features, or the reverse, and
// an Extras tab withheld because one of two lookups was unwell is the worse outcome.
var items []json.RawMessage
trailerErr := error(nil)
if result, err := s.emby.LocalTrailers(ctx, cred, itemID); err == nil {
items = append(items, result.Items...)
} else {
trailerErr = err
}
featureErr := error(nil)
if result, err := s.emby.SpecialFeatures(ctx, cred, itemID); err == nil {
items = append(items, result.Items...)
} else {
featureErr = err
}
if trailerErr != nil && featureErr != nil {
s.writeUpstreamError(ctx, w, featureErr, "could not load extras")
return
}
items = dedupeExtras(items)
body, err := json.Marshal(extrasResponse{Items: nonNilRaws(items)})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode extras")
return
}
// Cached for the ordinary item lifetime, empty answers included. Most of a library has
// no extras at all and every detail page asks, so the "no" is the entry worth keeping.
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("extras cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// dedupeExtras drops the same file appearing in both lists, in first-seen order.
//
// A trailer filed as a special feature *and* as a local trailer is an ordinary way for a
// library to be laid out, and the television renders these into a keyed grid — which throws
// on a repeated key rather than merely looking wrong. Anything without an id is dropped for
// the same reason.
func dedupeExtras(items []json.RawMessage) []json.RawMessage {
seen := make(map[string]struct{}, len(items))
out := make([]json.RawMessage, 0, len(items))
for _, raw := range items {
var identified struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &identified); err != nil || identified.ID == "" {
continue
}
if _, repeated := seen[identified.ID]; repeated {
continue
}
seen[identified.ID] = struct{}{}
out = append(out, raw)
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
// The two lists Emby answers with can name the same file, and the television renders the
// result into a keyed grid — which throws on a repeated key rather than merely looking
// wrong. First-seen order is kept, so the trailer stays at the front where the join put it.
func TestDedupeExtrasKeepsFirstOfEachID(t *testing.T) {
items := []json.RawMessage{
json.RawMessage(`{"Id":"trailer-1","Name":"Official Trailer","Type":"Trailer"}`),
json.RawMessage(`{"Id":"feature-1","Name":"Four Winters","Type":"Featurette"}`),
json.RawMessage(`{"Id":"trailer-1","Name":"Official Trailer","Type":"Trailer"}`),
json.RawMessage(`{"Id":"feature-2","Name":"Deleted: The Crossing","Type":"DeletedScene"}`),
}
got := dedupeExtras(items)
if len(got) != 3 {
t.Fatalf("expected 3 extras after deduplication, got %d", len(got))
}
ids := extraIDs(t, got)
want := []string{"trailer-1", "feature-1", "feature-2"}
for index, id := range want {
if ids[index] != id {
t.Fatalf("extra %d was %q, want %q (order: %v)", index, ids[index], id, ids)
}
}
}
// An entry the television could not key is dropped rather than passed on. Nothing
// downstream can tell one blank id from another.
func TestDedupeExtrasDropsUnidentifiableEntries(t *testing.T) {
items := []json.RawMessage{
json.RawMessage(`{"Name":"Nameless","Type":"Clip"}`),
json.RawMessage(`{"Id":"","Name":"Blank","Type":"Clip"}`),
json.RawMessage(`not json at all`),
json.RawMessage(`{"Id":"clip-1","Name":"A Clip","Type":"Clip"}`),
}
got := dedupeExtras(items)
if len(got) != 1 {
t.Fatalf("expected only the identified extra to survive, got %d", len(got))
}
if ids := extraIDs(t, got); ids[0] != "clip-1" {
t.Fatalf("survivor was %q, want clip-1", ids[0])
}
}
func TestDedupeExtrasOnNothing(t *testing.T) {
if got := dedupeExtras(nil); len(got) != 0 {
t.Fatalf("expected no extras, got %d", len(got))
}
}
// The Details tab is the only reader of these, and every one of them is absent from Emby's
// response unless it is asked for by name — so a field quietly dropped from the query is a
// row that silently stops appearing rather than anything that fails.
func TestDetailFieldsCarryTheDetailsTabVocabulary(t *testing.T) {
fields := make(map[string]bool)
for _, field := range strings.Split(fieldsDetail, ",") {
fields[field] = true
}
for _, required := range []string{
"Studios", "Taglines", "PremiereDate", "OriginalTitle", "ProductionLocations",
} {
if !fields[required] {
t.Errorf("detail responses must request %q for the Details tab", required)
}
}
}
func extraIDs(t *testing.T, items []json.RawMessage) []string {
t.Helper()
ids := make([]string, 0, len(items))
for _, raw := range items {
var identified struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &identified); err != nil {
t.Fatalf("could not read back an extra: %v", err)
}
ids = append(ids, identified.ID)
}
return ids
}
+6 -1
View File
@@ -32,7 +32,12 @@ const (
// Status is a series' production state ("Continuing"/"Ended"). The television needs it
// to decide whether it is estimating a finish or a catch-up, and the detail call is
// where a field like this belongs — adding it to a home query is a startup cost.
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
//
// Studios, Taglines, PremiereDate, OriginalTitle and ProductionLocations are the Details
// 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"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
+1 -1
View File
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
}
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People or the stored ratings.
key := cache.UserKey(sess.EmbyUserID, "item:v5:"+itemID)
key := cache.UserKey(sess.EmbyUserID, "item:v6:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
+4 -1
View File
@@ -48,7 +48,10 @@ func TestScheduleRowsCarryLifecycle(t *testing.T) {
airs := now.Add(3 * time.Hour)
series := buildSonarrRowForTest(t, sonarr.Episode{
ID: 7, SeriesID: 3, SeasonNumber: 2, EpisodeNumber: 4, AirDateUTC: &airs,
Monitored: true, Series: sonarr.Series{ID: 3, Title: "Severance", Status: "continuing"},
Monitored: true,
Series: sonarr.Series{
ID: 3, Title: "Severance", Status: "continuing", Monitored: true,
},
}, now)
if series.MembyLifecycle != "continuing" || series.MembyLifecycleText != "CONTINUING" {
t.Fatalf("sonarr card lifecycle = %q/%q", series.MembyLifecycle, series.MembyLifecycleText)
+5 -3
View File
@@ -12,9 +12,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
// fieldsRelated adds Studios to the detail set: the explanation layer names the studio a
// viewer keeps returning to, and Emby omits it unless asked.
const fieldsRelated = fieldsDetail + ",Studios"
// fieldsRelated is the detail set. It once added Studios on its own account — the
// explanation layer names the studio a viewer keeps returning to and the Details tab has
// since put Studios in [fieldsDetail] for everybody. Kept as its own name so the
// explanation layer's requirement stays stated rather than depending on another feature.
const fieldsRelated = fieldsDetail
// relatedResponse is what the detail page renders: a strip of short reasons under the
// description, and the carousel beneath the page.
+13 -1
View File
@@ -416,7 +416,7 @@ func buildSonarrRow(
dayStart := localDayStart(now, location)
windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays)
for _, episode := range episodes {
if episode.AirDateUTC == nil {
if episode.AirDateUTC == nil || !scheduleShowFollowed(episode) {
continue
}
localAirTime := episode.AirDateUTC.In(location)
@@ -438,6 +438,18 @@ func buildSonarrRow(
}, nil
}
// scheduleShowFollowed answers whether a show is one the household is actually waiting
// for. Sonarr's calendar is asked with unmonitored=true — the calendar page and the aired
// banners both want the whole picture — but the launcher row answers "what is coming",
// and an unmonitored show is never coming. Absence of a series record is not evidence of
// anything, so an episode carrying no expanded series is kept rather than dropped.
func scheduleShowFollowed(episode sonarr.Episode) bool {
if episode.Series.ID <= 0 {
return true
}
return episode.Series.Monitored
}
func toSonarrScheduleItem(
episode sonarr.Episode,
now time.Time,
+41 -4
View File
@@ -23,8 +23,9 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
Monitored: true,
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
Series: sonarr.Series{
ID: 7,
Title: "Northbound",
ID: 7,
Title: "Northbound",
Monitored: true,
Images: []sonarr.Image{
{CoverType: "poster"},
{CoverType: "fanart"},
@@ -52,6 +53,42 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
}
}
// Sonarr's calendar is asked with unmonitored=true, because the calendar page and the
// aired banners want the whole picture. The launcher row does not: a show nobody follows
// is never coming, so it has no business being announced as airing.
func TestBuildSonarrRowDropsUnmonitoredShows(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
episode := func(id int, series sonarr.Series) sonarr.Episode {
utc := air
return sonarr.Episode{
ID: id, SeriesID: series.ID, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc, Monitored: true, Series: series,
}
}
row, err := buildSonarrRow([]sonarr.Episode{
episode(1, sonarr.Series{ID: 1, Title: "Followed", Monitored: true}),
episode(2, sonarr.Series{ID: 2, Title: "Dropped"}),
// No expanded series record is no evidence either way, so the card stays.
episode(3, sonarr.Series{}),
}, now, location, nil)
if err != nil {
t.Fatal(err)
}
ids := make([]string, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
ids = append(ids, item.ID)
}
if len(ids) != 2 || ids[0] != "sonarr:1:1" || ids[1] != "sonarr:0:3" {
t.Fatalf("unmonitored shows were not dropped: %v", ids)
}
}
func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
@@ -65,7 +102,7 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
utc := air
return sonarr.Episode{
ID: 1, SeriesID: 1, SeasonNumber: 1, EpisodeNumber: 1, AirDateUTC: &utc,
Series: sonarr.Series{ID: 1, Title: title, Year: year},
Series: sonarr.Series{ID: 1, Title: title, Year: year, Monitored: true},
}
}
cases := map[string]struct {
@@ -169,7 +206,7 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
return sonarr.Episode{
ID: id, SeriesID: id, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc,
Series: sonarr.Series{ID: id, Title: "Show"},
Series: sonarr.Series{ID: id, Title: "Show", Monitored: true},
}
}
row, err := buildSonarrRow([]sonarr.Episode{
+19
View File
@@ -335,6 +335,25 @@ func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID str
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
// SpecialFeatures returns the featurettes, deleted scenes and interviews Emby holds beside
// an item's media file.
//
// Deliberately separate from [Client.LocalTrailers]: Emby keeps trailers out of this list
// entirely, so the Extras tab is the join of the two and neither endpoint can answer for the
// other. Like LocalTrailers it answers with a bare array rather than an Items envelope.
func (c *Client) SpecialFeatures(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/SpecialFeatures"
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
if err != nil {
return nil, err
}
var items []json.RawMessage
if err := c.do(req, &items); err != nil {
return nil, err
}
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
params := url.Values{}
if fields != "" {