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
@@ -160,6 +160,9 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
|
||||
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
|
||||
|
||||
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
|
||||
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
|
||||
|
||||
@@ -19,6 +19,8 @@ const (
|
||||
featureHEVCDirectPlay = "hevc_direct_play"
|
||||
featureInstallPermission = "install_permission_prompt"
|
||||
featureSubtitleDownload = "subtitle_download"
|
||||
featureTrickplay = "trickplay"
|
||||
featureSkipIntro = "skip_intro"
|
||||
)
|
||||
|
||||
type featureDefinition struct {
|
||||
@@ -64,6 +66,20 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "subtitle_download_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the option simply stops being offered.",
|
||||
},
|
||||
{
|
||||
Key: featureTrickplay, Name: "Seek preview thumbnails", Area: "Playback",
|
||||
Description: "Show the frame a skip will land on, from the preview images Emby " +
|
||||
"generates. Turn it off to stop the gateway reading them.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "trickplay_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the preview simply stops appearing.",
|
||||
},
|
||||
{
|
||||
Key: featureSkipIntro, Name: "Skip the title sequence", Area: "Playback",
|
||||
Description: "Offer to jump past an episode's opening titles, from the intro " +
|
||||
"markers Emby writes. Turn it off to stop the gateway reading them.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
|
||||
},
|
||||
{
|
||||
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
||||
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
package api
|
||||
|
||||
// The home hero — the four cards above the launcher's rows, and the one place the server
|
||||
// says "this, tonight" rather than "here is a shelf".
|
||||
//
|
||||
// It used to be chosen on the television: take the first movies off whichever rows looked
|
||||
// new or popular, rotate the starting point once a day. That was as good as the evidence
|
||||
// the client had, which is very little. Emby's PremiereDate is frequently whatever a
|
||||
// metadata agent guessed, nothing on the wire said whether a title was any good, and a
|
||||
// series could only ever reach the hero as a random show off a shelf. So a poorly
|
||||
// reviewed film imported last Tuesday led the launcher over the best-received release of
|
||||
// the month, and the return of a household's favourite show passed unremarked.
|
||||
//
|
||||
// The gateway has the three pieces of evidence the television does not:
|
||||
//
|
||||
// - **Radarr knows when a film actually came out.** `digitalRelease` is the date the
|
||||
// household could first have watched it, which is what a viewer means by "new". The
|
||||
// schedule row already prefers it over Emby's; the hero reads the same answer over a
|
||||
// backwards window instead of a forwards one.
|
||||
// - **Sonarr knows a premiere from an ordinary episode.** S01E01 is a new show, S02E01
|
||||
// is a returning one, and both are news in a way that the fourth episode of a show
|
||||
// somebody is already halfway through is not.
|
||||
// - **MDBList knows whether it is worth the evening.** By the point this runs those
|
||||
// scores are already attached to the cards, so ranking by them costs nothing.
|
||||
//
|
||||
// Two properties are what stop this becoming a second recommendation engine, and both
|
||||
// are easy to give away:
|
||||
//
|
||||
// - **It asks Emby for nothing.** The movie candidates are the rows already assembled
|
||||
// and their ratings are already attached, so the expensive half of the launcher is
|
||||
// reused rather than repeated. What it does read is the two *arr calendars, and those
|
||||
// are cached for the day behind a shared lock like the schedule rows' — one household
|
||||
// pays one miss each per day, and the three reads run together rather than in turn
|
||||
// because this is the tail of a response every television is waiting on.
|
||||
// - **Every card it produces is playable.** A premiere the household has not downloaded
|
||||
// yet, or a film Radarr is still waiting on, is news for the schedule row — the hero
|
||||
// exists to be pressed, and a lead card that does nothing is worse than no lead card.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
)
|
||||
|
||||
const (
|
||||
heroRowID = "hero"
|
||||
heroRowKind = "hero"
|
||||
|
||||
// How far back a release can be and still be the reason a card leads. Digital
|
||||
// releases and premieres arrive in bursts, and a hero that empties out in a quiet
|
||||
// fortnight is a hero that falls back to the library — three weeks keeps it
|
||||
// populated without billing a six-week-old film as new.
|
||||
heroWindowDays = 21
|
||||
|
||||
// The television draws four cards. The row carries a few more so one it cannot draw
|
||||
// — no artwork, a type this build predates — costs a card rather than a gap.
|
||||
heroRowLimit = 8
|
||||
|
||||
// Candidates are capped before scoring. A launcher is a few hundred cards; this is a
|
||||
// guard against a future row type offering a thousand, not a limit anything reaches.
|
||||
heroCandidateLimit = 240
|
||||
)
|
||||
|
||||
// The ranking. Recency and quality are deliberately close in weight: the request this
|
||||
// answers is that a well-received release should be able to beat a fresher one that
|
||||
// nobody liked, which needs quality to be worth roughly as much as a fortnight of age.
|
||||
const (
|
||||
heroRecencyWeight = 0.55
|
||||
heroRatingWeight = 0.45
|
||||
|
||||
// What a title nobody has rated is worth. Deliberately near the middle rather than
|
||||
// zero: a good score is meant to *lift* a title above the merely recent, not to bury
|
||||
// everything MDBList has never been asked about — which, on a household that has just
|
||||
// turned ratings on, is the entire library.
|
||||
heroUnratedScore = 0.55
|
||||
|
||||
// Radarr's cinema + 30 days is a guess (see effectiveRadarrRelease), and a guess
|
||||
// should not outrank a date somebody published.
|
||||
heroEstimatedPenalty = 0.12
|
||||
|
||||
// Where "well reviewed" starts, for the one label that claims it.
|
||||
heroAcclaimedRating = 0.75
|
||||
)
|
||||
|
||||
// Caption wording is the gateway's, like MembyAirLabel and MembyLifecycleText. The
|
||||
// television renders the string it is handed, so a new kind of hero card reads correctly
|
||||
// on a build that predates it.
|
||||
const (
|
||||
heroLabelSeriesPremiere = "SERIES PREMIERE"
|
||||
heroLabelSeasonPremiere = "NEW SEASON"
|
||||
heroLabelNewRelease = "NEW RELEASE"
|
||||
heroLabelAcclaimed = "HIGHLY RATED"
|
||||
heroLabelLibrary = "FROM YOUR LIBRARY"
|
||||
)
|
||||
|
||||
// The fields the hero adds to an item payload. Emby's JSON is otherwise forwarded
|
||||
// verbatim; these are injected the way MembyRatings is.
|
||||
const (
|
||||
heroLabelField = "MembyHeroLabel"
|
||||
heroReasonField = "MembyHeroReason"
|
||||
)
|
||||
|
||||
const heroReleasedCachePrefix = "radarr:released:v1:"
|
||||
const heroPremiereCachePrefix = "sonarr:premieres:v1:"
|
||||
|
||||
type heroKind int
|
||||
|
||||
const (
|
||||
heroMovie heroKind = iota
|
||||
heroSeriesPremiere
|
||||
heroSeasonPremiere
|
||||
)
|
||||
|
||||
// heroCandidate is one title the hero could lead with, and everything the ranking needs
|
||||
// to decide whether it should.
|
||||
type heroCandidate struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind heroKind
|
||||
Item json.RawMessage
|
||||
|
||||
// ReleasedAt is the *effective* release: Radarr's digital date for a film, the
|
||||
// premiere's air date for a show, and Emby's PremiereDate only when nothing better
|
||||
// is known. Zero means nothing is known at all, which is an answer — such a title
|
||||
// ranks on quality alone rather than being excluded.
|
||||
ReleasedAt time.Time
|
||||
Estimated bool
|
||||
|
||||
// Rating is normalised onto 0..1 across whatever providers answered. Rated is false
|
||||
// when none did, which is a different thing from a score of zero.
|
||||
Rating float64
|
||||
Rated bool
|
||||
}
|
||||
|
||||
// heroRecency decays linearly across the window.
|
||||
//
|
||||
// A release in the future scores zero rather than more than one. The hero is a thing to
|
||||
// be pressed, and a title that has not come out yet belongs to the schedule row — this
|
||||
// only ever sees such a date because Radarr publishes a digital date before it arrives.
|
||||
func heroRecency(released, now time.Time) float64 {
|
||||
if released.IsZero() || released.After(now) {
|
||||
return 0
|
||||
}
|
||||
age := now.Sub(released).Hours() / 24
|
||||
if age >= heroWindowDays {
|
||||
return 0
|
||||
}
|
||||
return 1 - age/heroWindowDays
|
||||
}
|
||||
|
||||
func heroScore(candidate heroCandidate, now time.Time) float64 {
|
||||
rating := heroUnratedScore
|
||||
if candidate.Rated {
|
||||
rating = candidate.Rating
|
||||
}
|
||||
score := heroRecencyWeight*heroRecency(candidate.ReleasedAt, now) + heroRatingWeight*rating
|
||||
if candidate.Estimated {
|
||||
score -= heroEstimatedPenalty
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
|
||||
// about without a network.
|
||||
//
|
||||
// The sort is stable and the tie-break is the order it was given, so the caller's own
|
||||
// preference survives two titles the scorer cannot separate. Deduplication keeps the
|
||||
// first appearance: a film that is both a Radarr release and a library card is the
|
||||
// release, which is the more specific thing to say about it.
|
||||
func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []heroCandidate {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
type ranked struct {
|
||||
candidate heroCandidate
|
||||
position int
|
||||
score float64
|
||||
}
|
||||
seen := make(map[string]bool, len(candidates))
|
||||
scored := make([]ranked, 0, len(candidates))
|
||||
for position, candidate := range candidates {
|
||||
if candidate.ID == "" || seen[candidate.ID] || len(candidate.Item) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
scored = append(scored, ranked{
|
||||
candidate: candidate,
|
||||
position: position,
|
||||
score: heroScore(candidate, now),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
if scored[i].score != scored[j].score {
|
||||
return scored[i].score > scored[j].score
|
||||
}
|
||||
return scored[i].position < scored[j].position
|
||||
})
|
||||
if len(scored) > limit {
|
||||
scored = scored[:limit]
|
||||
}
|
||||
out := make([]heroCandidate, 0, len(scored))
|
||||
for _, entry := range scored {
|
||||
out = append(out, entry.candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heroLabel is the caption the card wears, and it may only say what is actually known.
|
||||
//
|
||||
// The labels it replaced were the card's *position* — the first slot was captioned NEW
|
||||
// RELEASE whatever was in it — which is how a 2019 film came to be announced as new. Each
|
||||
// of these is a claim the candidate has already satisfied.
|
||||
func heroLabel(candidate heroCandidate, now time.Time) string {
|
||||
switch candidate.Kind {
|
||||
case heroSeriesPremiere:
|
||||
return heroLabelSeriesPremiere
|
||||
case heroSeasonPremiere:
|
||||
return heroLabelSeasonPremiere
|
||||
}
|
||||
if heroRecency(candidate.ReleasedAt, now) > 0 {
|
||||
return heroLabelNewRelease
|
||||
}
|
||||
if candidate.Rated && candidate.Rating >= heroAcclaimedRating {
|
||||
return heroLabelAcclaimed
|
||||
}
|
||||
return heroLabelLibrary
|
||||
}
|
||||
|
||||
// heroReason is the second line: why this, over the rest of the library. It is allowed to
|
||||
// be empty, and is empty precisely when there is nothing true to say — a card with no
|
||||
// evidence behind it says nothing rather than inventing a reason.
|
||||
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
|
||||
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
|
||||
fresh := heroRecency(candidate.ReleasedAt, now) > 0
|
||||
switch {
|
||||
case candidate.Kind == heroSeriesPremiere && acclaimed:
|
||||
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case candidate.Kind == heroSeriesPremiere:
|
||||
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case candidate.Kind == heroSeasonPremiere:
|
||||
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case fresh && acclaimed && candidate.Estimated:
|
||||
return "Well reviewed, and expected to have landed " +
|
||||
heroWhen(candidate.ReleasedAt, now, location)
|
||||
case fresh && acclaimed:
|
||||
return "Well reviewed, released " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case fresh && candidate.Estimated:
|
||||
return "Expected to have landed " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case fresh:
|
||||
return "Released " + heroWhen(candidate.ReleasedAt, now, location)
|
||||
case acclaimed:
|
||||
return "One of the best-reviewed titles in your library"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// heroWhen words a date the way somebody would say it out loud. Nothing here is more
|
||||
// precise than the evidence: a digital release date carries no time of day, so a card
|
||||
// never claims an hour.
|
||||
func heroWhen(released, now time.Time, location *time.Location) string {
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
released = released.In(location)
|
||||
today := localDayStart(now, location)
|
||||
day := localDayStart(released, location)
|
||||
switch days := int(today.Sub(day).Hours() / 24); {
|
||||
case days <= 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "yesterday"
|
||||
case days < 7:
|
||||
return "on " + released.Format("Monday")
|
||||
case days < 14:
|
||||
return "last week"
|
||||
default:
|
||||
return "this month"
|
||||
}
|
||||
}
|
||||
|
||||
// heroRatingOf reads the scores already attached to the card.
|
||||
//
|
||||
// It is the *mean* of what the household's chosen providers said, normalised onto 0..1.
|
||||
// The sources disagree about scale and about films — IMDb is generous, Rotten Tomatoes'
|
||||
// critics are not — and averaging them is a better answer than nominating a favourite and
|
||||
// letting one provider's blind spot decide what leads the launcher.
|
||||
func heroRatingOf(raw json.RawMessage) (float64, bool) {
|
||||
var payload struct {
|
||||
Ratings []movieRating `json:"MembyRatings"`
|
||||
CommunityRating *float64 `json:"CommunityRating"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
return 0, false
|
||||
}
|
||||
var sum float64
|
||||
var count int
|
||||
for _, rating := range payload.Ratings {
|
||||
source, known := movieRatingSources[strings.ToLower(strings.TrimSpace(rating.Source))]
|
||||
if !known || source.Maximum <= 0 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(rating.Score), 64)
|
||||
if err != nil || value <= 0 || value > source.Maximum {
|
||||
continue
|
||||
}
|
||||
sum += value / source.Maximum
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
return sum / float64(count), true
|
||||
}
|
||||
// Emby's own CommunityRating is the fallback, and only here. The client is forbidden
|
||||
// from *drawing* it (a card naming a provider that was never asked is a lie), but
|
||||
// ordering four cards by it claims nothing to anybody — and it is what lets the hero
|
||||
// rank sensibly on a household that has not configured MDBList at all.
|
||||
if payload.CommunityRating != nil && *payload.CommunityRating > 0 && *payload.CommunityRating <= 10 {
|
||||
return *payload.CommunityRating / 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// heroItemFacts pulls what the ranking needs out of an ordinary Emby item payload.
|
||||
type heroItemFacts struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Premiere time.Time
|
||||
Playable bool
|
||||
}
|
||||
|
||||
func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
|
||||
var payload struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
PremiereDate string `json:"PremiereDate"`
|
||||
Source string `json:"MembySource"`
|
||||
Playable *bool `json:"MembyPlayable"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" {
|
||||
return heroItemFacts{}, false
|
||||
}
|
||||
facts := heroItemFacts{
|
||||
ID: payload.ID,
|
||||
Name: strings.TrimSpace(payload.Name),
|
||||
Type: payload.Type,
|
||||
// A synthetic schedule card carries MembySource and is explicitly not playable.
|
||||
// Anything from Emby carries neither field, and is.
|
||||
Playable: strings.TrimSpace(payload.Source) == "" &&
|
||||
(payload.Playable == nil || *payload.Playable),
|
||||
}
|
||||
if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil {
|
||||
facts.Premiere = parsed
|
||||
}
|
||||
return facts, true
|
||||
}
|
||||
|
||||
// parseEmbyDate accepts the shapes Emby writes a date in. A date it will not parse is
|
||||
// simply unknown, which the ranking already has a behaviour for.
|
||||
func parseEmbyDate(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errNoDate
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errNoDate
|
||||
}
|
||||
|
||||
var errNoDate = errors.New("hero: unparsable date")
|
||||
|
||||
// injectHeroFields writes the caption and the reason onto one card, the way
|
||||
// injectItemRatings writes the scores: through a map, so a field this build knows nothing
|
||||
// about survives the round trip.
|
||||
func injectHeroFields(raw json.RawMessage, label, reason string) json.RawMessage {
|
||||
if label == "" && reason == "" {
|
||||
return raw
|
||||
}
|
||||
var members map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &members) != nil || members == nil {
|
||||
return raw
|
||||
}
|
||||
if label != "" {
|
||||
if encoded, err := json.Marshal(label); err == nil {
|
||||
members[heroLabelField] = encoded
|
||||
}
|
||||
}
|
||||
if reason != "" {
|
||||
if encoded, err := json.Marshal(reason); err == nil {
|
||||
members[heroReasonField] = encoded
|
||||
}
|
||||
}
|
||||
out, err := json.Marshal(members)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heroReleaseIndex answers "when did this film actually come out" for the candidates.
|
||||
//
|
||||
// It is keyed two ways because neither key is reliable on its own: a TMDB id is exact but
|
||||
// only exists for a library item the import has resolved, and a normalised title/year is
|
||||
// always available but can be wrong about a remake. The id is consulted first.
|
||||
type heroReleaseIndex struct {
|
||||
byTMDB map[string]radarrRelease
|
||||
byTitle map[string]radarrRelease
|
||||
}
|
||||
|
||||
func newHeroReleaseIndex(movies []radarr.Movie) heroReleaseIndex {
|
||||
index := heroReleaseIndex{
|
||||
byTMDB: make(map[string]radarrRelease, len(movies)),
|
||||
byTitle: make(map[string]radarrRelease, len(movies)*2),
|
||||
}
|
||||
for _, movie := range movies {
|
||||
release, ok := effectiveRadarrRelease(movie)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if movie.TMDBID > 0 {
|
||||
index.byTMDB[strconv.Itoa(movie.TMDBID)] = release
|
||||
}
|
||||
title := normalizedShowTitle(movie.Title)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
// Year-qualified first and never overwritten, so a remake cannot claim the
|
||||
// original's release date — the same rule seriesIndex applies.
|
||||
if movie.Year > 0 {
|
||||
if _, seen := index.byTitle[seriesIndexKey(title, movie.Year)]; !seen {
|
||||
index.byTitle[seriesIndexKey(title, movie.Year)] = release
|
||||
}
|
||||
}
|
||||
if _, seen := index.byTitle[title]; !seen {
|
||||
index.byTitle[title] = release
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (index heroReleaseIndex) lookup(tmdbID, title string, year int) (radarrRelease, bool) {
|
||||
if tmdbID != "" {
|
||||
if release, ok := index.byTMDB[tmdbID]; ok {
|
||||
return release, true
|
||||
}
|
||||
}
|
||||
key := normalizedShowTitle(title)
|
||||
if key == "" {
|
||||
return radarrRelease{}, false
|
||||
}
|
||||
if year > 0 {
|
||||
if release, ok := index.byTitle[seriesIndexKey(key, year)]; ok {
|
||||
return release, true
|
||||
}
|
||||
}
|
||||
release, ok := index.byTitle[key]
|
||||
return release, ok
|
||||
}
|
||||
|
||||
// heroPremiere is one thing Sonarr calls a premiere, resolved onto the Emby series the
|
||||
// household can actually play.
|
||||
type heroPremiere struct {
|
||||
EmbySeriesID string
|
||||
SeasonNumber int
|
||||
AiredAt time.Time
|
||||
}
|
||||
|
||||
// sonarrPremieres picks the premieres out of a calendar window.
|
||||
//
|
||||
// A premiere is the *first episode of a season* — S01E01 is a new show and S02E01 is a
|
||||
// returning one, and the answered design question is that both are news where the fourth
|
||||
// episode of something already in Continue Watching is not. Three filters do the work and
|
||||
// each removes a card that would misfire:
|
||||
//
|
||||
// - Season 0 is specials. A Christmas special is not a premiere.
|
||||
// - HasFile is required, because a hero card exists to be pressed.
|
||||
// - The show must be one Emby holds, or the card has no page and no artwork.
|
||||
//
|
||||
// The most recent premiere per series wins: a show that premiered and then returned
|
||||
// inside one window is one card about its newer season, not two.
|
||||
func sonarrPremieres(
|
||||
episodes []sonarr.Episode,
|
||||
series seriesIndex,
|
||||
from, until time.Time,
|
||||
) []heroPremiere {
|
||||
best := map[string]heroPremiere{}
|
||||
order := make([]string, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
if episode.EpisodeNumber != 1 || episode.SeasonNumber < 1 || !episode.HasFile {
|
||||
continue
|
||||
}
|
||||
if episode.AirDateUTC == nil {
|
||||
continue
|
||||
}
|
||||
aired := *episode.AirDateUTC
|
||||
if aired.Before(from) || aired.After(until) {
|
||||
continue
|
||||
}
|
||||
embyID := series.lookup(episode.Series.Title, episode.Series.Year)
|
||||
if embyID == "" {
|
||||
continue
|
||||
}
|
||||
existing, seen := best[embyID]
|
||||
if !seen {
|
||||
order = append(order, embyID)
|
||||
}
|
||||
if seen && !aired.After(existing.AiredAt) {
|
||||
continue
|
||||
}
|
||||
best[embyID] = heroPremiere{
|
||||
EmbySeriesID: embyID,
|
||||
SeasonNumber: episode.SeasonNumber,
|
||||
AiredAt: aired,
|
||||
}
|
||||
}
|
||||
out := make([]heroPremiere, 0, len(order))
|
||||
for _, embyID := range order {
|
||||
out = append(out, best[embyID])
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].AiredAt.After(out[j].AiredAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// heroRow composes the row. It is the only impure part of the feature, and every failure
|
||||
// inside it costs a signal rather than the hero: a Radarr that will not answer means
|
||||
// films fall back to Emby's premiere dates, a Sonarr that will not answer means no
|
||||
// premieres, and neither means the launcher gets the ranking it had before.
|
||||
func (s *Server) heroRow(
|
||||
ctx context.Context,
|
||||
rows []recommend.Row,
|
||||
now time.Time,
|
||||
) *recommend.Row {
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
candidates := s.heroCandidates(ctx, rows, now)
|
||||
ranked := rankHeroCandidates(candidates, now, heroRowLimit)
|
||||
if len(ranked) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]json.RawMessage, 0, len(ranked))
|
||||
for _, candidate := range ranked {
|
||||
items = append(items, injectHeroFields(
|
||||
candidate.Item,
|
||||
heroLabel(candidate, now),
|
||||
heroReason(candidate, now, location),
|
||||
))
|
||||
}
|
||||
return &recommend.Row{
|
||||
ID: heroRowID,
|
||||
Title: "Featured",
|
||||
Kind: heroRowKind,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
// heroCandidates gathers everything eligible, premieres first.
|
||||
//
|
||||
// Premieres lead the input order so that they win a tie against a film of identical
|
||||
// score — a returning show is the more time-sensitive piece of news, and the scorer
|
||||
// cannot see that.
|
||||
func (s *Server) heroCandidates(
|
||||
ctx context.Context,
|
||||
rows []recommend.Row,
|
||||
now time.Time,
|
||||
) []heroCandidate {
|
||||
movies, facts := heroMovieCandidates(rows)
|
||||
|
||||
// Three independent reads, and on the one cache miss a day two of them are *arr round
|
||||
// trips. They run together rather than in turn because this is the tail of the home
|
||||
// response: every television in the house is waiting on it, and there is no reason for
|
||||
// Sonarr's answer to be behind Radarr's.
|
||||
var (
|
||||
releases heroReleaseIndex
|
||||
premieres []heroCandidate
|
||||
providers map[string]string
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
|
||||
go func() { defer wg.Done(); premieres = s.heroPremiereCandidates(ctx, now) }()
|
||||
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
|
||||
wg.Wait()
|
||||
|
||||
for index := range movies {
|
||||
fact := facts[movies[index].ID]
|
||||
// Radarr's digital date is preferred over Emby's PremiereDate wherever there is
|
||||
// one. That preference is the point of the feature: Emby's date is the
|
||||
// theatrical release where it is right at all, and is a metadata agent's guess
|
||||
// where it is not, so ranking "new releases" by it puts films in an order that
|
||||
// has nothing to do with when the household could first watch them.
|
||||
if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
|
||||
movies[index].ReleasedAt = release.at
|
||||
movies[index].Estimated = release.estimated
|
||||
}
|
||||
}
|
||||
|
||||
return append(premieres, movies...)
|
||||
}
|
||||
|
||||
// heroMovieCandidates reads the assembled rows. Nothing is fetched: these are the same
|
||||
// payloads the launcher is about to be sent, ratings already attached.
|
||||
func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]heroItemFacts) {
|
||||
candidates := make([]heroCandidate, 0, heroCandidateLimit)
|
||||
facts := make(map[string]heroItemFacts, heroCandidateLimit)
|
||||
seen := make(map[string]bool, heroCandidateLimit)
|
||||
for _, row := range rows {
|
||||
// Continue Watching is what somebody is already in the middle of, which is the
|
||||
// opposite of what a hero is for; the schedule rows are not playable at all.
|
||||
if row.Kind == "continue" || row.Kind == "schedule" || row.Kind == "movie-schedule" {
|
||||
continue
|
||||
}
|
||||
for _, raw := range row.Items {
|
||||
if len(candidates) >= heroCandidateLimit {
|
||||
return candidates, facts
|
||||
}
|
||||
fact, ok := heroFactsOf(raw)
|
||||
if !ok || seen[fact.ID] || !fact.Playable ||
|
||||
!strings.EqualFold(fact.Type, "Movie") {
|
||||
continue
|
||||
}
|
||||
seen[fact.ID] = true
|
||||
facts[fact.ID] = fact
|
||||
rating, rated := heroRatingOf(raw)
|
||||
candidates = append(candidates, heroCandidate{
|
||||
ID: fact.ID,
|
||||
Name: fact.Name,
|
||||
Kind: heroMovie,
|
||||
Item: raw,
|
||||
ReleasedAt: fact.Premiere,
|
||||
Rating: rating,
|
||||
Rated: rated,
|
||||
})
|
||||
}
|
||||
}
|
||||
return candidates, facts
|
||||
}
|
||||
|
||||
func heroYearOf(fact heroItemFacts) int {
|
||||
if fact.Premiere.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return fact.Premiere.Year()
|
||||
}
|
||||
|
||||
// heroProviderIDs resolves the candidates onto TMDB ids so Radarr can be matched exactly.
|
||||
// A failure costs the exact match and leaves the title/year fallback.
|
||||
func (s *Server) heroProviderIDs(
|
||||
ctx context.Context,
|
||||
facts map[string]heroItemFacts,
|
||||
) map[string]string {
|
||||
out := make(map[string]string, len(facts))
|
||||
if s.store == nil || len(facts) == 0 {
|
||||
return out
|
||||
}
|
||||
ids := make([]string, 0, len(facts))
|
||||
for id := range facts {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
refs, err := s.store.LibraryProviderIDs(ctx, ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero provider ids unavailable", "error", err)
|
||||
return out
|
||||
}
|
||||
for id, ref := range refs {
|
||||
if tmdb := strings.TrimSpace(providerID(ref.ProviderIDs, "tmdb")); tmdb != "" {
|
||||
out[id] = tmdb
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heroReleaseIndex reads Radarr over the window that has already happened, cached for the
|
||||
// day beside the schedule row's forward-looking one.
|
||||
func (s *Server) heroReleaseIndex(ctx context.Context, now time.Time) heroReleaseIndex {
|
||||
empty := heroReleaseIndex{
|
||||
byTMDB: map[string]radarrRelease{},
|
||||
byTitle: map[string]radarrRelease{},
|
||||
}
|
||||
if s.radarr == nil {
|
||||
return empty
|
||||
}
|
||||
location := s.cfg.RadarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
dayStart := localDayStart(now.In(location), location)
|
||||
key := heroReleasedCachePrefix + dayStart.Format("2006-01-02")
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var movies []radarr.Movie
|
||||
if json.Unmarshal(raw, &movies) == nil {
|
||||
return newHeroReleaseIndex(movies)
|
||||
}
|
||||
}
|
||||
|
||||
s.radarrMu.Lock()
|
||||
defer s.radarrMu.Unlock()
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var movies []radarr.Movie
|
||||
if json.Unmarshal(raw, &movies) == nil {
|
||||
return newHeroReleaseIndex(movies)
|
||||
}
|
||||
}
|
||||
|
||||
// The cinema fallback is cinema + 30 days, so a film whose digital date is unknown
|
||||
// but which is inside the window had its cinema date up to 30 days before that.
|
||||
movies, err := s.radarr.Calendar(
|
||||
ctx,
|
||||
dayStart.AddDate(0, 0, -(heroWindowDays+radarrTheatricalDelayDays)),
|
||||
dayStart.AddDate(0, 0, 1),
|
||||
)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero release calendar failed", "error", err)
|
||||
return empty
|
||||
}
|
||||
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.RadarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("hero release cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return newHeroReleaseIndex(movies)
|
||||
}
|
||||
|
||||
// heroPremiereCandidates reads Sonarr's recent calendar and turns each premiere into the
|
||||
// Emby series card the household can play.
|
||||
func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []heroCandidate {
|
||||
if s.sonarr == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
dayStart := localDayStart(now.In(location), location)
|
||||
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
|
||||
|
||||
var episodes []sonarr.Episode
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
_ = json.Unmarshal(raw, &episodes)
|
||||
}
|
||||
if episodes == nil {
|
||||
s.sonarrMu.Lock()
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
_ = json.Unmarshal(raw, &episodes)
|
||||
}
|
||||
if episodes == nil {
|
||||
fetched, err := s.sonarr.Calendar(
|
||||
ctx,
|
||||
dayStart.AddDate(0, 0, -heroWindowDays),
|
||||
dayStart.AddDate(0, 0, 1),
|
||||
)
|
||||
if err != nil {
|
||||
s.sonarrMu.Unlock()
|
||||
s.loggerFor(ctx).Warn("hero premiere calendar failed", "error", err)
|
||||
return nil
|
||||
}
|
||||
episodes = fetched
|
||||
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("hero premiere cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.sonarrMu.Unlock()
|
||||
}
|
||||
|
||||
premieres := sonarrPremieres(
|
||||
episodes,
|
||||
s.embySeriesIndex(ctx),
|
||||
now.AddDate(0, 0, -heroWindowDays),
|
||||
now,
|
||||
)
|
||||
if len(premieres) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(premieres))
|
||||
for _, premiere := range premieres {
|
||||
ids = append(ids, premiere.EmbySeriesID)
|
||||
}
|
||||
payloads, err := s.store.LibraryItemsByID(ctx, ids)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("hero premiere series unavailable", "error", err)
|
||||
return nil
|
||||
}
|
||||
// The imported catalogue is shared by the household and deliberately carries no user
|
||||
// data, so these cards arrive without ratings. Decorating them is one indexed read
|
||||
// and is what lets a premiere be ranked on the same terms as a film.
|
||||
s.decorateItemRatings(ctx, payloads)
|
||||
|
||||
byID := make(map[string]json.RawMessage, len(payloads))
|
||||
for _, raw := range payloads {
|
||||
if id := itemIDOf(raw); id != "" {
|
||||
byID[id] = raw
|
||||
}
|
||||
}
|
||||
candidates := make([]heroCandidate, 0, len(premieres))
|
||||
for _, premiere := range premieres {
|
||||
raw, ok := byID[premiere.EmbySeriesID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fact, ok := heroFactsOf(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
kind := heroSeasonPremiere
|
||||
if premiere.SeasonNumber == 1 {
|
||||
kind = heroSeriesPremiere
|
||||
}
|
||||
rating, rated := heroRatingOf(raw)
|
||||
candidates = append(candidates, heroCandidate{
|
||||
ID: fact.ID,
|
||||
Name: fact.Name,
|
||||
Kind: kind,
|
||||
Item: raw,
|
||||
ReleasedAt: premiere.AiredAt,
|
||||
Rating: rating,
|
||||
Rated: rated,
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
)
|
||||
|
||||
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
|
||||
|
||||
func heroDaysAgo(days int) time.Time { return heroNow.AddDate(0, 0, -days) }
|
||||
|
||||
func heroItem(id, name, itemType string) json.RawMessage {
|
||||
raw, err := json.Marshal(map[string]any{"Id": id, "Name": name, "Type": itemType})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func heroMovieCandidate(id string, released time.Time, rating float64, rated bool) heroCandidate {
|
||||
return heroCandidate{
|
||||
ID: id,
|
||||
Name: id,
|
||||
Kind: heroMovie,
|
||||
Item: heroItem(id, id, "Movie"),
|
||||
ReleasedAt: released,
|
||||
Rating: rating,
|
||||
Rated: rated,
|
||||
}
|
||||
}
|
||||
|
||||
// The request this whole feature answers: a well-received release should be able to lead
|
||||
// over a fresher one nobody liked, rather than the launcher being a list sorted only by
|
||||
// whichever date happened to be attached.
|
||||
func TestHeroRankingPrefersAWellRatedReleaseOverAFresherPoorOne(t *testing.T) {
|
||||
candidates := []heroCandidate{
|
||||
heroMovieCandidate("fresh-and-bad", heroDaysAgo(1), 0.30, true),
|
||||
heroMovieCandidate("recent-and-good", heroDaysAgo(6), 0.92, true),
|
||||
}
|
||||
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
||||
if len(ranked) != 2 || ranked[0].ID != "recent-and-good" {
|
||||
t.Fatalf("expected the well-reviewed release to lead, got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: quality must not be able to overturn recency
|
||||
// altogether, or the hero becomes a list of the library's best films and stops being
|
||||
// about what is new at all.
|
||||
func TestHeroRankingKeepsAFreshReleaseAheadOfAnOldAcclaimedOne(t *testing.T) {
|
||||
candidates := []heroCandidate{
|
||||
heroMovieCandidate("old-masterpiece", heroDaysAgo(400), 1.0, true),
|
||||
heroMovieCandidate("new-and-decent", heroDaysAgo(2), 0.70, true),
|
||||
}
|
||||
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
||||
if ranked[0].ID != "new-and-decent" {
|
||||
t.Fatalf("expected the new release to lead, got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// An unrated title is not a bad title. On a household that has never configured MDBList
|
||||
// this is every title, and burying them would leave the hero empty.
|
||||
func TestHeroRankingDoesNotBuryUnratedTitles(t *testing.T) {
|
||||
candidates := []heroCandidate{
|
||||
heroMovieCandidate("rated-poorly", heroDaysAgo(3), 0.20, true),
|
||||
heroMovieCandidate("unrated", heroDaysAgo(3), 0, false),
|
||||
}
|
||||
ranked := rankHeroCandidates(candidates, heroNow, 4)
|
||||
if ranked[0].ID != "unrated" {
|
||||
t.Fatalf("expected the unrated title to outrank the poorly rated one, got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// Radarr's cinema + 30 days is a guess, and a guess must not outrank a published date.
|
||||
func TestHeroRankingPenalisesAnEstimatedRelease(t *testing.T) {
|
||||
known := heroMovieCandidate("known", heroDaysAgo(4), 0.60, true)
|
||||
estimated := heroMovieCandidate("estimated", heroDaysAgo(4), 0.60, true)
|
||||
estimated.Estimated = true
|
||||
ranked := rankHeroCandidates([]heroCandidate{estimated, known}, heroNow, 4)
|
||||
if ranked[0].ID != "known" {
|
||||
t.Fatalf("expected the published date to lead, got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// A release Radarr has dated but which has not happened yet belongs to the schedule row.
|
||||
// The hero exists to be pressed.
|
||||
func TestHeroRecencyIgnoresFutureReleases(t *testing.T) {
|
||||
if score := heroRecency(heroNow.AddDate(0, 0, 3), heroNow); score != 0 {
|
||||
t.Fatalf("expected a future release to score 0, got %v", score)
|
||||
}
|
||||
if score := heroRecency(time.Time{}, heroNow); score != 0 {
|
||||
t.Fatalf("expected an unknown release to score 0, got %v", score)
|
||||
}
|
||||
if score := heroRecency(heroDaysAgo(heroWindowDays), heroNow); score != 0 {
|
||||
t.Fatalf("expected the window edge to score 0, got %v", score)
|
||||
}
|
||||
if score := heroRecency(heroNow, heroNow); score != 1 {
|
||||
t.Fatalf("expected a release today to score 1, got %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRankingDeduplicatesAndCaps(t *testing.T) {
|
||||
candidates := []heroCandidate{
|
||||
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
|
||||
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
|
||||
heroMovieCandidate("b", heroDaysAgo(2), 0.8, true),
|
||||
heroMovieCandidate("c", heroDaysAgo(3), 0.7, true),
|
||||
}
|
||||
ranked := rankHeroCandidates(candidates, heroNow, 2)
|
||||
if len(ranked) != 2 || ranked[0].ID != "a" || ranked[1].ID != "b" {
|
||||
t.Fatalf("expected [a b], got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate with no payload cannot be drawn, so it must never take one of four slots.
|
||||
func TestHeroRankingSkipsCandidatesWithNothingToDraw(t *testing.T) {
|
||||
empty := heroMovieCandidate("empty", heroDaysAgo(1), 1.0, true)
|
||||
empty.Item = nil
|
||||
ranked := rankHeroCandidates(
|
||||
[]heroCandidate{empty, heroMovieCandidate("real", heroDaysAgo(9), 0.4, true)},
|
||||
heroNow, 4,
|
||||
)
|
||||
if len(ranked) != 1 || ranked[0].ID != "real" {
|
||||
t.Fatalf("expected only the drawable candidate, got %+v", heroIDs(ranked))
|
||||
}
|
||||
}
|
||||
|
||||
// Labels are claims. Each one has to have been earned, which is exactly what the
|
||||
// positional captions this replaced could not promise.
|
||||
func TestHeroLabels(t *testing.T) {
|
||||
series := heroMovieCandidate("s", heroDaysAgo(2), 0.5, true)
|
||||
series.Kind = heroSeriesPremiere
|
||||
season := series
|
||||
season.Kind = heroSeasonPremiere
|
||||
|
||||
acclaimedOld := heroMovieCandidate("old", heroDaysAgo(300), 0.88, true)
|
||||
quietOld := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
|
||||
|
||||
cases := []struct {
|
||||
candidate heroCandidate
|
||||
want string
|
||||
}{
|
||||
{series, heroLabelSeriesPremiere},
|
||||
{season, heroLabelSeasonPremiere},
|
||||
{heroMovieCandidate("new", heroDaysAgo(2), 0.5, true), heroLabelNewRelease},
|
||||
{acclaimedOld, heroLabelAcclaimed},
|
||||
{quietOld, heroLabelLibrary},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := heroLabel(testCase.candidate, heroNow); got != testCase.want {
|
||||
t.Fatalf("label for %q: want %q, got %q", testCase.candidate.ID, testCase.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A card with nothing true to say says nothing, rather than inventing a reason.
|
||||
func TestHeroReasonIsEmptyWithoutEvidence(t *testing.T) {
|
||||
quiet := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
|
||||
if reason := heroReason(quiet, heroNow, time.UTC); reason != "" {
|
||||
t.Fatalf("expected no reason, got %q", reason)
|
||||
}
|
||||
unknown := heroMovieCandidate("unknown", time.Time{}, 0, false)
|
||||
if reason := heroReason(unknown, heroNow, time.UTC); reason != "" {
|
||||
t.Fatalf("expected no reason, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroReasonWording(t *testing.T) {
|
||||
premiere := heroMovieCandidate("p", heroDaysAgo(1), 0.5, true)
|
||||
premiere.Kind = heroSeriesPremiere
|
||||
if got, want := heroReason(premiere, heroNow, time.UTC), "A new series premiered yesterday"; got != want {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
estimated := heroMovieCandidate("e", heroDaysAgo(0), 0.5, true)
|
||||
estimated.Estimated = true
|
||||
if got, want := heroReason(estimated, heroNow, time.UTC), "Expected to have landed today"; got != want {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
acclaimed := heroMovieCandidate("a", heroDaysAgo(1), 0.9, true)
|
||||
if got, want := heroReason(acclaimed, heroNow, time.UTC), "Well reviewed, released yesterday"; got != want {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ratings are read off the card the launcher is about to be sent, and averaged across
|
||||
// providers measured on different scales.
|
||||
func TestHeroRatingOfAveragesAcrossScales(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"Id": "x",
|
||||
"MembyRatings": []map[string]string{
|
||||
{"source": "imdb", "score": "8.0"},
|
||||
{"source": "tomatoes", "score": "90"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rating, rated := heroRatingOf(raw)
|
||||
if !rated {
|
||||
t.Fatal("expected the card to be rated")
|
||||
}
|
||||
if want := (0.8 + 0.9) / 2; rating < want-0.0001 || rating > want+0.0001 {
|
||||
t.Fatalf("want %v, got %v", want, rating)
|
||||
}
|
||||
}
|
||||
|
||||
// Emby's own score orders the hero when MDBList has never been asked. It is never drawn
|
||||
// (that would name a provider nobody consulted) but ordering four cards by it claims
|
||||
// nothing to anyone, and it is what makes this work before ratings are configured.
|
||||
func TestHeroRatingFallsBackToCommunityRating(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{"Id": "x", "CommunityRating": 7.5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rating, rated := heroRatingOf(raw)
|
||||
if !rated || rating < 0.74 || rating > 0.76 {
|
||||
t.Fatalf("want 0.75, got %v (rated=%v)", rating, rated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeroRatingUnratedIsNotZero(t *testing.T) {
|
||||
if _, rated := heroRatingOf(heroItem("x", "X", "Movie")); rated {
|
||||
t.Fatal("expected an item with no scores to be unrated")
|
||||
}
|
||||
// An out-of-range or unparsable score is the same as no score, not a score of nil.
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"Id": "x",
|
||||
"MembyRatings": []map[string]string{{"source": "imdb", "score": "n/a"}},
|
||||
})
|
||||
if _, rated := heroRatingOf(raw); rated {
|
||||
t.Fatal("expected an unparsable score to leave the card unrated")
|
||||
}
|
||||
}
|
||||
|
||||
// Radarr's digital date is the answer Emby's PremiereDate is asked for and gets wrong.
|
||||
func TestHeroReleaseIndexPrefersTMDBThenTitleAndYear(t *testing.T) {
|
||||
digital := heroDaysAgo(3)
|
||||
cinema := heroDaysAgo(40)
|
||||
index := newHeroReleaseIndex([]radarr.Movie{
|
||||
{ID: 1, TMDBID: 550, Title: "Fight Club", Year: 1999, DigitalRelease: &digital},
|
||||
{ID: 2, Title: "The Thing", Year: 1982, InCinemas: &cinema},
|
||||
{ID: 3, Title: "The Thing", Year: 2011, InCinemas: &cinema},
|
||||
})
|
||||
|
||||
release, ok := index.lookup("550", "", 0)
|
||||
if !ok || !release.at.Equal(digital) || release.estimated {
|
||||
t.Fatalf("expected the exact tmdb match, got %+v (ok=%v)", release, ok)
|
||||
}
|
||||
// A title with no tmdb id still resolves, and the year keeps the remake apart from
|
||||
// the original.
|
||||
if _, ok := index.lookup("", "The Thing", 1982); !ok {
|
||||
t.Fatal("expected the title/year fallback to resolve")
|
||||
}
|
||||
estimated, ok := index.lookup("", "The Thing", 2011)
|
||||
if !ok || !estimated.estimated {
|
||||
t.Fatalf("expected a cinema date to be reported as estimated, got %+v", estimated)
|
||||
}
|
||||
if _, ok := index.lookup("", "Nothing Like It", 2020); ok {
|
||||
t.Fatal("expected an unknown title to resolve to nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func sonarrPremiereEpisode(
|
||||
series string, seasonNumber, episodeNumber int, aired time.Time, hasFile bool,
|
||||
) sonarr.Episode {
|
||||
return sonarr.Episode{
|
||||
SeasonNumber: seasonNumber,
|
||||
EpisodeNumber: episodeNumber,
|
||||
AirDateUTC: &aired,
|
||||
HasFile: hasFile,
|
||||
Series: sonarr.Series{Title: series},
|
||||
}
|
||||
}
|
||||
|
||||
// "Series premieres only, not random TV series" is this function, and most of it is
|
||||
// about refusing.
|
||||
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
||||
index := seriesIndex{
|
||||
"newshow": "emby-new",
|
||||
"returning": "emby-returning",
|
||||
"notinlibrary": "",
|
||||
"specials": "emby-specials",
|
||||
"undownloaded": "emby-undownloaded",
|
||||
}
|
||||
delete(index, "notinlibrary")
|
||||
|
||||
episodes := []sonarr.Episode{
|
||||
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(2), true),
|
||||
sonarrPremiereEpisode("Returning", 3, 1, heroDaysAgo(5), true),
|
||||
// Not a premiere: an ordinary episode of something already under way.
|
||||
sonarrPremiereEpisode("Returning", 3, 4, heroDaysAgo(1), true),
|
||||
// Season 0 is specials, not a premiere.
|
||||
sonarrPremiereEpisode("Specials", 0, 1, heroDaysAgo(1), true),
|
||||
// Not downloaded — news for the schedule row, not a card to press.
|
||||
sonarrPremiereEpisode("Undownloaded", 1, 1, heroDaysAgo(1), false),
|
||||
// Emby has never imported this show, so the card would have no page.
|
||||
sonarrPremiereEpisode("Not In Library", 1, 1, heroDaysAgo(1), true),
|
||||
// Outside the window.
|
||||
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(90), true),
|
||||
}
|
||||
|
||||
premieres := sonarrPremieres(episodes, index, heroDaysAgo(heroWindowDays), heroNow)
|
||||
if len(premieres) != 2 {
|
||||
t.Fatalf("expected 2 premieres, got %d: %+v", len(premieres), premieres)
|
||||
}
|
||||
if premieres[0].EmbySeriesID != "emby-new" || premieres[0].SeasonNumber != 1 {
|
||||
t.Fatalf("expected the newest premiere first, got %+v", premieres[0])
|
||||
}
|
||||
if premieres[1].EmbySeriesID != "emby-returning" || premieres[1].SeasonNumber != 3 {
|
||||
t.Fatalf("expected the returning show second, got %+v", premieres[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A show that premiered and then returned inside one window is one card about its newer
|
||||
// season, not two cards about the same show.
|
||||
func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) {
|
||||
index := seriesIndex{"show": "emby-show"}
|
||||
premieres := sonarrPremieres([]sonarr.Episode{
|
||||
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
|
||||
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
|
||||
}, index, heroDaysAgo(heroWindowDays), heroNow)
|
||||
if len(premieres) != 1 {
|
||||
t.Fatalf("expected one card, got %d", len(premieres))
|
||||
}
|
||||
if premieres[0].SeasonNumber != 2 {
|
||||
t.Fatalf("expected the newer season, got season %d", premieres[0].SeasonNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// The hero draws from the rows the launcher is already being sent, and the rows it must
|
||||
// not draw from are the ones whose cards cannot be pressed or are already in progress.
|
||||
func TestHeroMovieCandidatesSkipUnpressableRows(t *testing.T) {
|
||||
scheduleCard, _ := json.Marshal(map[string]any{
|
||||
"Id": "radarr:1", "Name": "Coming Soon", "Type": "Movie",
|
||||
"MembySource": "radarr", "MembyPlayable": false,
|
||||
})
|
||||
rows := []recommend.Row{
|
||||
{ID: "continue", Kind: "continue", Items: []json.RawMessage{heroItem("resume", "Resume", "Movie")}},
|
||||
{ID: "movie-schedule", Kind: "movie-schedule", Items: []json.RawMessage{scheduleCard}},
|
||||
{ID: "latest", Kind: "latest", Items: []json.RawMessage{
|
||||
heroItem("film", "Film", "Movie"),
|
||||
heroItem("show", "Show", "Series"),
|
||||
}},
|
||||
}
|
||||
candidates, facts := heroMovieCandidates(rows)
|
||||
if len(candidates) != 1 || candidates[0].ID != "film" {
|
||||
t.Fatalf("expected only the playable film, got %+v", heroIDs(candidates))
|
||||
}
|
||||
if _, ok := facts["film"]; !ok {
|
||||
t.Fatal("expected the film's facts to be recorded for the Radarr lookup")
|
||||
}
|
||||
}
|
||||
|
||||
// Emby writes dates in more than one shape, and one it will not parse is unknown rather
|
||||
// than fatal.
|
||||
func TestParseEmbyDate(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"2026-08-01T00:00:00.0000000Z",
|
||||
"2026-08-01T00:00:00Z",
|
||||
"2026-08-01",
|
||||
} {
|
||||
parsed, err := parseEmbyDate(value)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", value, err)
|
||||
}
|
||||
if parsed.Year() != 2026 || parsed.Month() != time.August || parsed.Day() != 1 {
|
||||
t.Fatalf("%q parsed to %v", value, parsed)
|
||||
}
|
||||
}
|
||||
if _, err := parseEmbyDate("not a date"); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if _, err := parseEmbyDate(""); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
}
|
||||
|
||||
// The caption and the reason ride on the item, and an unknown field must survive being
|
||||
// decorated — the payload is Emby's, forwarded verbatim.
|
||||
func TestInjectHeroFieldsPreservesUnknownFields(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"Id": "x", "SomethingNew": "keep me"})
|
||||
out := injectHeroFields(raw, heroLabelNewRelease, "Released yesterday")
|
||||
var members map[string]any
|
||||
if err := json.Unmarshal(out, &members); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if members["SomethingNew"] != "keep me" {
|
||||
t.Fatalf("unknown field was dropped: %v", members)
|
||||
}
|
||||
if members[heroLabelField] != heroLabelNewRelease {
|
||||
t.Fatalf("label missing: %v", members)
|
||||
}
|
||||
if members[heroReasonField] != "Released yesterday" {
|
||||
t.Fatalf("reason missing: %v", members)
|
||||
}
|
||||
// Nothing to say leaves the payload exactly as it was.
|
||||
if got := injectHeroFields(raw, "", ""); string(got) != string(raw) {
|
||||
t.Fatalf("expected the payload untouched, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func heroIDs(candidates []heroCandidate) []string {
|
||||
ids := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
ids = append(ids, candidate.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -67,10 +67,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
limit := queryInt(r, "limit", 24, 100)
|
||||
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
||||
hero := supportsHomeHero(r)
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
"home:v3:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
|
||||
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
|
||||
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
|
||||
":d"+sess.DeviceID,
|
||||
)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
@@ -282,6 +284,16 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
// the launcher pays one indexed read rather than a request per poster, and a card
|
||||
// shows its scores as it is drawn instead of when focus reaches it.
|
||||
s.decorateHomeRatings(ctx, &out)
|
||||
// The hero is composed last, from the finished rows, because that is the only point
|
||||
// at which the ratings it ranks by are already attached. It is prepended rather than
|
||||
// inserted: the television consumes this row instead of drawing it, so its position
|
||||
// among the shelves means nothing, and being first is what lets an older reader that
|
||||
// does draw it put it somewhere sensible.
|
||||
if hero {
|
||||
if row := s.heroRow(ctx, out.Rows, time.Now()); row != nil {
|
||||
out.Rows = append([]recommend.Row{*row}, out.Rows...)
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
@@ -445,6 +457,20 @@ func supportsRadarrSchedule(r *http.Request) bool {
|
||||
return version != "" && appupdate.CompareVersions(version, "0.1.79") >= 0
|
||||
}
|
||||
|
||||
// The server-composed hero ships in 0.2.27. Gating it matters more than gating a shelf:
|
||||
// a television that predates it has no idea the "hero" kind is meant to be consumed
|
||||
// rather than drawn, so it renders the featured cards a second time as a "Featured" row
|
||||
// of posters beneath the hero it picked for itself.
|
||||
//
|
||||
// Note that 0.2.27 is also the version the feature was *added to* rather than a version
|
||||
// after it, so any 0.2.27 build already in the field is one of the televisions this is
|
||||
// meant to exclude. That is a deliberate call by the operator; if it bites, moving this
|
||||
// floor to the next version is the fix, not a client change.
|
||||
func supportsHomeHero(r *http.Request) bool {
|
||||
version := clientVersion(r)
|
||||
return version != "" && appupdate.CompareVersions(version, "0.2.27") >= 0
|
||||
}
|
||||
|
||||
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
|
||||
// request, so the Dream still looks random without re-querying Emby every few seconds.
|
||||
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// introMinimumMs is the shortest span worth calling an intro. Emby's detector
|
||||
// occasionally writes a pair a couple of seconds apart on a title whose opening it
|
||||
// half-recognised, and a button that skips two seconds is worse than no button:
|
||||
// somebody presses it, the picture does not visibly move, and the feature reads as
|
||||
// broken.
|
||||
introMinimumMs = 5_000
|
||||
|
||||
// introMaximumMs is the longest. A pair minutes apart is a mis-detection — a recap, a
|
||||
// cold open, or two unrelated markers read as a range — and honouring it would throw a
|
||||
// viewer past the start of the story. FROM's openings run about two minutes; this is
|
||||
// generous enough to cover a long title sequence and short enough to refuse nonsense.
|
||||
introMaximumMs = 5 * 60 * 1_000
|
||||
|
||||
// introTTL keeps a found segment for a day. Chapter markers only change when the media
|
||||
// is re-analysed, and every playback of an episode asks for this once.
|
||||
introTTL = 24 * time.Hour
|
||||
|
||||
// introMissingTTL is how long "this episode has no intro markers" is remembered.
|
||||
// Deliberately shorter, for the same reason the trickplay one is: Emby detects intros
|
||||
// on a schedule, so an episode imported this afternoon must not be answered from a
|
||||
// day-old no.
|
||||
introMissingTTL = time.Hour
|
||||
)
|
||||
|
||||
// What a viewer has asked to happen when an episode reaches its opening titles. The
|
||||
// television holds the matching vocabulary in `data/SkipIntroPreference.kt`; this is the
|
||||
// side that decides what a legal value is, through the preference catalogue.
|
||||
const (
|
||||
skipIntroPrompt = "prompt"
|
||||
skipIntroAuto = "auto"
|
||||
skipIntroOff = "off"
|
||||
)
|
||||
|
||||
// introSegment is where an episode's title sequence sits, in milliseconds from the start.
|
||||
type introSegment struct {
|
||||
StartMs int64 `json:"startMs"`
|
||||
EndMs int64 `json:"endMs"`
|
||||
}
|
||||
|
||||
// introResponse is what a television is told. Available is explicit rather than implied by
|
||||
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
|
||||
// that has none, and the client defaults to false so a gateway that predates this — or has
|
||||
// the feature turned off — can never conjure a button.
|
||||
type introResponse struct {
|
||||
Available bool `json:"available"`
|
||||
StartMs int64 `json:"startMs,omitempty"`
|
||||
EndMs int64 `json:"endMs,omitempty"`
|
||||
}
|
||||
|
||||
// embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here.
|
||||
//
|
||||
// Emby writes intro markers as ordinary chapters carrying a MarkerType, interleaved with
|
||||
// the real ones in playback order — so they are read out of the same array the chapter
|
||||
// list comes from, and asking for Chapters is the whole of the request.
|
||||
type embyChapter struct {
|
||||
StartPositionTicks int64 `json:"StartPositionTicks"`
|
||||
MarkerType string `json:"MarkerType"`
|
||||
Name string `json:"Name"`
|
||||
}
|
||||
|
||||
// introFromChapters finds the title sequence in a chapter list.
|
||||
//
|
||||
// Pure, and the piece worth testing hard: it is what stands between one bad marker and a
|
||||
// viewer being thrown into the middle of a scene. The rule exists twice — the television's
|
||||
// copy is `introSegmentFrom` in `data/Intro.kt` — and the two are pinned by deliberately
|
||||
// parallel tests (`intro_test.go`, `IntroTest`). With no gateway there is nobody to ask,
|
||||
// and a skip must not land somewhere different depending on whether the container is up.
|
||||
//
|
||||
// Most of the function is about refusing to answer. A pair that is out of order, too
|
||||
// short, too long, or missing half of itself produces nothing at all, and nothing is a
|
||||
// perfectly good answer: the player simply never offers the button.
|
||||
func introFromChapters(chapters []embyChapter) (introSegment, bool) {
|
||||
const (
|
||||
markerStart = "IntroStart"
|
||||
markerEnd = "IntroEnd"
|
||||
)
|
||||
|
||||
start := int64(-1)
|
||||
for _, chapter := range chapters {
|
||||
switch chapter.MarkerType {
|
||||
case markerStart:
|
||||
// The first start wins, and a second one is ignored rather than replacing it.
|
||||
// Two starts mean the markers are already untrustworthy; taking the later one
|
||||
// would pick the larger, more damaging skip of the two.
|
||||
if start < 0 && chapter.StartPositionTicks >= 0 {
|
||||
start = chapter.StartPositionTicks / ticksPerMillisecond
|
||||
}
|
||||
case markerEnd:
|
||||
// An end before any start is a stray marker, not the close of a segment.
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
end := chapter.StartPositionTicks / ticksPerMillisecond
|
||||
length := end - start
|
||||
if length < introMinimumMs || length > introMaximumMs {
|
||||
return introSegment{}, false
|
||||
}
|
||||
return introSegment{StartMs: start, EndMs: end}, true
|
||||
}
|
||||
}
|
||||
return introSegment{}, false
|
||||
}
|
||||
|
||||
// handleIntro answers where an episode's title sequence is, if it has one.
|
||||
//
|
||||
// It is deliberately its own request rather than a field on /v1/items/{id}/playback, the
|
||||
// same call the seek previews make: reading it costs a round trip to Emby for a field
|
||||
// nothing else on the playback path wants, and that response is the one thing standing
|
||||
// between a Play press and a decoder starting. Nothing here is needed before the first
|
||||
// frame — the earliest intro in a typical library starts about two minutes in — so the
|
||||
// television asks once playback has settled.
|
||||
func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
itemID := r.PathValue("id")
|
||||
if itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.skipIntroEnabled(ctx) {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
segment, ok, err := s.introFor(ctx, sess, itemID)
|
||||
if err != nil {
|
||||
// Trouble is answered with "no intro" rather than an error. The button is an
|
||||
// optional convenience on a film that is already playing, and a failure the viewer
|
||||
// cannot act on is not worth a red line in the log for every episode watched.
|
||||
s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
// The same answer for everyone in the house, and it only changes when the media does.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
writeJSON(w, http.StatusOK, introResponse{
|
||||
Available: true,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
|
||||
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
|
||||
}
|
||||
|
||||
func introCacheKey(itemID string) string { return "intro:v1:" + itemID }
|
||||
|
||||
// introFor reads an item's chapter markers, remembering what they came to.
|
||||
//
|
||||
// "No intro" is cached as well as an intro. It is the common case — a film, a special, an
|
||||
// episode Emby has not analysed yet — and without it every playback in the house would be
|
||||
// a fresh request to Emby for the same no.
|
||||
func (s *Server) introFor(
|
||||
ctx context.Context, sess store.Session, itemID string,
|
||||
) (introSegment, bool, error) {
|
||||
key := introCacheKey(itemID)
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cached introResponse
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
|
||||
if err != nil {
|
||||
return introSegment{}, false, err
|
||||
}
|
||||
var parsed struct {
|
||||
Chapters []embyChapter `json:"Chapters"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return introSegment{}, false, err
|
||||
}
|
||||
|
||||
segment, ok := introFromChapters(parsed.Chapters)
|
||||
ttl := introMissingTTL
|
||||
if ok {
|
||||
ttl = introTTL
|
||||
}
|
||||
if encoded, err := json.Marshal(introResponse{
|
||||
Available: ok,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
}); err == nil {
|
||||
_ = s.cache.Set(ctx, key, encoded, ttl)
|
||||
}
|
||||
return segment, ok, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The intro rule, pinned against the same cases as the television's copy
|
||||
// (`IntroTest` in app/src/test). The two exist separately because with no gateway there is
|
||||
// nobody to ask, and a skip must not land somewhere different depending on whether the
|
||||
// container is up — so when one of these changes, the other has to change with it.
|
||||
//
|
||||
// The cases are taken from what Emby actually writes: markers arrive as ordinary chapters
|
||||
// carrying a MarkerType, interleaved with the real ones in playback order.
|
||||
|
||||
func chapter(seconds int64, marker string) embyChapter {
|
||||
return embyChapter{
|
||||
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
|
||||
MarkerType: marker,
|
||||
Name: marker,
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroFromChapters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chapters []embyChapter
|
||||
want introSegment
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
// FROM S01E01 as the server actually holds it: two ordinary chapters, then the
|
||||
// pair, then the rest of the chapters.
|
||||
name: "a real episode",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(300, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
chapter(600, "Chapter"),
|
||||
},
|
||||
want: introSegment{StartMs: 463_000, EndMs: 583_000},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "an intro that starts at the very beginning",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "IntroStart"),
|
||||
chapter(95, "IntroEnd"),
|
||||
},
|
||||
want: introSegment{StartMs: 0, EndMs: 95_000},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "no markers at all",
|
||||
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
name: "no chapters at all",
|
||||
chapters: nil,
|
||||
},
|
||||
{
|
||||
// Half a pair is not a segment. There is no honest end to skip to, and
|
||||
// guessing one is how a viewer lands in the middle of a scene.
|
||||
name: "a start with no end",
|
||||
chapters: []embyChapter{chapter(112, "IntroStart"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
name: "an end with no start",
|
||||
chapters: []embyChapter{chapter(0, "Chapter"), chapter(246, "IntroEnd")},
|
||||
},
|
||||
{
|
||||
name: "an end before its start",
|
||||
chapters: []embyChapter{chapter(300, "IntroStart"), chapter(120, "IntroEnd")},
|
||||
},
|
||||
{
|
||||
// Emby occasionally writes a pair seconds apart on a title whose opening it
|
||||
// half-recognised. A button that moves the picture imperceptibly reads as
|
||||
// broken, so there is deliberately no button at all.
|
||||
name: "a segment too short to be an intro",
|
||||
chapters: []embyChapter{chapter(100, "IntroStart"), chapter(103, "IntroEnd")},
|
||||
},
|
||||
{
|
||||
// Far more likely two unrelated markers read as a range than a ten-minute
|
||||
// title sequence, and honouring it would throw the viewer past the story.
|
||||
name: "a segment too long to be an intro",
|
||||
chapters: []embyChapter{chapter(60, "IntroStart"), chapter(660, "IntroEnd")},
|
||||
},
|
||||
{
|
||||
// The first start wins. Two starts mean the markers are already untrustworthy,
|
||||
// and taking the later one would pick the larger, more damaging skip.
|
||||
name: "two starts before one end",
|
||||
chapters: []embyChapter{
|
||||
chapter(100, "IntroStart"),
|
||||
chapter(160, "IntroStart"),
|
||||
chapter(220, "IntroEnd"),
|
||||
},
|
||||
want: introSegment{StartMs: 100_000, EndMs: 220_000},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "a later pair is ignored once one has been found",
|
||||
chapters: []embyChapter{
|
||||
chapter(100, "IntroStart"),
|
||||
chapter(220, "IntroEnd"),
|
||||
chapter(1800, "IntroStart"),
|
||||
chapter(1900, "IntroEnd"),
|
||||
},
|
||||
want: introSegment{StartMs: 100_000, EndMs: 220_000},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// A credits marker is a different feature that does not exist here yet, and
|
||||
// must never be mistaken for an intro.
|
||||
name: "credit markers are not intros",
|
||||
chapters: []embyChapter{
|
||||
chapter(2800, "CreditsStart"),
|
||||
chapter(2900, "CreditsEnd"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := introFromChapters(tc.chapters)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("available = %v, want %v (segment %+v)", ok, tc.ok, got)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Fatalf("segment = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway with no Emby behind it — and one whose operator has turned the feature off —
|
||||
// answers "no intro" rather than an error. The button is an optional convenience on a film
|
||||
// that is already playing, and a 502 here would be a red line in the log for every episode
|
||||
// anybody watched.
|
||||
func TestIntroWithoutEmbyAnswersUnavailable(t *testing.T) {
|
||||
server := testServer(config.Config{})
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/items/1304864/intro", nil)
|
||||
request.SetPathValue("id", "1304864")
|
||||
rec := httptest.NewRecorder()
|
||||
server.handleIntro(rec, request, store.Session{})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 — trouble is answered with silence here", rec.Code)
|
||||
}
|
||||
var body introResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
if body.Available {
|
||||
t.Fatal("a gateway with nothing to ask must never claim an intro")
|
||||
}
|
||||
}
|
||||
|
||||
// The mode a television is told to use has to be one this build knows, or a set could be
|
||||
// pushed a value it silently does nothing with.
|
||||
func TestSkipIntroPreferenceIsCatalogued(t *testing.T) {
|
||||
definition, ok := preferenceDefinitionFor("skipIntroMode")
|
||||
if !ok {
|
||||
t.Fatal("skipIntroMode is missing from the preference catalogue")
|
||||
}
|
||||
if definition.Default != skipIntroPrompt {
|
||||
t.Fatalf("default = %v, want %q — a button is the asked-for behaviour, not a silent seek",
|
||||
definition.Default, skipIntroPrompt)
|
||||
}
|
||||
wanted := []string{skipIntroPrompt, skipIntroAuto, skipIntroOff}
|
||||
if len(definition.Options) != len(wanted) {
|
||||
t.Fatalf("options = %+v, want the three modes", definition.Options)
|
||||
}
|
||||
for i, value := range wanted {
|
||||
if definition.Options[i].Value != value {
|
||||
t.Fatalf("option %d = %q, want %q", i, definition.Options[i].Value, value)
|
||||
}
|
||||
}
|
||||
|
||||
// An illegal value must come back as the default rather than reaching a player.
|
||||
normalised := normalizePreferences(map[string]any{"skipIntroMode": "immediately"})
|
||||
if normalised["skipIntroMode"] != skipIntroPrompt {
|
||||
t.Fatalf("normalised = %v, want %q", normalised["skipIntroMode"], skipIntroPrompt)
|
||||
}
|
||||
}
|
||||
@@ -171,8 +171,9 @@ func isPlaybackItemPath(path string) bool {
|
||||
return false
|
||||
}
|
||||
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
|
||||
// "search" on its own would claim any future per-item search as playback.
|
||||
if strings.Contains(path, "/subtitles/") {
|
||||
// "search" on its own would claim any future per-item search as playback. A seek
|
||||
// preview is the same shape: the frame number is the last segment, not the word.
|
||||
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") {
|
||||
return true
|
||||
}
|
||||
switch path[strings.LastIndex(path, "/")+1:] {
|
||||
|
||||
@@ -47,6 +47,8 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
|
||||
"/v1/items/42/playback": "playback",
|
||||
"/v1/items/42/next": "playback",
|
||||
"/v1/items/42/subtitles/search": "playback",
|
||||
"/v1/items/42/trickplay": "playback",
|
||||
"/v1/items/42/trickplay/12.jpg": "playback",
|
||||
"/v1/playback/started": "playback",
|
||||
"/v1/images/42/primary": "artwork",
|
||||
"/v1/recommendations": "recommendations",
|
||||
|
||||
@@ -39,6 +39,16 @@ type playbackResponse struct {
|
||||
// already holds this response, and one boolean on a request that is made once per
|
||||
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
|
||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||||
// Whether it is worth asking this gateway for seek previews. Only the answer rides
|
||||
// here; the manifest itself does not, because reading it costs a round trip to Emby
|
||||
// and this response is the one thing standing between a Play press and a decoder
|
||||
// starting. The television asks for the manifest once the first frame is up.
|
||||
TrickplayAvailable bool `json:"trickplayAvailable"`
|
||||
// Whether it is worth asking this gateway where the title sequence is. Only the answer
|
||||
// rides here, for the same reason the previews' does: reading the markers costs a round
|
||||
// trip to Emby, and nothing about a skip button is needed before the first frame. The
|
||||
// television asks for the segment itself once playback has settled.
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -195,6 +205,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,19 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Description: "Show the lower-third when ten minutes are left.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
// The vocabulary is duplicated on the television (data/SkipIntroPreference.kt),
|
||||
// which normalises anything it does not recognise — so a mode added here reaches an
|
||||
// older set as "prompt" rather than as silence.
|
||||
Key: "skipIntroMode", Name: "Skip the title sequence", Area: "Playback",
|
||||
Description: "What to do when an episode reaches its opening titles.",
|
||||
Kind: preferenceChoice, Default: skipIntroPrompt,
|
||||
Options: []preferenceOption{
|
||||
option(skipIntroPrompt, "Offer a button"),
|
||||
option(skipIntroAuto, "Skip automatically"),
|
||||
option(skipIntroOff, "Do nothing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The television decodes this into GatewayTrickplay; the matching Kotlin test is
|
||||
// `decodes a seek preview layout` in GatewayPayloadTest. Rename a field on either side and
|
||||
// one of them fails before a TV ever sees it.
|
||||
func TestTrickplayManifestShape(t *testing.T) {
|
||||
raw, err := json.Marshal(trickplayManifest{
|
||||
Available: true, IntervalMs: 10_000, Count: 817, Width: 320, Height: 172,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
want := `{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}`
|
||||
if string(raw) != want {
|
||||
t.Fatalf("manifest = %s, want %s", raw, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrickplayManifestOmitsTheRestWhenUnavailable(t *testing.T) {
|
||||
// A title with no thumbnails, an operator who has turned the feature off and a gateway
|
||||
// with no Emby all mean the same thing to a television, so they must all look the same
|
||||
// on the wire — and none of them may carry a count it would try to draw from.
|
||||
raw, err := json.Marshal(trickplayManifest{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if string(raw) != `{"available":false}` {
|
||||
t.Fatalf("manifest = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrickplayAnswersUnavailableRatherThanFailing(t *testing.T) {
|
||||
// A gateway with no Emby configured still answers, with 200 and "no previews". The
|
||||
// seek indicator is a complete answer without a thumbnail, and an error here would be
|
||||
// one nobody could act on, logged once per press of Right.
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/items/42/trickplay", nil)
|
||||
req.SetPathValue("id", "42")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.handleTrickplay(rec, req, store.Session{EmbyUserID: "user-1"})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var manifest trickplayManifest
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if manifest.Available {
|
||||
t.Fatal("a gateway with no Emby offered seek previews")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrickplayFrameRejectsAnUnreadableIndex(t *testing.T) {
|
||||
s := &Server{}
|
||||
for _, frame := range []string{"", "-1", "twelve", "12.png.jpg"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/items/42/trickplay/"+frame, nil)
|
||||
req.SetPathValue("id", "42")
|
||||
req.SetPathValue("frame", frame)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
s.handleTrickplayFrame(rec, req, store.Session{EmbyUserID: "user-1"})
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("frame %q: status = %d, want 404", frame, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.1.20
|
||||
0.1.23
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -465,6 +466,41 @@ func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, im
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TrickplayBytes reads part of a title's preview-thumbnail file (BIF).
|
||||
//
|
||||
// It is always a ranged read, because the whole file is megabytes and nothing here ever
|
||||
// wants all of it: callers take the index off the front, then one frame at a time. Emby
|
||||
// answers ranges on this route correctly but does not say so — the response advertises
|
||||
// "Accept-Ranges: none" and a Content-Length taken from the media file — so the request is
|
||||
// made on the strength of the 206 rather than on what the headers promise, and a server
|
||||
// that ignored the range would simply return the head of the file, which is the index a
|
||||
// caller wanted anyway.
|
||||
func (c *Client) TrickplayBytes(
|
||||
ctx context.Context, cred Credentials, itemID string, width int, from, to int64,
|
||||
) ([]byte, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Width", strconv.Itoa(width))
|
||||
path := "/Videos/" + url.PathEscape(itemID) + "/index.bif"
|
||||
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", from, to))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
// Cap the read at what was asked for. A 200 means the range was ignored and the whole
|
||||
// file is on its way, which must not become a multi-megabyte read on a seek.
|
||||
return io.ReadAll(io.LimitReader(resp.Body, to-from+1))
|
||||
}
|
||||
|
||||
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
|
||||
// address: video never flows through the gateway, only metadata does.
|
||||
func (c *Client) StreamURL(cred Credentials, itemID string) string {
|
||||
|
||||
@@ -101,6 +101,11 @@ type Engine struct {
|
||||
// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
|
||||
// screen rather than a wall of near-duplicates.
|
||||
MaxSimilarRows int
|
||||
// SeedPool is how far back down the watch history those rows may be anchored. It is
|
||||
// deliberately several times MaxSimilarRows: the rows are drawn from bands of this
|
||||
// window and rotate within them daily, which is what keeps the launcher moving for a
|
||||
// household part-way through one series.
|
||||
SeedPool int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
WeightedConfig WeightedConfig
|
||||
@@ -491,7 +496,8 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
source: source,
|
||||
log: log,
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
MaxSimilarRows: 3,
|
||||
SeedPool: 12,
|
||||
RowSize: 20,
|
||||
WeightedConfig: DefaultWeightedConfig(),
|
||||
CuratedRows: []CuratedRow{
|
||||
@@ -630,10 +636,11 @@ func (e *Engine) BuildRowsForUser(
|
||||
}
|
||||
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
|
||||
|
||||
variation := dailySeed(cred.UserID)
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
for _, seed := range e.seedsFor(profile) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed)
|
||||
for _, seed := range e.seedsFor(profile, variation) {
|
||||
row, ok := e.similarRow(ctx, cred, profile, seed, variation)
|
||||
if ok {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
@@ -643,7 +650,7 @@ func (e *Engine) BuildRowsForUser(
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, variation)...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -942,16 +949,72 @@ func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item
|
||||
return Decode(raws), true
|
||||
}
|
||||
|
||||
func (e *Engine) seedsFor(profile Profile) []Seed {
|
||||
if len(profile.Seeds) <= e.MaxSimilarRows {
|
||||
return profile.Seeds
|
||||
func (e *Engine) seedsFor(profile Profile, variation string) []Seed {
|
||||
return selectSeeds(profile.Seeds, e.SeedPool, e.MaxSimilarRows, variation)
|
||||
}
|
||||
|
||||
// selectSeeds chooses which watched titles anchor "Because you watched …" rows.
|
||||
//
|
||||
// Taking the freshest few is the obvious rule and is also why the launcher went stale: the
|
||||
// head of history is a resumable title and the series somebody is part-way through, and
|
||||
// neither moves for weeks — so the same two rows came back day after day. Instead the
|
||||
// recent window is cut into equal bands and one seed is drawn from each by a variation
|
||||
// that changes daily. Three properties are the point and are unit-tested:
|
||||
//
|
||||
// - The bands are in recency order, so the first row is still anchored to something
|
||||
// watched lately and the rows under it reach further back. It is a rotation within
|
||||
// bands, never a shuffle of the whole window.
|
||||
// - The same day always yields the same seeds. Rows are rebuilt on every cache miss and
|
||||
// a set of rows that re-picked each time would change under somebody browsing.
|
||||
// - Nothing new has to be watched for the launcher to move on.
|
||||
func selectSeeds(seeds []Seed, pool, max int, variation string) []Seed {
|
||||
if max <= 0 || len(seeds) == 0 {
|
||||
return nil
|
||||
}
|
||||
return profile.Seeds[:e.MaxSimilarRows]
|
||||
if pool < max {
|
||||
pool = max
|
||||
}
|
||||
if pool > len(seeds) {
|
||||
pool = len(seeds)
|
||||
}
|
||||
if pool <= max {
|
||||
return append([]Seed(nil), seeds[:pool]...)
|
||||
}
|
||||
|
||||
band := pool / max
|
||||
out := make([]Seed, 0, max)
|
||||
for index := 0; index < max; index++ {
|
||||
start := index * band
|
||||
end := start + band
|
||||
// The last band takes the remainder, so a pool that does not divide evenly is
|
||||
// still drawn from in full rather than having its oldest entries stranded.
|
||||
if index == max-1 {
|
||||
end = pool
|
||||
}
|
||||
best := start
|
||||
for candidate := start + 1; candidate < end; candidate++ {
|
||||
if stableVariation(variation, seeds[candidate].ID) <
|
||||
stableVariation(variation, seeds[best].ID) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
out = append(out, seeds[best])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// similarRow asks Emby what resembles a title the user just watched. Emby's own
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen.
|
||||
func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) {
|
||||
// similarity scoring beats anything computed here, so this only filters out the seen and
|
||||
// varies the order within relevance bands, the way curated shelves do — a row anchored to
|
||||
// the same seed on two consecutive days must not present the same posters in the same
|
||||
// order, or rotating the seeds is the only thing that ever changes.
|
||||
func (e *Engine) similarRow(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
profile Profile,
|
||||
seed Seed,
|
||||
variation string,
|
||||
) (Row, bool) {
|
||||
result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(e.RowSize * 2)},
|
||||
@@ -970,6 +1033,7 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
|
||||
if len(items) < e.MinRowItems {
|
||||
return Row{}, false
|
||||
}
|
||||
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
|
||||
return Row{
|
||||
ID: "similar:" + seed.ID,
|
||||
Title: "Because you watched " + seed.Name,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -875,3 +876,93 @@ func rowTitles(rows []Row) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testSeeds(count int) []Seed {
|
||||
seeds := make([]Seed, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
id := "s" + strconv.Itoa(index)
|
||||
seeds = append(seeds, Seed{ID: id, Name: "Title " + id})
|
||||
}
|
||||
return seeds
|
||||
}
|
||||
|
||||
func seedIDs(seeds []Seed) []string {
|
||||
out := make([]string, 0, len(seeds))
|
||||
for _, seed := range seeds {
|
||||
out = append(out, seed.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The head of a household's history barely moves while they work through one series, so
|
||||
// the seeds must move without it.
|
||||
func TestSelectSeedsRotatesWithoutNewHistory(t *testing.T) {
|
||||
seeds := testSeeds(12)
|
||||
days := map[string]bool{}
|
||||
for day := 1; day <= 14; day++ {
|
||||
chosen := selectSeeds(seeds, 12, 3, "u1:2026-08-"+strconv.Itoa(day))
|
||||
if len(chosen) != 3 {
|
||||
t.Fatalf("day %d chose %d seeds", day, len(chosen))
|
||||
}
|
||||
days[strings.Join(seedIDs(chosen), ",")] = true
|
||||
}
|
||||
if len(days) < 4 {
|
||||
t.Fatalf("a fortnight produced only %d distinct seed sets: %v", len(days), days)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation is within recency bands, never a shuffle of the whole window: the first row is
|
||||
// still anchored to something watched lately.
|
||||
func TestSelectSeedsKeepsRecencyBands(t *testing.T) {
|
||||
seeds := testSeeds(12)
|
||||
for day := 1; day <= 30; day++ {
|
||||
chosen := selectSeeds(seeds, 12, 3, "u1:day-"+strconv.Itoa(day))
|
||||
for index, seed := range chosen {
|
||||
position, err := strconv.Atoi(strings.TrimPrefix(seed.ID, "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected seed id %q", seed.ID)
|
||||
}
|
||||
if position < index*4 || position >= (index+1)*4 {
|
||||
t.Fatalf("row %d drew %s from outside its band", index, seed.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rows are rebuilt on every cache miss, so the same day must always yield the same seeds.
|
||||
func TestSelectSeedsIsStableWithinADay(t *testing.T) {
|
||||
seeds := testSeeds(20)
|
||||
first := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07"))
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
if got := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")); !slices.Equal(got, first) {
|
||||
t.Fatalf("same day produced %v then %v", first, got)
|
||||
}
|
||||
}
|
||||
other := seedIDs(selectSeeds(seeds, 12, 3, "u2:2026-08-07"))
|
||||
if slices.Equal(other, first) {
|
||||
t.Log("two users may coincide; only a smoke check")
|
||||
}
|
||||
}
|
||||
|
||||
// A short history has nothing to rotate: take what there is, newest first.
|
||||
func TestSelectSeedsFallsBackToRecencyWhenShort(t *testing.T) {
|
||||
if got := seedIDs(selectSeeds(testSeeds(2), 12, 3, "u1:day")); !slices.Equal(got, []string{"s0", "s1"}) {
|
||||
t.Fatalf("short history = %v", got)
|
||||
}
|
||||
if got := selectSeeds(nil, 12, 3, "u1:day"); len(got) != 0 {
|
||||
t.Fatalf("no history should seed nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A pool that does not divide evenly must not strand its oldest entries.
|
||||
func TestSelectSeedsLastBandTakesTheRemainder(t *testing.T) {
|
||||
seeds := testSeeds(11)
|
||||
reached := map[string]bool{}
|
||||
for day := 1; day <= 40; day++ {
|
||||
chosen := selectSeeds(seeds, 11, 3, "u1:day-"+strconv.Itoa(day))
|
||||
reached[chosen[2].ID] = true
|
||||
}
|
||||
if !reached["s10"] {
|
||||
t.Fatalf("the oldest seed was never reachable: %v", reached)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user