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
+5
View File
@@ -277,6 +277,11 @@ weekday and local time. Cards also show season/episode number, episode title and
upcoming, downloading, awaiting download, unmonitored, or the exact time Sonarr added
the episode file.
Only shows the household actually follows appear. Sonarr's calendar is read with
`unmonitored=true`, because the calendar page and the aired banners want the whole
picture, but an unmonitored show is never coming and so is dropped from this row. An
episode arriving with no expanded series record is kept — that is no evidence either way.
This row is informational. An episode listed before it is downloaded is not an Emby
item, so the TV deliberately does not offer Play, Favourite or Watched actions on it.
Sonarr poster and fanart requests are proxied through the gateway; its API key is never
+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 != "" {