// Package trickplay reads the preview thumbnails Emby serves at /Videos/{id}/index.bif. // // That is the BIF format Roku published: a 64-byte header, then one 8-byte (timestamp, // offset) entry per frame plus a terminator, then the JPEGs laid end to end. Emby 4.10 // generates one frame every ten seconds at 320px wide, which for a two-hour film is // eight hundred images and about five megabytes. // // The index being at the *front* of the file is the whole reason previews are affordable // on a television. Read the first few kilobytes and every frame's byte range is known, so // showing one thumbnail costs a ranged request of about seven kilobytes rather than a // five-megabyte download nobody would wait for mid-seek. Emby answers ranged requests on // this route correctly — measured against 4.10.0.21, which returns a 206 with // "Accept-Ranges: bytes" and a Content-Range naming the BIF's own length. Earlier builds // were reported to answer the range while advertising "Accept-Ranges: none", so callers // still trust the 206 rather than the headers and cap the read at what was asked for: // being wrong about that must not turn a press of Right into a multi-megabyte download. package trickplay import ( "bytes" "encoding/binary" "errors" "fmt" ) const ( // HeaderSize is the fixed preamble: magic, version, frame count, timestamp multiplier. HeaderSize = 64 entrySize = 8 // defaultMultiplier is what the format says a zero in the header means. defaultMultiplier = 1000 ) // magic is the file's first eight bytes. The leading 0x89 and the CR/LF pair are the same // trick PNG uses: a file mangled by a text-mode transfer stops matching. var magic = []byte{0x89, 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a} var ( // ErrShort means the caller has not read enough of the file yet. It is a signal to // fetch more, never a bad file. ErrShort = errors.New("trickplay: not enough bytes") // ErrNotBIF means what came back is not a BIF at all — an Emby error page, most // likely, since the route answers 200 with an explanation for some failures. ErrNotBIF = errors.New("trickplay: not a bif file") ) // Index is everything needed to serve any frame of a title: how long each covers, and // where each one's bytes start and end. // // Offsets holds Count+1 values, the last being the end of the final frame, so a frame's // extent is a subtraction rather than a special case at the tail. type Index struct { Count int `json:"count"` IntervalMs int64 `json:"intervalMs"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` Offsets []int64 `json:"offsets"` } // Available reports whether this title actually has thumbnails. // // A count of zero is the ordinary answer for a title Emby has not generated previews for, // and the header it returns is otherwise perfectly well formed — so this is a question // about the library, not about the file being readable. func (i *Index) Available() bool { return i != nil && i.Count > 0 && len(i.Offsets) > i.Count } // ParseHeader reads the fixed preamble. It is separate from ParseIndex because the frame // count is what says how long the index is, and that is the number a caller needs before // it can decide how much of the file to ask for. func ParseHeader(b []byte) (count int, intervalMs int64, err error) { if len(b) < HeaderSize { return 0, 0, ErrShort } if !bytes.Equal(b[:8], magic) { return 0, 0, ErrNotBIF } count = int(binary.LittleEndian.Uint32(b[12:16])) multiplier := int64(binary.LittleEndian.Uint32(b[16:20])) if multiplier <= 0 { multiplier = defaultMultiplier } if count < 0 { return 0, 0, ErrNotBIF } return count, multiplier, nil } // IndexLength is how many bytes of the file hold the header and the whole index, which is // also the offset the first JPEG must start at. func IndexLength(count int) int { return HeaderSize + (count+1)*entrySize } // ParseIndex reads the header and the index out of the front of a BIF. // // b may be longer than the index — a caller that fetched a fixed window of the file passes // what it got and the rest is ignored. func ParseIndex(b []byte) (*Index, error) { count, multiplier, err := ParseHeader(b) if err != nil { return nil, err } if count == 0 { return &Index{Count: 0, IntervalMs: multiplier}, nil } if len(b) < IndexLength(count) { return nil, ErrShort } offsets := make([]int64, 0, count+1) timestamps := make([]int64, 0, count) for entry := 0; entry <= count; entry++ { at := HeaderSize + entry*entrySize timestamp := int64(binary.LittleEndian.Uint32(b[at : at+4])) offset := int64(binary.LittleEndian.Uint32(b[at+4 : at+8])) offsets = append(offsets, offset) if entry < count { timestamps = append(timestamps, timestamp) } } // A frame that starts inside the index, or before the one ahead of it, means the file // is not laid out the way the format says. Serving a byte range from it would hand a // television whatever happened to be there. if offsets[0] < int64(IndexLength(count)) { return nil, fmt.Errorf("%w: first frame overlaps the index", ErrNotBIF) } for entry := 1; entry <= count; entry++ { if offsets[entry] < offsets[entry-1] { return nil, fmt.Errorf("%w: frame offsets go backwards at %d", ErrNotBIF, entry) } } interval := multiplier if count >= 2 && timestamps[1] > timestamps[0] { interval = (timestamps[1] - timestamps[0]) * multiplier } if interval <= 0 { interval = defaultMultiplier } return &Index{Count: count, IntervalMs: interval, Offsets: offsets}, nil } // Frame is the half-open byte range of one thumbnail, ready for a Range header. func (i *Index) Frame(n int) (start, end int64, ok bool) { if !i.Available() || n < 0 || n >= i.Count { return 0, 0, false } start, end = i.Offsets[n], i.Offsets[n+1] if end <= start { return 0, 0, false } return start, end, true } // FrameAt is which thumbnail covers a moment in the title. // // It clamps rather than refusing: a seek preview is asked for while somebody is still // moving a target around, and a position a second past the end of the last frame should // show the last frame, not nothing. func (i *Index) FrameAt(positionMs int64) int { if !i.Available() || i.IntervalMs <= 0 { return 0 } if positionMs < 0 { return 0 } n := positionMs / i.IntervalMs if n >= int64(i.Count) { return i.Count - 1 } return int(n) }