package api import "testing" // The credits rule, pinned against the same cases as the television's copy (`CreditsTest` // in app/src/test). The two exist separately because with no gateway there is nobody to // ask, and the picture must not start shrinking at a different moment depending on whether // the container is up — so when one of these changes, the other has to change with it. // // The cases come from a survey of a real 20,000-item library, and the shape of that survey is // why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** — only // `Chapter`, `IntroStart` and `IntroEnd` — while 216 items carried a chapter *named* like // credits at 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, which is the // case the position floor exists for and the one worth never regressing. func named(seconds int64, name string) embyChapter { return embyChapter{ StartPositionTicks: seconds * 1_000 * ticksPerMillisecond, MarkerType: "Chapter", Name: name, } } // A two-thousand-second episode, so a percentage of runtime reads as a round number. const testRuntimeMs = 2_000_000 func TestCreditsFromChapters(t *testing.T) { cases := []struct { name string chapters []embyChapter runtimeMs int64 want int64 ok bool }{ { // A real episode as the server holds it: chapters, the intro pair, more // chapters, and the credits marker near the end. name: "a real episode", chapters: []embyChapter{ chapter(0, "Chapter"), chapter(463, "IntroStart"), chapter(583, "IntroEnd"), chapter(1200, "Chapter"), chapter(1900, "CreditsStart"), }, want: 1_900_000, ok: true, }, { // A film, which commonly has the credits marker and no intro at all. This is // the case that would have been lost had the two features shared one flag. name: "credits with no intro", chapters: []embyChapter{ chapter(0, "Chapter"), chapter(1400, "Chapter"), chapter(1850, "CreditsStart"), }, want: 1_850_000, ok: true, }, { name: "no markers at all", chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")}, }, { name: "no chapters at all", chapters: nil, }, { // An intro pair is a different feature and must never be read as credits. name: "intro markers are not credits", chapters: []embyChapter{ chapter(463, "IntroStart"), chapter(583, "IntroEnd"), }, }, { // A marker at zero says the whole file is credits, which is not something Emby // means and not something worth shrinking a picture for. name: "a marker at the very beginning", chapters: []embyChapter{chapter(0, "CreditsStart"), chapter(300, "Chapter")}, }, { // The last marker wins, where the intro rule takes the first. Two starts mean // the markers are untrustworthy, and the two features are damaged in opposite // directions: an intro skip that fires late throws somebody past the story, so // the earlier marker is safer there; the credits pane firing early runs the last // scene past somebody at double speed, so the later marker is safer here. name: "two starts, the later one wins", chapters: []embyChapter{ chapter(1700, "CreditsStart"), chapter(1900, "CreditsStart"), }, want: 1_900_000, ok: true, }, { // Order in the array is not trusted to be sorted, so a later marker earlier in // the list still loses to the one further into the film. name: "the later marker wins whatever order they arrive in", chapters: []embyChapter{ chapter(1900, "CreditsStart"), chapter(1700, "CreditsStart"), }, want: 1_700_000, ok: true, }, { // Squid Game, as the library actually holds it: no marker of any kind, one // ordinary chapter named "Credits" at 90% of runtime. This is where every bit of // this feature's coverage comes from today. name: "a chapter named Credits, which is all Emby 4.10 gives", chapters: []embyChapter{ chapter(0, "Chapter"), chapter(463, "IntroStart"), chapter(583, "IntroEnd"), named(1800, "Credits"), }, want: 1_800_000, ok: true, }, { name: "a chapter named End Credits", chapters: []embyChapter{named(1920, "End Credits")}, want: 1_920_000, ok: true, }, { // THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones // at 0%. Matching a name without testing the position starts the pane in the first // minute of a film and runs its opening at double speed — the worst thing this // feature could do, and the reason creditsMinimumPositionFraction exists. name: "Opening Credits at the start of a film is never the credit roll", chapters: []embyChapter{ named(20, "Opening Credits"), chapter(600, "Chapter"), }, }, { // Belfast in full: both chapters present. The opening one must be rejected and the // closing one found, which the position floor does on its own. name: "an opening and a closing credit chapter in one film", chapters: []embyChapter{ named(20, "Opening Credits"), chapter(600, "Chapter"), named(1900, "End Credits"), }, want: 1_900_000, ok: true, }, { // "The Pitt" carries both, seconds apart, describing one roll. The *earliest* // qualifying chapter wins here — the opposite of the marker rule above — because // the roll begins at the first of them and taking the last would skip part of it. name: "two credits chapters describing one roll take the earlier", chapters: []embyChapter{ named(1920, "End Credits"), named(1900, "Credits"), }, want: 1_900_000, ok: true, }, { // Anything in the first three quarters is refused however it is worded. A position // test catches wordings nobody thought of; a list of words only catches the listed. name: "a credits-named chapter too early to be the roll", chapters: []embyChapter{named(900, "Credits")}, }, { // An explicit marker outranks a name, and is honoured even with no runtime to // measure against: it is Emby asserting a position rather than this inferring one. name: "a marker is honoured when the runtime is unknown", chapters: []embyChapter{chapter(1900, "CreditsStart")}, runtimeMs: -1, want: 1_900_000, ok: true, }, { // A name is not. Without a runtime there is no way to tell an opening credit // sequence from a closing one, and guessing is what this whole guard refuses. name: "a name is refused when the runtime is unknown", chapters: []embyChapter{named(1900, "End Credits")}, runtimeMs: -1, }, { // A marker below the floor is a mis-detection whoever wrote it, so it gives way to // a name that does qualify rather than being honoured on authority. name: "a marker below the floor falls through to a name that qualifies", chapters: []embyChapter{ chapter(200, "CreditsStart"), named(1900, "End Credits"), }, want: 1_900_000, ok: true, }, { // The intro's own chapters are named "Intro Start"/"Intro End" in this library, and // an episode whose titles run late must never have them read as a credit roll. name: "chapters named for the intro are never credits", chapters: []embyChapter{ named(1800, "Intro Start"), named(1900, "Intro End"), }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // 0 means "the ordinary case, use the default"; -1 means "deliberately // unknown", which is a case of its own rather than an absent field. runtime := tc.runtimeMs switch runtime { case 0: runtime = testRuntimeMs case -1: runtime = 0 } got, ok := creditsFromChapters(tc.chapters, runtime) if ok != tc.ok { t.Fatalf("available = %v, want %v (start %d)", ok, tc.ok, got) } if ok && got != tc.want { t.Fatalf("start = %d, want %d", got, tc.want) } }) } } // The two halves of one reading are independent. A film with credits and no intro must not // report an intro starting at zero, which is what a shared flag would have produced. func TestMarkersResponseKeepsTheHalvesApart(t *testing.T) { response := markersResponse(chapterMarkers{creditsStart: 6_840_000, creditsFound: true}) if response.Available || response.StartMs != 0 || response.EndMs != 0 { t.Fatalf("intro = %+v, want an absent intro on a title that has none", response) } if !response.CreditsAvailable || response.CreditsStartMs != 6_840_000 { t.Fatalf("credits = %+v, want the marker that was found", response) } response = markersResponse(chapterMarkers{ intro: introSegment{StartMs: 463_000, EndMs: 583_000}, introFound: true, }) if response.CreditsAvailable || response.CreditsStartMs != 0 { t.Fatalf("credits = %+v, want none on a title with only an intro", response) } } // An operator turning one feature off must not take the other with it, and must not poison // the cache: masking happens on the way out, so the entry behind it still holds the truth. func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) { full := introResponse{ Available: true, StartMs: 463_000, EndMs: 583_000, CreditsAvailable: true, CreditsStartMs: 2_704_000, } withoutIntro := maskMarkers(full, false, true) if withoutIntro.Available || withoutIntro.StartMs != 0 || withoutIntro.EndMs != 0 { t.Fatalf("intro = %+v, want it withheld", withoutIntro) } if !withoutIntro.CreditsAvailable || withoutIntro.CreditsStartMs != 2_704_000 { t.Fatal("turning the skip button off must not cost the credits pane as well") } withoutCredits := maskMarkers(full, true, false) if withoutCredits.CreditsAvailable || withoutCredits.CreditsStartMs != 0 { t.Fatalf("credits = %+v, want them withheld", withoutCredits) } if !withoutCredits.Available || withoutCredits.StartMs != 463_000 { t.Fatal("turning the credits pane off must not cost the skip button as well") } } // The credits pane is not a viewer preference any more, and this pins the removal from both // ends: the key is gone from the catalogue, and a document still carrying it — written by a // television or an operator before the removal — has it dropped rather than round-tripped. // Without the second half a stored `false` would survive every sync and quietly keep the // feature off on the one set that had turned it off. func TestSpeedUpCreditsIsNoLongerAPreference(t *testing.T) { if _, ok := preferenceDefinitionFor("speedUpCredits"); ok { t.Fatal("speedUpCredits is still in the preference catalogue") } normalised := normalizePreferences(map[string]any{"speedUpCredits": false}) if _, present := normalised["speedUpCredits"]; present { t.Fatal("a stored speedUpCredits must be dropped, not carried forward") } }