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 // 90–98% 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") }