Files
memby/server/internal/api/credits_test.go
T

288 lines
11 KiB
Go
Raw Normal View History

2026-08-09 08:25:50 +12:00
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 9098% of runtime. Two of them carried "Opening Credits" at 01%, 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")
2026-08-09 08:25:50 +12:00
}
normalised := normalizePreferences(map[string]any{"speedUpCredits": false})
if _, present := normalised["speedUpCredits"]; present {
t.Fatal("a stored speedUpCredits must be dropped, not carried forward")
2026-08-09 08:25:50 +12:00
}
}