Files
ponzischeme89andClaude Opus 5 80c304d86b App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:44:17 +12:00

192 lines
6.3 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The intro rule, pinned against the same cases as the television's copy
// (`IntroTest` in app/src/test). The two exist separately because with no gateway there is
// nobody to ask, and a skip must not land somewhere different depending on whether the
// container is up — so when one of these changes, the other has to change with it.
//
// The cases are taken from what Emby actually writes: markers arrive as ordinary chapters
// carrying a MarkerType, interleaved with the real ones in playback order.
func chapter(seconds int64, marker string) embyChapter {
return embyChapter{
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
MarkerType: marker,
Name: marker,
}
}
func TestIntroFromChapters(t *testing.T) {
cases := []struct {
name string
chapters []embyChapter
want introSegment
ok bool
}{
{
// FROM S01E01 as the server actually holds it: two ordinary chapters, then the
// pair, then the rest of the chapters.
name: "a real episode",
chapters: []embyChapter{
chapter(0, "Chapter"),
chapter(300, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(600, "Chapter"),
},
want: introSegment{StartMs: 463_000, EndMs: 583_000},
ok: true,
},
{
name: "an intro that starts at the very beginning",
chapters: []embyChapter{
chapter(0, "IntroStart"),
chapter(95, "IntroEnd"),
},
want: introSegment{StartMs: 0, EndMs: 95_000},
ok: true,
},
{
name: "no markers at all",
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
},
{
name: "no chapters at all",
chapters: nil,
},
{
// Half a pair is not a segment. There is no honest end to skip to, and
// guessing one is how a viewer lands in the middle of a scene.
name: "a start with no end",
chapters: []embyChapter{chapter(112, "IntroStart"), chapter(300, "Chapter")},
},
{
name: "an end with no start",
chapters: []embyChapter{chapter(0, "Chapter"), chapter(246, "IntroEnd")},
},
{
name: "an end before its start",
chapters: []embyChapter{chapter(300, "IntroStart"), chapter(120, "IntroEnd")},
},
{
// Emby occasionally writes a pair seconds apart on a title whose opening it
// half-recognised. A button that moves the picture imperceptibly reads as
// broken, so there is deliberately no button at all.
name: "a segment too short to be an intro",
chapters: []embyChapter{chapter(100, "IntroStart"), chapter(103, "IntroEnd")},
},
{
// Far more likely two unrelated markers read as a range than a ten-minute
// title sequence, and honouring it would throw the viewer past the story.
name: "a segment too long to be an intro",
chapters: []embyChapter{chapter(60, "IntroStart"), chapter(660, "IntroEnd")},
},
{
// The first start wins. Two starts mean the markers are already untrustworthy,
// and taking the later one would pick the larger, more damaging skip.
name: "two starts before one end",
chapters: []embyChapter{
chapter(100, "IntroStart"),
chapter(160, "IntroStart"),
chapter(220, "IntroEnd"),
},
want: introSegment{StartMs: 100_000, EndMs: 220_000},
ok: true,
},
{
name: "a later pair is ignored once one has been found",
chapters: []embyChapter{
chapter(100, "IntroStart"),
chapter(220, "IntroEnd"),
chapter(1800, "IntroStart"),
chapter(1900, "IntroEnd"),
},
want: introSegment{StartMs: 100_000, EndMs: 220_000},
ok: true,
},
{
// A credits marker is a different feature that does not exist here yet, and
// must never be mistaken for an intro.
name: "credit markers are not intros",
chapters: []embyChapter{
chapter(2800, "CreditsStart"),
chapter(2900, "CreditsEnd"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := introFromChapters(tc.chapters)
if ok != tc.ok {
t.Fatalf("available = %v, want %v (segment %+v)", ok, tc.ok, got)
}
if ok && got != tc.want {
t.Fatalf("segment = %+v, want %+v", got, tc.want)
}
})
}
}
// A gateway with no Emby behind it — and one whose operator has turned the feature off —
// answers "no intro" rather than an error. The button is an optional convenience on a film
// that is already playing, and a 502 here would be a red line in the log for every episode
// anybody watched.
func TestIntroWithoutEmbyAnswersUnavailable(t *testing.T) {
server := testServer(config.Config{})
request := httptest.NewRequest(http.MethodGet, "/v1/items/1304864/intro", nil)
request.SetPathValue("id", "1304864")
rec := httptest.NewRecorder()
server.handleIntro(rec, request, store.Session{})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 — trouble is answered with silence here", rec.Code)
}
var body introResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
if body.Available {
t.Fatal("a gateway with nothing to ask must never claim an intro")
}
}
// The mode a television is told to use has to be one this build knows, or a set could be
// pushed a value it silently does nothing with.
func TestSkipIntroPreferenceIsCatalogued(t *testing.T) {
definition, ok := preferenceDefinitionFor("skipIntroMode")
if !ok {
t.Fatal("skipIntroMode is missing from the preference catalogue")
}
if definition.Default != skipIntroPrompt {
t.Fatalf("default = %v, want %q — a button is the asked-for behaviour, not a silent seek",
definition.Default, skipIntroPrompt)
}
wanted := []string{skipIntroPrompt, skipIntroAuto, skipIntroOff}
if len(definition.Options) != len(wanted) {
t.Fatalf("options = %+v, want the three modes", definition.Options)
}
for i, value := range wanted {
if definition.Options[i].Value != value {
t.Fatalf("option %d = %q, want %q", i, definition.Options[i].Value, value)
}
}
// An illegal value must come back as the default rather than reaching a player.
normalised := normalizePreferences(map[string]any{"skipIntroMode": "immediately"})
if normalised["skipIntroMode"] != skipIntroPrompt {
t.Fatalf("normalised = %v, want %q", normalised["skipIntroMode"], skipIntroPrompt)
}
}