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. // // The closing credits ride the same response for one reason: they are in the same chapter // list, so answering both costs the one Emby request this handler was always going to make. // A second route for `CreditsStart` would have doubled the cost of a feature whose entire // claim is that it is free. type introResponse struct { Available bool `json:"available"` StartMs int64 `json:"startMs,omitempty"` EndMs int64 `json:"endMs,omitempty"` // CreditsAvailable and CreditsStartMs describe the closing credits. Separate from // Available on purpose: an episode routinely has one and not the other, and folding // them into a single flag would cost the credits pane every title Emby has detected no // intro for — which is most films. CreditsAvailable bool `json:"creditsAvailable"` CreditsStartMs int64 `json:"creditsStartMs,omitempty"` } // chapterMarkers is everything one reading of an item's chapter list came to. type chapterMarkers struct { intro introSegment introFound bool creditsStart int64 creditsFound bool } // 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 } // Two features read this one list, and the request is only worth making if the operator // has left at least one of them on. intro, credits := s.skipIntroEnabled(ctx), s.endCreditsEnabled(ctx) if !intro && !credits { writeJSON(w, http.StatusOK, introResponse{}) return } markers, err := s.markersFor(ctx, sess, itemID) if err != nil { // Trouble is answered with "nothing found" rather than an error. Both features are // optional conveniences 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("chapter markers unavailable", "item_id", itemID, "error", err) markers = chapterMarkers{} } // A discovered marker fills in where Emby has none, which on 4.10 is nearly everywhere: // a survey of this household's library found no CreditsStart markers at all. Emby still // wins where it has an answer — it is the media server's own reading of its own file, and // this subsystem exists to cover the case where there is nothing to defer to. if credits && !markers.creditsFound { if start, found := s.discoveredCredits(ctx, itemID); found { markers.creditsStart, markers.creditsFound = start, true } } if !markers.introFound && !markers.creditsFound { 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, maskMarkers(markersResponse(markers), intro, credits)) } // markersResponse is the wire shape of a reading, and the one place the two halves are put // together — so a title with credits and no intro cannot accidentally report an intro // starting at zero. func markersResponse(markers chapterMarkers) introResponse { response := introResponse{} if markers.introFound { response.Available = true response.StartMs = markers.intro.StartMs response.EndMs = markers.intro.EndMs } if markers.creditsFound { response.CreditsAvailable = true response.CreditsStartMs = markers.creditsStart } return response } // maskMarkers withholds the half of a reading whose feature the operator has turned off. // // It happens on the way out rather than on the way in, which is what lets the cache hold // the unmasked truth: a feature switched back on takes effect on the next playback, instead // of serving a day of deliberate silence from an entry written while it was off. func maskMarkers(response introResponse, intro, credits bool) introResponse { if !intro { response.Available, response.StartMs, response.EndMs = false, 0, 0 } if !credits { response.CreditsAvailable, response.CreditsStartMs = false, 0 } return response } func (s *Server) skipIntroEnabled(ctx context.Context) bool { return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro) } func (s *Server) endCreditsEnabled(ctx context.Context) bool { return s.emby != nil && s.featureEnabled(ctx, featureEndCredits) } // introCacheKey is v2 because the cached shape grew the credits marker. An entry written by // the previous build holds no `creditsAvailable`, and decoding it would report "no credits" // for a day on every title the house had already played — so the key moves rather than the // old entries being trusted. func introCacheKey(itemID string) string { return "intro:v2:" + itemID } // markersFor reads an item's chapter markers, remembering what they came to. // // "Nothing found" is cached as well as a finding. 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. // // One reading answers for both features. The intro and the credits are the same field of // the same response, so splitting them into two lookups would have made the second one cost // a round trip it has no need to spend. func (s *Server) markersFor( ctx context.Context, sess store.Session, itemID string, ) (chapterMarkers, error) { key := introCacheKey(itemID) if raw, err := s.cache.Get(ctx, key); err == nil { var cached introResponse if json.Unmarshal(raw, &cached) == nil { return chapterMarkers{ intro: introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, introFound: cached.Available, creditsStart: cached.CreditsStartMs, creditsFound: cached.CreditsAvailable, }, nil } } raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters") if err != nil { return chapterMarkers{}, err } // RunTimeTicks rides along because the credits rule needs it: a chapter merely *named* // "Credits" cannot be told from "Opening Credits" without knowing how far into the file it // sits. It is a default field on this response, so asking for it costs nothing. var parsed struct { Chapters []embyChapter `json:"Chapters"` RunTimeTicks int64 `json:"RunTimeTicks"` } if err := json.Unmarshal(raw, &parsed); err != nil { return chapterMarkers{}, err } segment, introFound := introFromChapters(parsed.Chapters) creditsStart, creditsFound := creditsFromChapters( parsed.Chapters, parsed.RunTimeTicks/ticksPerMillisecond, ) markers := chapterMarkers{ intro: segment, introFound: introFound, creditsStart: creditsStart, creditsFound: creditsFound, } // The shorter "not analysed yet" life applies unless *something* was found. A title // with credits but no intro has been analysed, and re-asking hourly for the intro Emby // has already decided it has none of would be a request per playback for a settled no. ttl := introMissingTTL if introFound || creditsFound { ttl = introTTL } if encoded, err := json.Marshal(markersResponse(markers)); err == nil { _ = s.cache.Set(ctx, key, encoded, ttl) } return markers, nil }