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 }