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) } }