365 lines
14 KiB
Go
365 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// The television has one UserData reader and it must not be able to tell where the block
|
|
// came from. This pins the field names against the client's UserItemData, which is the
|
|
// contract that keeps viewers from becoming a special case in every screen.
|
|
func TestViewerUserDataIsShapedLikeEmbys(t *testing.T) {
|
|
played := time.Date(2026, 8, 19, 21, 14, 0, 0, time.UTC)
|
|
raw := viewerUserData(store.ViewerState{
|
|
ItemID: "982173",
|
|
PositionTicks: 15_420_000_000,
|
|
RuntimeTicks: 30_840_000_000,
|
|
PlayCount: 2,
|
|
Favourite: true,
|
|
LastPlayedAt: &played,
|
|
})
|
|
|
|
var parsed struct {
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
Played bool `json:"Played"`
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
PlayCount int `json:"PlayCount"`
|
|
PlayedPercentage *float64 `json:"PlayedPercentage"`
|
|
LastPlayedDate string `json:"LastPlayedDate"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("decode viewer user data: %v", err)
|
|
}
|
|
if !parsed.IsFavorite {
|
|
t.Error("favourite was not carried")
|
|
}
|
|
if parsed.Played {
|
|
t.Error("a part-watched title was reported as played")
|
|
}
|
|
if parsed.PlaybackPositionTicks != 15_420_000_000 {
|
|
t.Errorf("position = %d", parsed.PlaybackPositionTicks)
|
|
}
|
|
if parsed.PlayCount != 2 {
|
|
t.Errorf("play count = %d", parsed.PlayCount)
|
|
}
|
|
if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 49 || *parsed.PlayedPercentage > 51 {
|
|
t.Errorf("played percentage = %v, want about 50", parsed.PlayedPercentage)
|
|
}
|
|
if parsed.LastPlayedDate != "2026-08-19T21:14:00Z" {
|
|
t.Errorf("last played = %q", parsed.LastPlayedDate)
|
|
}
|
|
}
|
|
|
|
// A title nobody has touched has to render as untouched rather than as a card claiming a
|
|
// zero-length progress bar, so the two optional fields are omitted rather than sent empty.
|
|
func TestViewerUserDataOmitsWhatItDoesNotKnow(t *testing.T) {
|
|
raw := viewerUserData(store.ViewerState{ItemID: "982173"})
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
t.Fatalf("decode viewer user data: %v", err)
|
|
}
|
|
if _, ok := fields["PlayedPercentage"]; ok {
|
|
t.Error("a percentage was claimed for a title with no runtime or position")
|
|
}
|
|
if _, ok := fields["LastPlayedDate"]; ok {
|
|
t.Error("a play date was claimed for a title that has never been played")
|
|
}
|
|
if fields["Played"] != false || fields["IsFavorite"] != false {
|
|
t.Errorf("untouched state rendered as %v", fields)
|
|
}
|
|
}
|
|
|
|
// IsMain is what every mutation branches on, so it is worth stating that it reads the
|
|
// stored kind rather than guessing from the id.
|
|
func TestViewerIsMainReadsTheStoredKind(t *testing.T) {
|
|
if !(store.Viewer{ID: "abc", Kind: store.ViewerMain}).IsMain() {
|
|
t.Error("a main viewer did not report as main")
|
|
}
|
|
if (store.Viewer{ID: "abc", Kind: store.ViewerShadow}).IsMain() {
|
|
t.Error("a shadow viewer reported as main")
|
|
}
|
|
if (store.Viewer{ID: "abc"}).IsMain() {
|
|
t.Error("a viewer with no kind reported as main")
|
|
}
|
|
}
|
|
|
|
// The ids are the answer, not just the selection: a shadow viewer's Continue Watching is
|
|
// ordered by their own history in Postgres, and Emby answers an Ids= query in its own
|
|
// order. Handing that back would keep the right titles and discard the reason for them.
|
|
func TestOrderItemsByIDRestoresTheOrderAskedFor(t *testing.T) {
|
|
item := func(id string) json.RawMessage {
|
|
return json.RawMessage(`{"Id":"` + id + `","Name":"` + id + `"}`)
|
|
}
|
|
idsOf := func(items []json.RawMessage) []string {
|
|
out := []string{}
|
|
for _, raw := range items {
|
|
out = append(out, itemIDOf(raw))
|
|
}
|
|
return out
|
|
}
|
|
|
|
t.Run("emby's order is replaced", func(t *testing.T) {
|
|
got := orderItemsByID(
|
|
[]json.RawMessage{item("c"), item("a"), item("b")},
|
|
[]string{"b", "c", "a"},
|
|
)
|
|
want := []string{"b", "c", "a"}
|
|
if diff := idsOf(got); !equalStrings(diff, want) {
|
|
t.Fatalf("order = %v, want %v", diff, want)
|
|
}
|
|
})
|
|
|
|
// A title the catalogue still names but Emby will not answer for leaves a card that
|
|
// cannot be opened, so it is dropped instead.
|
|
t.Run("a missing title is dropped", func(t *testing.T) {
|
|
got := orderItemsByID([]json.RawMessage{item("a")}, []string{"a", "gone", "b"})
|
|
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
|
|
t.Fatalf("order = %v, want [a]", diff)
|
|
}
|
|
})
|
|
|
|
// Every keyed list on the television throws on a repeated key, and a paging boundary
|
|
// or a duplicated row is exactly where an id comes back twice.
|
|
t.Run("a repeated id draws one card", func(t *testing.T) {
|
|
got := orderItemsByID([]json.RawMessage{item("a"), item("a")}, []string{"a", "a"})
|
|
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
|
|
t.Fatalf("order = %v, want one card", diff)
|
|
}
|
|
})
|
|
|
|
// Anything Emby volunteers that was not asked for is not part of the answer.
|
|
t.Run("an unasked title is dropped", func(t *testing.T) {
|
|
got := orderItemsByID([]json.RawMessage{item("a"), item("z")}, []string{"a"})
|
|
if diff := idsOf(got); !equalStrings(diff, []string{"a"}) {
|
|
t.Fatalf("order = %v, want [a]", diff)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The account's UserData must be replaced outright rather than merged: a partial overlay
|
|
// leaves whichever fields Memby had nothing to say about carrying the account's values.
|
|
func TestInjectItemUserDataReplacesRatherThanMerges(t *testing.T) {
|
|
raw := json.RawMessage(
|
|
`{"Id":"1","Name":"Anatomy of a Fall",` +
|
|
`"UserData":{"Played":true,"PlaybackPositionTicks":9999,"IsFavorite":true,"PlayCount":4}}`)
|
|
out := injectItemUserData(raw, viewerUserData(store.ViewerState{ItemID: "1"}))
|
|
|
|
var parsed struct {
|
|
Name string `json:"Name"`
|
|
UserData struct {
|
|
Played bool `json:"Played"`
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
PlayCount int `json:"PlayCount"`
|
|
} `json:"UserData"`
|
|
}
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if parsed.Name != "Anatomy of a Fall" {
|
|
t.Errorf("the rest of the item was disturbed: name = %q", parsed.Name)
|
|
}
|
|
if parsed.UserData.Played || parsed.UserData.IsFavorite {
|
|
t.Error("the account's watched or favourite state survived")
|
|
}
|
|
if parsed.UserData.PlaybackPositionTicks != 0 || parsed.UserData.PlayCount != 0 {
|
|
t.Errorf("the account's position or play count survived: %+v", parsed.UserData)
|
|
}
|
|
}
|
|
|
|
// A series and a season are answered from a count of episodes rather than from a row of
|
|
// their own, so they have to be told apart from the leaf items around them.
|
|
func TestAggregateItemsAreRecognised(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
itemType string
|
|
want bool
|
|
}{
|
|
{"Series", true},
|
|
{"Season", true},
|
|
{"Episode", false},
|
|
{"Movie", false},
|
|
{"", false},
|
|
} {
|
|
raw := json.RawMessage(`{"Id":"1","Type":"` + tc.itemType + `"}`)
|
|
if got := isAggregateItem(raw); got != tc.want {
|
|
t.Errorf("isAggregateItem(%q) = %v, want %v", tc.itemType, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Everything downstream keys its cached views on this, so a request that never passed
|
|
// through authed must still answer with the value those keys held before viewers existed.
|
|
func TestViewerOfFallsBackToTheAccount(t *testing.T) {
|
|
sess := store.Session{EmbyUserID: "emby-user-1", Username: "Matt"}
|
|
viewer := viewerOf(context.Background(), sess)
|
|
if !viewer.IsMain() || viewer.ID != "emby-user-1" {
|
|
t.Fatalf("fallback viewer = %+v, want the account as main", viewer)
|
|
}
|
|
if got := viewerKeyOf(context.Background(), sess); got != "emby-user-1" {
|
|
t.Fatalf("cache key = %q, want the Emby user id", got)
|
|
}
|
|
|
|
shadow := store.Viewer{ID: "v0123", Name: "Alessandra", Kind: store.ViewerShadow}
|
|
ctx := withViewer(context.Background(), shadow)
|
|
if got := viewerKeyOf(ctx, sess); got != "v0123" {
|
|
t.Fatalf("cache key = %q, want the shadow viewer", got)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// The last place a shadow viewer was shown the account's answer. A series card draws its
|
|
// tick and its "left to watch" from these fields, and Emby fills them in from children it
|
|
// has never heard of for this person.
|
|
func TestViewerAggregateUserDataCountsWhatIsLeft(t *testing.T) {
|
|
played := time.Date(2026, 8, 18, 20, 5, 0, 0, time.UTC)
|
|
raw := viewerAggregateUserData(
|
|
store.ViewerState{ItemID: "series-1", Favourite: true},
|
|
store.ViewerAggregate{Total: 10, Played: 4, LastPlayedAt: &played},
|
|
)
|
|
|
|
var parsed struct {
|
|
IsFavorite bool `json:"IsFavorite"`
|
|
Played bool `json:"Played"`
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
UnplayedItemCount *int `json:"UnplayedItemCount"`
|
|
PlayedPercentage *float64 `json:"PlayedPercentage"`
|
|
LastPlayedDate string `json:"LastPlayedDate"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("decode aggregate user data: %v", err)
|
|
}
|
|
// The favourite is the container's own — somebody marks a show, not the sum of its
|
|
// episodes — so it comes from the row rather than from the count.
|
|
if !parsed.IsFavorite {
|
|
t.Error("the viewer's own favourite on the series was dropped")
|
|
}
|
|
if parsed.Played {
|
|
t.Error("a part-watched series reported as finished")
|
|
}
|
|
if parsed.UnplayedItemCount == nil || *parsed.UnplayedItemCount != 6 {
|
|
t.Errorf("unplayed = %v, want 6", parsed.UnplayedItemCount)
|
|
}
|
|
if parsed.PlayedPercentage == nil || *parsed.PlayedPercentage < 39 || *parsed.PlayedPercentage > 41 {
|
|
t.Errorf("played percentage = %v, want about 40", parsed.PlayedPercentage)
|
|
}
|
|
// A container is never resumable; what resumes is an episode.
|
|
if parsed.PlaybackPositionTicks != 0 {
|
|
t.Errorf("a series carried a resume position: %d", parsed.PlaybackPositionTicks)
|
|
}
|
|
if parsed.LastPlayedDate != "2026-08-18T20:05:00Z" {
|
|
t.Errorf("last played = %q", parsed.LastPlayedDate)
|
|
}
|
|
|
|
finished := viewerAggregateUserData(
|
|
store.ViewerState{}, store.ViewerAggregate{Total: 10, Played: 10},
|
|
)
|
|
var done map[string]any
|
|
if err := json.Unmarshal(finished, &done); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if done["Played"] != true {
|
|
t.Errorf("a fully watched series did not report as played: %v", done)
|
|
}
|
|
if count, ok := done["UnplayedItemCount"].(float64); !ok || count != 0 {
|
|
t.Errorf("unplayed on a finished series = %v, want 0", done["UnplayedItemCount"])
|
|
}
|
|
}
|
|
|
|
// "Nothing left to watch" and "I cannot say how much there is" are different answers, and
|
|
// only one of them is true for a library the catalogue has not imported yet. Ticking every
|
|
// show in the house is the worst thing this could do.
|
|
func TestViewerAggregateUserDataSaysNothingItCannotCount(t *testing.T) {
|
|
raw := viewerAggregateUserData(store.ViewerState{}, store.ViewerAggregate{})
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if _, ok := fields["UnplayedItemCount"]; ok {
|
|
t.Error("a count was claimed for a series the catalogue cannot count")
|
|
}
|
|
if _, ok := fields["PlayedPercentage"]; ok {
|
|
t.Error("a percentage was claimed with nothing to divide by")
|
|
}
|
|
if _, ok := fields["LastPlayedDate"]; ok {
|
|
t.Error("a play date was claimed for a series nobody has watched")
|
|
}
|
|
if fields["Played"] != false {
|
|
t.Errorf("an uncountable series reported as watched: %v", fields)
|
|
}
|
|
}
|
|
|
|
// A season is looked up by its own id, and its series is asked for alongside it, because
|
|
// the catalogue's indexed column is the series. Getting that wrong costs the season card
|
|
// its count on exactly the page — a series detail page — where seasons appear.
|
|
func TestContainerIDsCollectSeriesAndSeasons(t *testing.T) {
|
|
items := []json.RawMessage{
|
|
json.RawMessage(`{"Id":"ep-1","Type":"Episode","SeriesId":"show-1"}`),
|
|
json.RawMessage(`{"Id":"show-1","Type":"Series"}`),
|
|
json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`),
|
|
json.RawMessage(`{"Id":"season-2","Type":"Season","SeriesId":"show-2"}`),
|
|
json.RawMessage(`{"Id":"film-1","Type":"Movie"}`),
|
|
}
|
|
seriesIDs, seasonIDs := containerIDsIn([][]json.RawMessage{items})
|
|
|
|
if !equalStrings(seasonIDs, []string{"season-2"}) {
|
|
t.Errorf("seasons = %v, want one season and no repeat", seasonIDs)
|
|
}
|
|
// show-2 is there because the season named it; show-1 because it is a card in its own
|
|
// right. Neither the episode nor the film contributes a container.
|
|
if !equalStrings(seriesIDs, []string{"show-1", "show-2"}) {
|
|
t.Errorf("series = %v, want the series card and the season's parent", seriesIDs)
|
|
}
|
|
}
|
|
|
|
// Switched off, an account looks like one nobody has added anybody to — which is how the
|
|
// television decides not to offer the picker. It must never look like an account whose
|
|
// list failed to load.
|
|
func TestMainViewerOnlyLeavesTheAccount(t *testing.T) {
|
|
viewers := []store.Viewer{
|
|
{ID: "emby-user-1", Name: "Matt", Kind: store.ViewerMain},
|
|
{ID: "v01", Name: "Alessandra", Kind: store.ViewerShadow},
|
|
{ID: "v02", Name: "Guest", Kind: store.ViewerShadow},
|
|
}
|
|
got := mainViewerOnly(viewers)
|
|
if len(got) != 1 || !got[0].IsMain() || got[0].ID != "emby-user-1" {
|
|
t.Fatalf("switched-off list = %+v, want the account alone", got)
|
|
}
|
|
if mainViewerOnly(nil) != nil {
|
|
t.Error("a list with no main viewer invented one")
|
|
}
|
|
}
|
|
|
|
// The switch is the operator's and rides the ordinary feature machinery, so what is worth
|
|
// pinning is that it is *in* the catalogue and gated on a capability — a household half of
|
|
// whose televisions cannot choose between people must not be offered it.
|
|
func TestViewersIsAnOperatorFeature(t *testing.T) {
|
|
definition, ok := knownFeature(featureViewers)
|
|
if !ok {
|
|
t.Fatal("viewers is not in the feature catalogue")
|
|
}
|
|
if definition.Capability != "viewers_v1" {
|
|
t.Errorf("capability = %q, want viewers_v1", definition.Capability)
|
|
}
|
|
// Off by default, and deliberately so: this is the switch deciding where a household's
|
|
// watched state is written, and a feature that arrives already on is one every server
|
|
// running this build starts using before anybody decided to.
|
|
if definition.DefaultEnabled {
|
|
t.Error("viewers defaults on; it is opted into rather than out of")
|
|
}
|
|
}
|