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:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
+170
View File
@@ -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)
}
+149
View File
@@ -0,0 +1,149 @@
package trickplay
import (
"encoding/binary"
"errors"
"testing"
)
// buildBIF assembles a file in the shape Emby writes one, so the tests exercise the same
// arithmetic the real parser will meet rather than a convenient fiction.
func buildBIF(count int, multiplier uint32, frameSizes []int) []byte {
length := IndexLength(count)
file := make([]byte, length)
copy(file, magic)
binary.LittleEndian.PutUint32(file[12:16], uint32(count))
binary.LittleEndian.PutUint32(file[16:20], multiplier)
offset := length
for entry := 0; entry < count; entry++ {
at := HeaderSize + entry*entrySize
binary.LittleEndian.PutUint32(file[at:at+4], uint32(entry))
binary.LittleEndian.PutUint32(file[at+4:at+8], uint32(offset))
size := 100
if entry < len(frameSizes) {
size = frameSizes[entry]
}
offset += size
file = append(file, make([]byte, size)...)
}
at := HeaderSize + count*entrySize
binary.LittleEndian.PutUint32(file[at:at+4], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(file[at+4:at+8], uint32(offset))
return file
}
func TestParseIndexReadsEmbysLayout(t *testing.T) {
// Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
// interval is ten seconds, and reading the multiplier as the interval would be right
// only by accident. The multiplication is the part worth pinning.
file := buildBIF(3, 10_000, []int{500, 600, 700})
index, err := ParseIndex(file)
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
if index.Count != 3 {
t.Fatalf("Count = %d, want 3", index.Count)
}
if index.IntervalMs != 10_000 {
t.Fatalf("IntervalMs = %d, want 10000", index.IntervalMs)
}
start, end, ok := index.Frame(1)
if !ok {
t.Fatal("Frame(1) not ok")
}
if want := int64(IndexLength(3) + 500); start != want {
t.Fatalf("frame 1 starts at %d, want %d", start, want)
}
if end-start != 600 {
t.Fatalf("frame 1 is %d bytes, want 600", end-start)
}
}
func TestParseIndexAcceptsATitleWithNoThumbnails(t *testing.T) {
// This is what Emby serves for a title it has not generated previews for: a
// well-formed 72-byte header with a count of zero. It must read as "this title has
// none", never as a broken file, or every such title logs an error.
file := buildBIF(0, 10_000, nil)
index, err := ParseIndex(file)
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
if index.Available() {
t.Fatal("a zero-frame BIF reported itself as available")
}
}
func TestParseIndexRejectsWhatIsNotABIF(t *testing.T) {
if _, err := ParseIndex([]byte("<html>not found</html>")); !errors.Is(err, ErrNotBIF) &&
!errors.Is(err, ErrShort) {
t.Fatalf("err = %v, want ErrNotBIF or ErrShort", err)
}
file := buildBIF(2, 10_000, nil)
file[3] = 'X'
if _, err := ParseIndex(file); !errors.Is(err, ErrNotBIF) {
t.Fatalf("err = %v, want ErrNotBIF", err)
}
}
func TestParseIndexRejectsFramesInsideTheIndex(t *testing.T) {
// An offset pointing back into the index would have the gateway serve a slice of the
// index itself as a JPEG. Refuse the file rather than hand a television nonsense.
file := buildBIF(2, 10_000, nil)
binary.LittleEndian.PutUint32(file[HeaderSize+4:HeaderSize+8], 8)
if _, err := ParseIndex(file); !errors.Is(err, ErrNotBIF) {
t.Fatalf("err = %v, want ErrNotBIF", err)
}
}
func TestParseIndexAsksForMoreRatherThanFailing(t *testing.T) {
// A caller reads a fixed window off the front of the file, so a long title legitimately
// arrives with the index cut short. That must be answerable — fetch more — rather than
// looking like a bad file.
file := buildBIF(40, 10_000, nil)
if _, err := ParseIndex(file[:HeaderSize+16]); !errors.Is(err, ErrShort) {
t.Fatalf("err = %v, want ErrShort", err)
}
if _, _, err := ParseHeader(file[:20]); !errors.Is(err, ErrShort) {
t.Fatalf("ParseHeader err = %v, want ErrShort", err)
}
}
func TestFrameAtClampsRatherThanRefusing(t *testing.T) {
// The preview is drawn while somebody is still moving a seek target about, so a
// position past the last frame must show the last frame. Nothing is worse here than
// the thumbnail blanking at exactly the end of a film.
index, err := ParseIndex(buildBIF(3, 10_000, nil))
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
for _, tc := range []struct {
positionMs int64
want int
}{
{-5_000, 0},
{0, 0},
{9_999, 0},
{10_000, 1},
{25_000, 2},
{9_000_000, 2},
} {
if got := index.FrameAt(tc.positionMs); got != tc.want {
t.Fatalf("FrameAt(%d) = %d, want %d", tc.positionMs, got, tc.want)
}
}
}
func TestFrameRefusesAnIndexOutOfRange(t *testing.T) {
index, err := ParseIndex(buildBIF(2, 10_000, nil))
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
for _, n := range []int{-1, 2, 99} {
if _, _, ok := index.Frame(n); ok {
t.Fatalf("Frame(%d) was served", n)
}
}
}