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,170 @@
|
||||
// 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, which it does not advertise: the response carries
|
||||
// "Accept-Ranges: none" and a Content-Length borrowed from the media file. Trust the 206,
|
||||
// not the headers.
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user