App v0.2.27 and gateway 0.1.23

Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
+3
View File
@@ -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))
+16
View File
@@ -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 " +
+836
View File
@@ -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
}
+412
View File
@@ -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
}
+28 -2
View File
@@ -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) {
+204
View File
@@ -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
}
+191
View File
@@ -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)
}
}
+3 -2
View File
@@ -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:] {
+2
View File
@@ -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",
+12
View File
@@ -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),
})
}
+13
View File
@@ -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.",
+236
View File
@@ -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
}
+78
View File
@@ -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)
}
}
}