0.2.67 - Detail pages improvements
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user