2026-08-07 10:44:17 +12:00
|
|
|
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.
|
2026-08-09 08:25:50 +12:00
|
|
|
//
|
|
|
|
|
// 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.
|
2026-08-07 10:44:17 +12:00
|
|
|
type introResponse struct {
|
|
|
|
|
Available bool `json:"available"`
|
|
|
|
|
StartMs int64 `json:"startMs,omitempty"`
|
|
|
|
|
EndMs int64 `json:"endMs,omitempty"`
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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 {
|
2026-08-07 10:44:17 +12:00
|
|
|
writeJSON(w, http.StatusOK, introResponse{})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
markers, err := s.markersFor(ctx, sess, itemID)
|
2026-08-07 10:44:17 +12:00
|
|
|
if err != nil {
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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
|
2026-08-07 10:44:17 +12:00
|
|
|
// cannot act on is not worth a red line in the log for every episode watched.
|
2026-08-09 08:25:50 +12:00
|
|
|
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
|
2026-08-15 09:23:26 +12:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
if !markers.introFound && !markers.creditsFound {
|
2026-08-07 10:44:17 +12:00
|
|
|
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")
|
2026-08-09 08:25:50 +12:00
|
|
|
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
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
|
|
|
|
|
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
func (s *Server) endCreditsEnabled(ctx context.Context) bool {
|
|
|
|
|
return s.emby != nil && s.featureEnabled(ctx, featureEndCredits)
|
|
|
|
|
}
|
2026-08-07 10:44:17 +12:00
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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.
|
2026-08-07 10:44:17 +12:00
|
|
|
//
|
2026-08-09 08:25:50 +12:00
|
|
|
// "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(
|
2026-08-07 10:44:17 +12:00
|
|
|
ctx context.Context, sess store.Session, itemID string,
|
2026-08-09 08:25:50 +12:00
|
|
|
) (chapterMarkers, error) {
|
2026-08-07 10:44:17 +12:00
|
|
|
key := introCacheKey(itemID)
|
|
|
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
|
|
|
var cached introResponse
|
|
|
|
|
if json.Unmarshal(raw, &cached) == nil {
|
2026-08-09 08:25:50 +12:00
|
|
|
return chapterMarkers{
|
|
|
|
|
intro: introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs},
|
|
|
|
|
introFound: cached.Available,
|
|
|
|
|
creditsStart: cached.CreditsStartMs,
|
|
|
|
|
creditsFound: cached.CreditsAvailable,
|
|
|
|
|
}, nil
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
|
|
|
|
|
if err != nil {
|
2026-08-09 08:25:50 +12:00
|
|
|
return chapterMarkers{}, err
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
// 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.
|
2026-08-07 10:44:17 +12:00
|
|
|
var parsed struct {
|
2026-08-09 08:25:50 +12:00
|
|
|
Chapters []embyChapter `json:"Chapters"`
|
|
|
|
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
2026-08-09 08:25:50 +12:00
|
|
|
return chapterMarkers{}, err
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
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.
|
2026-08-07 10:44:17 +12:00
|
|
|
ttl := introMissingTTL
|
2026-08-09 08:25:50 +12:00
|
|
|
if introFound || creditsFound {
|
2026-08-07 10:44:17 +12:00
|
|
|
ttl = introTTL
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
if encoded, err := json.Marshal(markersResponse(markers)); err == nil {
|
2026-08-07 10:44:17 +12:00
|
|
|
_ = s.cache.Set(ctx, key, encoded, ttl)
|
|
|
|
|
}
|
2026-08-09 08:25:50 +12:00
|
|
|
return markers, nil
|
2026-08-07 10:44:17 +12:00
|
|
|
}
|