Files
memby/server/internal/api/trickplay.go
T

237 lines
8.6 KiB
Go
Raw Normal View History

2026-08-07 10:44:17 +12:00
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"image/jpeg"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trickplay"
)
const (
// trickplayWidth is the thumbnail width to ask Emby for. Emby generates preview images
// at the widths its own settings name and answers any other with an empty file, so
// this is not a free parameter: 320 is what Emby writes by default and the only width
// present on this library. Asking for one it has not generated does not fail — it
// returns a well-formed BIF with no frames, which reads as "this title has no
// previews" and is indistinguishable from a title that genuinely has none.
trickplayWidth = 320
// trickplayIndexWindow is how much of the front of the file to read while looking for
// the index. It covers a title of about twenty-two hours at ten seconds a frame, so in
// practice one request settles it; anything longer costs a second, exact read rather
// than being refused.
trickplayIndexWindow = 64 << 10
// trickplayIndexTTL keeps a parsed index warm for a day. The file only changes when
// the media does, and every frame request needs it.
trickplayIndexTTL = 24 * time.Hour
// trickplayMissingTTL is how long "this title has no previews" is remembered. Shorter
// than the index, because Emby generates thumbnails on a schedule: a film imported this
// afternoon should not be answered from a day-old no.
trickplayMissingTTL = time.Hour
)
// trickplayManifest is what a television needs to draw previews: how much of the title
// each thumbnail covers, how many there are, and what shape they are.
//
// Frame URLs are not listed. There are hundreds of them, they are formed by a rule the
// client already knows, and a list of them would be most of the response.
type trickplayManifest struct {
Available bool `json:"available"`
IntervalMs int64 `json:"intervalMs,omitempty"`
Count int `json:"count,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// handleTrickplay answers whether a title has seek previews, and how they are laid out.
//
// It is deliberately its own request rather than a field on /v1/items/{id}/playback:
// reading the index costs a round trip to Emby, and the playback response is the single
// thing standing between a Play press and a decoder starting. The television asks for this
// once the first frame is up.
func (s *Server) handleTrickplay(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if !s.trickplayEnabled(r.Context()) {
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
index, err := s.trickplayIndex(r.Context(), sess, itemID)
if err != nil {
// A title with no previews is the ordinary case and is answered above; reaching
// here means Emby would not say. Answer "none" rather than an error: the seek
// indicator has a perfectly good wordless form, and a failure the viewer cannot
// act on is not worth a red line in the log every time somebody presses Right.
s.loggerFor(r.Context()).Debug("trickplay index unavailable",
"item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
if !index.Available() {
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
// Previews are worth caching on the television for as long as the index is, and the
// answer is the same for everyone in the house.
w.Header().Set("Cache-Control", "private, max-age=3600")
writeJSON(w, http.StatusOK, trickplayManifest{
Available: true,
IntervalMs: index.IntervalMs,
Count: index.Count,
Width: index.Width,
Height: index.Height,
})
}
// handleTrickplayFrame serves one thumbnail.
//
// The gateway reads the frame's byte range out of Emby's file and writes it on; it never
// holds the file and never re-encodes the image. A frame is about seven kilobytes, which
// is what makes a preview affordable while somebody is still moving the seek target.
func (s *Server) handleTrickplayFrame(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
// The ".jpg" is for the benefit of anything downstream that sniffs a URL rather than a
// content type — an image loader's disk cache, a proxy — and carries no meaning here.
frame, err := strconv.Atoi(strings.TrimSuffix(r.PathValue("frame"), ".jpg"))
if itemID == "" || err != nil || frame < 0 {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
if !s.trickplayEnabled(r.Context()) {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
index, err := s.trickplayIndex(r.Context(), sess, itemID)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
return
}
start, end, ok := index.Frame(frame)
if !ok {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
body, err := s.emby.TrickplayBytes(
r.Context(), credentials(sess), itemID, trickplayWidth, start, end-1,
)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
// A frame's bytes only change when the media is re-encoded, which also renumbers the
// index, so this is safe to keep. It is not "immutable" like a tag-addressed image:
// there is no tag in the URL to make a new file a new address.
w.Header().Set("Cache-Control", "private, max-age=86400")
w.WriteHeader(http.StatusOK)
copyImage(w, r, bytes.NewReader(body), s.loggerFor(r.Context()),
"source", "emby", "item_id", itemID, "frame", frame)
}
func (s *Server) trickplayEnabled(ctx context.Context) bool {
return s.emby != nil && s.featureEnabled(ctx, featureTrickplay)
}
func trickplayIndexKey(itemID string) string {
return "tp:v1:" + strconv.Itoa(trickplayWidth) + ":" + itemID
}
// trickplayIndex reads the index off the front of a title's BIF, remembering it.
//
// A title with no previews is cached too, as a zero-frame index. It is the common case in
// a library where thumbnails are still being generated, and without it every press of
// Right on such a title would be a fresh request to Emby for the same no.
func (s *Server) trickplayIndex(
ctx context.Context, sess store.Session, itemID string,
) (*trickplay.Index, error) {
key := trickplayIndexKey(itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached trickplay.Index
if json.Unmarshal(raw, &cached) == nil {
return &cached, nil
}
}
cred := credentials(sess)
head, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, 0, trickplayIndexWindow-1)
if err != nil {
return nil, err
}
index, err := trickplay.ParseIndex(head)
if errors.Is(err, trickplay.ErrShort) {
// A title long enough that its index runs past the window. Now the count is known,
// so the second read is exact.
count, _, headerErr := trickplay.ParseHeader(head)
if headerErr != nil {
return nil, headerErr
}
var full []byte
full, err = s.emby.TrickplayBytes(
ctx, cred, itemID, trickplayWidth, 0, int64(trickplay.IndexLength(count))-1,
)
if err != nil {
return nil, err
}
index, err = trickplay.ParseIndex(full)
}
if err != nil {
return nil, err
}
ttl := trickplayMissingTTL
if index.Available() {
ttl = trickplayIndexTTL
// The frames' dimensions are not in the header, and the television needs them to
// give the preview a place on screen before the first one has arrived — otherwise
// the seek indicator grows a thumbnail-shaped hole mid-press. One frame is read to
// find out, once per title per day.
index.Width, index.Height = s.trickplayFrameSize(ctx, cred, itemID, index)
}
if raw, err := json.Marshal(index); err == nil {
_ = s.cache.Set(ctx, key, raw, ttl)
}
return index, nil
}
// trickplayFrameSize reads the first thumbnail's dimensions.
//
// Only the JPEG's header is decoded, never its pixels. A zero pair is a perfectly usable
// answer — the client falls back to the aspect it draws by default — so a frame that will
// not parse costs the exact sizing and nothing else.
func (s *Server) trickplayFrameSize(
ctx context.Context, cred emby.Credentials, itemID string, index *trickplay.Index,
) (int, int) {
start, end, ok := index.Frame(0)
if !ok {
return 0, 0
}
body, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, start, end-1)
if err != nil {
return 0, 0
}
config, err := jpeg.DecodeConfig(bytes.NewReader(body))
if err != nil {
return 0, 0
}
return config.Width, config.Height
}