Files
memby/server/internal/api/intro.go
T
ponzischeme89andClaude Opus 5 80c304d86b App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:44:17 +12:00

205 lines
7.7 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
// introMinimumMs is the shortest span worth calling an intro. Emby's detector
// occasionally writes a pair a couple of seconds apart on a title whose opening it
// half-recognised, and a button that skips two seconds is worse than no button:
// somebody presses it, the picture does not visibly move, and the feature reads as
// broken.
introMinimumMs = 5_000
// introMaximumMs is the longest. A pair minutes apart is a mis-detection — a recap, a
// cold open, or two unrelated markers read as a range — and honouring it would throw a
// viewer past the start of the story. FROM's openings run about two minutes; this is
// generous enough to cover a long title sequence and short enough to refuse nonsense.
introMaximumMs = 5 * 60 * 1_000
// introTTL keeps a found segment for a day. Chapter markers only change when the media
// is re-analysed, and every playback of an episode asks for this once.
introTTL = 24 * time.Hour
// introMissingTTL is how long "this episode has no intro markers" is remembered.
// Deliberately shorter, for the same reason the trickplay one is: Emby detects intros
// on a schedule, so an episode imported this afternoon must not be answered from a
// day-old no.
introMissingTTL = time.Hour
)
// What a viewer has asked to happen when an episode reaches its opening titles. The
// television holds the matching vocabulary in `data/SkipIntroPreference.kt`; this is the
// side that decides what a legal value is, through the preference catalogue.
const (
skipIntroPrompt = "prompt"
skipIntroAuto = "auto"
skipIntroOff = "off"
)
// introSegment is where an episode's title sequence sits, in milliseconds from the start.
type introSegment struct {
StartMs int64 `json:"startMs"`
EndMs int64 `json:"endMs"`
}
// introResponse is what a television is told. Available is explicit rather than implied by
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
// that has none, and the client defaults to false so a gateway that predates this — or has
// the feature turned off — can never conjure a button.
type introResponse struct {
Available bool `json:"available"`
StartMs int64 `json:"startMs,omitempty"`
EndMs int64 `json:"endMs,omitempty"`
}
// embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here.
//
// Emby writes intro markers as ordinary chapters carrying a MarkerType, interleaved with
// the real ones in playback order — so they are read out of the same array the chapter
// list comes from, and asking for Chapters is the whole of the request.
type embyChapter struct {
StartPositionTicks int64 `json:"StartPositionTicks"`
MarkerType string `json:"MarkerType"`
Name string `json:"Name"`
}
// introFromChapters finds the title sequence in a chapter list.
//
// Pure, and the piece worth testing hard: it is what stands between one bad marker and a
// viewer being thrown into the middle of a scene. The rule exists twice — the television's
// copy is `introSegmentFrom` in `data/Intro.kt` — and the two are pinned by deliberately
// parallel tests (`intro_test.go`, `IntroTest`). With no gateway there is nobody to ask,
// and a skip must not land somewhere different depending on whether the container is up.
//
// Most of the function is about refusing to answer. A pair that is out of order, too
// short, too long, or missing half of itself produces nothing at all, and nothing is a
// perfectly good answer: the player simply never offers the button.
func introFromChapters(chapters []embyChapter) (introSegment, bool) {
const (
markerStart = "IntroStart"
markerEnd = "IntroEnd"
)
start := int64(-1)
for _, chapter := range chapters {
switch chapter.MarkerType {
case markerStart:
// The first start wins, and a second one is ignored rather than replacing it.
// Two starts mean the markers are already untrustworthy; taking the later one
// would pick the larger, more damaging skip of the two.
if start < 0 && chapter.StartPositionTicks >= 0 {
start = chapter.StartPositionTicks / ticksPerMillisecond
}
case markerEnd:
// An end before any start is a stray marker, not the close of a segment.
if start < 0 {
continue
}
end := chapter.StartPositionTicks / ticksPerMillisecond
length := end - start
if length < introMinimumMs || length > introMaximumMs {
return introSegment{}, false
}
return introSegment{StartMs: start, EndMs: end}, true
}
}
return introSegment{}, false
}
// handleIntro answers where an episode's title sequence is, if it has one.
//
// It is deliberately its own request rather than a field on /v1/items/{id}/playback, the
// same call the seek previews make: reading it costs a round trip to Emby for a field
// nothing else on the playback path wants, and that response is the one thing standing
// between a Play press and a decoder starting. Nothing here is needed before the first
// frame — the earliest intro in a typical library starts about two minutes in — so the
// television asks once playback has settled.
func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if !s.skipIntroEnabled(ctx) {
writeJSON(w, http.StatusOK, introResponse{})
return
}
segment, ok, err := s.introFor(ctx, sess, itemID)
if err != nil {
// Trouble is answered with "no intro" rather than an error. The button is an
// optional convenience on a film that is already playing, and a failure the viewer
// cannot act on is not worth a red line in the log for every episode watched.
s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, introResponse{})
return
}
if !ok {
writeJSON(w, http.StatusOK, introResponse{})
return
}
// The same answer for everyone in the house, and it only changes when the media does.
w.Header().Set("Cache-Control", "private, max-age=3600")
writeJSON(w, http.StatusOK, introResponse{
Available: true,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
})
}
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
}
func introCacheKey(itemID string) string { return "intro:v1:" + itemID }
// introFor reads an item's chapter markers, remembering what they came to.
//
// "No intro" is cached as well as an intro. It is the common case — a film, a special, an
// episode Emby has not analysed yet — and without it every playback in the house would be
// a fresh request to Emby for the same no.
func (s *Server) introFor(
ctx context.Context, sess store.Session, itemID string,
) (introSegment, bool, error) {
key := introCacheKey(itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached introResponse
if json.Unmarshal(raw, &cached) == nil {
return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil
}
}
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
if err != nil {
return introSegment{}, false, err
}
var parsed struct {
Chapters []embyChapter `json:"Chapters"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return introSegment{}, false, err
}
segment, ok := introFromChapters(parsed.Chapters)
ttl := introMissingTTL
if ok {
ttl = introTTL
}
if encoded, err := json.Marshal(introResponse{
Available: ok,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
}); err == nil {
_ = s.cache.Set(ctx, key, encoded, ttl)
}
return segment, ok, nil
}