Files
memby/server/internal/api/credits.go
2026-08-09 08:25:50 +12:00

125 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import "strings"
// Where a title's closing credits begin.
//
// Two sources, in order of trust, because Emby gives one and the media gives the other:
//
// - `CreditsStart`, a marker Emby's own detector writes. It is in Emby's `MarkerType`
// enumeration and is what this feature was originally built on — but **Emby 4.10 does not
// write it**. A survey of a 20,000-item library found `Chapter`, `IntroStart` and
// `IntroEnd` and nothing else, so the enum value existing is not the detector populating
// it. It is still read first, so the day a version does write it this needs no change.
// - A chapter *named* like credits. Plenty of media carries "Credits" or "End Credits" as
// ordinary chapter metadata, and in that same library 216 items had one, clustered at
// 9098% of runtime and consistent within a show. That is where the feature's coverage
// actually comes from today.
//
// There is deliberately no end marker in either source. Credits run to the end of the file by
// definition, so nothing writes one and this must not invent one.
const markerCreditsStart = "CreditsStart"
// creditsMinimumPositionFraction is how far into a file a credit roll has to begin.
//
// **This is the load-bearing guard, and it exists because of one observed case.** Chapter
// names are not a vocabulary anybody agreed on, and real media carries "Opening Credits" —
// Belfast at 1% of runtime, Game of Thrones at 0%. A name match without a position test
// therefore starts the credits pane in the *first minute* of a film and runs its opening at
// double speed, which is the worst thing this feature could possibly do.
//
// Three quarters is deliberately far below the evidence rather than near it: every genuine
// credit roll in that survey began at 90% or later, so this leaves fifteen points of headroom
// for a long roll while rejecting the whole first half of a file outright.
const creditsMinimumPositionFraction = 0.75
// creditsFromChapters finds where the closing credits begin.
//
// The rule exists twice — the television's copy is `creditsStartFrom` in `data/Credits.kt` —
// and the two are pinned by deliberately parallel tests (`credits_test.go`, `CreditsTest`).
// 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.
//
// Most of this is about refusing to answer, and nothing is a perfectly good answer: the player
// never shrinks anything and the credits play out full size, which is what every other client
// does anyway.
//
// [runtimeMs] may be zero when Emby does not report one. An explicit marker is still honoured
// then — it is Emby asserting a position rather than this inferring one — but a *named*
// chapter is refused outright, because the name alone cannot distinguish an opening credit
// sequence from a closing one and the position test is the only thing that can.
func creditsFromChapters(chapters []embyChapter, runtimeMs int64) (int64, bool) {
floor := int64(-1)
if runtimeMs > 0 {
floor = int64(float64(runtimeMs) * creditsMinimumPositionFraction)
}
// An explicit marker first. The last one wins, where the intro rule takes the first:
// two starts mean the markers are untrustworthy, so each rule picks whichever risks
// least, and the two features are damaged in opposite directions. An intro skip firing
// late throws somebody past the story, so the earlier marker is safer there; the credits
// pane firing early runs the last scene past them at double speed, so the later marker is
// safer here.
marked := int64(-1)
for _, chapter := range chapters {
if chapter.MarkerType != markerCreditsStart || chapter.StartPositionTicks <= 0 {
continue
}
marked = chapter.StartPositionTicks / ticksPerMillisecond
}
// A marker below the floor is a mis-detection whoever wrote it, so it falls through to the
// names rather than being honoured — but with no runtime to measure against, an explicit
// assertion gets the benefit of the doubt.
if marked > 0 && (floor < 0 || marked >= floor) {
return marked, true
}
if floor < 0 {
return 0, false
}
// Then the names. The *earliest* qualifying chapter wins here, which is the opposite of
// the marker rule above and is not an inconsistency: several credits-named chapters are
// ordinary rather than suspicious — "The Pitt" carries both "Credits" and "End Credits" —
// and they describe one roll, which begins at the first of them.
named := int64(-1)
for _, chapter := range chapters {
if !isCreditsChapterName(chapter.Name) || chapter.StartPositionTicks <= 0 {
continue
}
at := chapter.StartPositionTicks / ticksPerMillisecond
if at < floor {
continue
}
if named < 0 || at < named {
named = at
}
}
if named > 0 {
return named, true
}
return 0, false
}
// isCreditsChapterName recognises a chapter that names a credit roll.
//
// The exclusions are belt-and-braces beside [creditsMinimumPositionFraction], which is what
// actually stops an opening sequence being read as a closing one — a position test catches
// wordings nobody thought of, where a list of them only catches the ones on the list. They are
// here so the trap is stated where the next reader will look for it.
func isCreditsChapterName(name string) bool {
lowered := strings.ToLower(strings.TrimSpace(name))
if lowered == "" {
return false
}
for _, opening := range []string{"opening", "main title", "title sequence", "intro"} {
if strings.Contains(lowered, opening) {
return false
}
}
return strings.Contains(lowered, "credit") ||
strings.Contains(lowered, "end titles") ||
strings.Contains(lowered, "closing")
}