App v0.2.27 and gateway 0.1.23
Skip Intro from Emby's own chapter markers, trickplay seek previews from BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and review scores, and My Alerts as its own page behind the user picker. Related titles now degrade at every step instead of returning empty, and the "+" is back on Manage users so a second viewer can be added from the launcher. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -0,0 +1,236 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user