Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
+110 -30
View File
@@ -53,10 +53,29 @@ type introSegment struct {
// 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.
@@ -128,77 +147,138 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if !s.skipIntroEnabled(ctx) {
// 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
}
segment, ok, err := s.introFor(ctx, sess, itemID)
markers, err := s.markersFor(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
// 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("intro markers unavailable", "item_id", itemID, "error", err)
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, introResponse{})
return
}
if !ok {
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, introResponse{
Available: true,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
})
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 introCacheKey(itemID string) string { return "intro:v1:" + itemID }
func (s *Server) endCreditsEnabled(ctx context.Context) bool {
return s.emby != nil && s.featureEnabled(ctx, featureEndCredits)
}
// introFor reads an item's chapter markers, remembering what they came to.
// 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.
//
// "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(
// "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,
) (introSegment, bool, error) {
) (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 introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, 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 introSegment{}, false, err
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"`
Chapters []embyChapter `json:"Chapters"`
RunTimeTicks int64 `json:"RunTimeTicks"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return introSegment{}, false, err
return chapterMarkers{}, err
}
segment, ok := introFromChapters(parsed.Chapters)
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 ok {
if introFound || creditsFound {
ttl = introTTL
}
if encoded, err := json.Marshal(introResponse{
Available: ok,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
}); err == nil {
if encoded, err := json.Marshal(markersResponse(markers)); err == nil {
_ = s.cache.Set(ctx, key, encoded, ttl)
}
return segment, ok, nil
return markers, nil
}