This commit is contained in:
ponzischeme89
2026-08-26 21:31:05 +12:00
parent a5f9a91832
commit 3d42c98947
33 changed files with 1172 additions and 69 deletions
+21 -2
View File
@@ -20,6 +20,11 @@ const maxAnalyticsBatch = 200
// nothing about what anyone was looking at.
const maxDwellMs = 30 * 60 * 1000
// maxPositionMs clamps a pause/resume step's reported position. A title that long does
// not exist, so a wilder value says the field was misread rather than that playback was
// genuinely there.
const maxPositionMs = 24 * 60 * 60 * 1000
type rowEventPayload struct {
RowID string `json:"rowId"`
RowKind string `json:"rowKind"`
@@ -52,6 +57,9 @@ type journeyEventPayload struct {
PlaySessionID string `json:"playSessionId"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
// PositionMs is where playback was, in the title, on a pause or resume step. Absent
// (zero) on every other kind of step.
PositionMs int64 `json:"positionMs"`
}
type journeyAnalyticsRequest struct {
@@ -63,7 +71,7 @@ var journeyActions = allowedAnalyticsValues(
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
"submit", "request", "start", "stop", "complete", "abandon", "change",
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
"mark_unplayed", "retry", "dismiss", "switch",
"mark_unplayed", "retry", "dismiss", "switch", "pause", "resume",
)
var journeyOutcomes = allowedAnalyticsValues("", "success", "failure", "cancelled", "completed", "abandoned")
@@ -122,7 +130,18 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemID: payload.ItemID,
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome,
PositionMs: clampPositionMs(payload.PositionMs)}, true
}
func clampPositionMs(value int64) int64 {
if value < 0 {
return 0
}
if value > maxPositionMs {
return maxPositionMs
}
return value
}
func safeAnalyticsValue(value string, max int) bool {
+42
View File
@@ -90,6 +90,48 @@ func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) {
}
}
// Pause and resume are playback steps like start and complete, and carry where playback
// was rather than an outcome — the console derives the pause's length from the gap
// between a pause row's timestamp and its matching resume's.
func TestJourneyEventAcceptsPauseAndResume(t *testing.T) {
now := time.Now().UTC()
for _, action := range []string{"pause", "resume"} {
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: action, Screen: "player", Feature: "playback", Source: "continue_watching",
Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie",
PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", PositionMs: 925000}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok {
t.Fatalf("action %q was rejected", action)
}
if event.PositionMs != 925000 {
t.Fatalf("action %q: position was not retained: %+v", action, event)
}
}
}
// A wild position is clamped rather than dropped: the step it describes is worth more
// than one field on it, the same trade dwell time makes on the row events.
func TestJourneyEventClampsAWildPosition(t *testing.T) {
now := time.Now().UTC()
base := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: "pause", Screen: "player", Feature: "playback", ItemID: "42"}
negative := base
negative.PositionMs = -500
event, ok := toJourneyEvent(negative, "u1", now)
if !ok || event.PositionMs != 0 {
t.Fatalf("negative position was not clamped to zero: %+v", event)
}
wild := base
wild.PositionMs = maxPositionMs + 1
event, ok = toJourneyEvent(wild, "u1", now)
if !ok || event.PositionMs != maxPositionMs {
t.Fatalf("oversized position was not clamped: %+v", event)
}
}
func TestJourneyEventRetainsTheItemName(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
+8
View File
@@ -23,6 +23,7 @@ const (
featureTrickplay = "trickplay"
featureSkipIntro = "skip_intro"
featureEndCredits = "end_credits"
featureNextEpisodeAiring = "next_episode_airing"
featureSeasonalThemes = "seasonal_themes"
featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser"
@@ -251,6 +252,13 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
},
{
Key: featureNextEpisodeAiring, Name: "Next episode airing notice", Area: "Playback",
Description: "Show a quiet notice ahead of the Next Up banner naming when a " +
"continuing show's next episode airs, from Sonarr's schedule.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "next_episode_airing_v1",
Recovery: "Takes effect the next time playback starts; the notice simply stops appearing.",
},
{
// The only switch there is for seasonal themes, and it is deliberately the
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
+54 -9
View File
@@ -62,6 +62,15 @@ type playbackResponse struct {
// separate features with separate switches, and a house that has turned the skip button
// off has not asked to lose the credits pane with it.
EndCreditsAvailable bool `json:"endCreditsAvailable"`
// Whether Sonarr can name when this continuing show's next episode airs. Rides here for
// the same reason the other availability flags do: the player asks once at the start of
// playback and the notice itself takes no further request. Absent (all fields blank)
// means either the show isn't Sonarr-tracked, isn't continuing, or has no scheduled
// airing to report — the client shows nothing in every one of those cases.
NextAiringAvailable bool `json:"nextAiringAvailable"`
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
}
type playableSubtitle struct {
@@ -114,6 +123,10 @@ type nextEpisodeResponse struct {
TrickplayAvailable bool `json:"trickplayAvailable"`
SkipIntroAvailable bool `json:"skipIntroAvailable"`
EndCreditsAvailable bool `json:"endCreditsAvailable"`
NextAiringAvailable bool `json:"nextAiringAvailable"`
NextAiringLabel string `json:"nextAiringLabel,omitempty"`
NextAiringDayLabel string `json:"nextAiringDayLabel,omitempty"`
NextAiringEpisodeCode string `json:"nextAiringEpisodeCode,omitempty"`
}
// handlePlayback resolves what to actually play.
@@ -235,6 +248,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
)
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
s.nextAiringFieldsFor(ctx, target)
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
@@ -257,6 +272,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
})
}
@@ -336,6 +355,22 @@ func (s *Server) firstPlayableEpisode(
return &summary, nil
}
// nextAiringFieldsFor answers the current episode's own show's next airing, never the
// episode being played itself. It only ever asks Sonarr about a series that is actually
// known — a hinted playback response that skipped the Emby lookup and so carries no
// SeriesName simply gets nothing, the same silent degradation every other availability
// flag here takes.
func (s *Server) nextAiringFieldsFor(ctx context.Context, item emby.Summary) (available bool, label, dayLabel, code string) {
if !strings.EqualFold(item.Type, "Episode") {
return false, "", "", ""
}
info, ok := s.nextAiringInfoFor(ctx, item.SeriesName)
if !ok {
return false, "", "", ""
}
return true, info.Label, info.DayLabel, info.EpisodeCode
}
func episodeCode(item emby.Summary) string {
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
return ""
@@ -435,20 +470,26 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
// The metadata-only shape. Everything below this point is a PlaybackInfo negotiation,
// and a client that said it does not want one yet must not be given one anyway.
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
s.nextAiringFieldsFor(ctx, next)
if !nextEpisodeWantsStream(r) {
s.playbackTitles.remember(next.ID, title)
s.loggerFor(ctx).Debug("next episode identified",
"title", title, "item", next.ID, "after_item", itemID)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
StreamReady: false,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: []playableSubtitle{},
SubtitlesEnabled: true,
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
Item: raw,
Title: title,
StreamReady: false,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: []playableSubtitle{},
SubtitlesEnabled: true,
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
})
return
}
@@ -491,6 +532,10 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
EndCreditsAvailable: s.endCreditsEnabled(ctx),
NextAiringAvailable: nextAiringAvailable,
NextAiringLabel: nextAiringLabel,
NextAiringDayLabel: nextAiringDayLabel,
NextAiringEpisodeCode: nextAiringCode,
})
}
+147
View File
@@ -582,6 +582,153 @@ func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
}
}
// nextAiringInfo is what the in-player notice needs about a continuing show's next
// episode: the household's own wording for when it airs, and the episode code when it can
// be resolved cheaply.
type nextAiringInfo struct {
Label string
DayLabel string
EpisodeCode string
}
// nextEpisodeAiringEnabled gates the whole feature: Sonarr must be configured and the
// operator must not have switched it off.
func (s *Server) nextEpisodeAiringEnabled(ctx context.Context) bool {
return s.sonarrEnabled(ctx) && s.featureEnabled(ctx, featureNextEpisodeAiring)
}
// nextAiringInfoFor resolves a currently-playing episode's series against Sonarr's own
// catalogue (already cached by sonarrSeriesCatalogue) and answers only when the show is
// still being made and Sonarr has a scheduled airing in the future. Matched by title alone
// — a playing episode's summary carries no series year, and title collisions are rare
// enough that My Shows accepts the same trade when a follow was saved with no year either.
func (s *Server) nextAiringInfoFor(ctx context.Context, seriesName string) (nextAiringInfo, bool) {
if !s.nextEpisodeAiringEnabled(ctx) {
return nextAiringInfo{}, false
}
catalogue, err := s.sonarrSeriesCatalogue(ctx)
if err != nil {
s.loggerFor(ctx).Debug("next airing lookup unavailable", "error", err)
return nextAiringInfo{}, false
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
matched, info, ok := resolveNextAiring(catalogue, seriesName, now, location)
if !ok {
return nextAiringInfo{}, false
}
info.EpisodeCode = s.nextAiringEpisodeCode(ctx, matched.ID, *matched.NextAiring)
return info, true
}
// resolveNextAiring is the pure half of nextAiringInfoFor: given Sonarr's catalogue and the
// series being played, decide whether there is anything to say and, when there is, the
// matched series so the caller can look up its episode code. Kept apart from the Server
// method so the matching, lifecycle and future-airing rules can be pinned without a live
// cache or Sonarr client.
func resolveNextAiring(
catalogue []sonarr.Series, seriesName string, now time.Time, location *time.Location,
) (*sonarr.Series, nextAiringInfo, bool) {
seriesName = strings.TrimSpace(seriesName)
if seriesName == "" {
return nil, nextAiringInfo{}, false
}
key := normalizedShowTitle(seriesName)
var matched *sonarr.Series
for i := range catalogue {
if normalizedShowTitle(catalogue[i].Title) != key {
continue
}
matched = &catalogue[i]
break
}
if matched == nil {
return nil, nextAiringInfo{}, false
}
if seriesLifecycleTag(matched.Status).Status != "continuing" {
return nil, nextAiringInfo{}, false
}
if matched.NextAiring == nil || !matched.NextAiring.After(now) {
return nil, nextAiringInfo{}, false
}
airTime := matched.NextAiring.In(location)
return matched, nextAiringInfo{
Label: scheduleAirLabel(airTime, now, location),
DayLabel: scheduleAirDayLabel(airTime, now, location),
}, true
}
// nextAiringEpisodeCode is a best-effort lookup against the same 5-day calendar window the
// schedule row already fetches and caches. A next airing beyond that window, or one Sonarr
// has not yet attached to a specific episode, omits the code rather than guessing at it.
func (s *Server) nextAiringEpisodeCode(ctx context.Context, seriesID int, nextAiring time.Time) string {
episodes, err := s.sonarrUpcomingEpisodes(ctx)
if err != nil {
return ""
}
for _, episode := range episodes {
candidateSeriesID := episode.SeriesID
if episode.Series.ID > 0 {
candidateSeriesID = episode.Series.ID
}
if candidateSeriesID != seriesID || episode.AirDateUTC == nil {
continue
}
if episode.AirDateUTC.Equal(nextAiring) {
return fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber)
}
}
return ""
}
// sonarrUpcomingEpisodes shares the schedule row's own cached calendar window, so this
// feature costs no extra request to Sonarr beyond what the launcher's schedule row already
// pays for once a day.
func (s *Server) sonarrUpcomingEpisodes(ctx context.Context) ([]sonarr.Episode, error) {
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := localDayStart(now, location)
key := sonarrCalendarCachePrefix + "episodes:" + dayStart.Format("2006-01-02")
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
return episodes, nil
}
s.sonarrMu.Lock()
defer s.sonarrMu.Unlock()
if episodes := s.cachedSonarrEpisodes(ctx, key); episodes != nil {
return episodes, nil
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, sonarrScheduleDays))
if err != nil {
return nil, err
}
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("sonarr upcoming episodes cache write failed", "error", cacheErr)
}
}
return episodes, nil
}
func (s *Server) cachedSonarrEpisodes(ctx context.Context, key string) []sonarr.Episode {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil
}
var episodes []sonarr.Episode
if json.Unmarshal(raw, &episodes) != nil {
return nil
}
return episodes
}
func localDayStart(value time.Time, location *time.Location) time.Time {
value = value.In(location)
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
+54
View File
@@ -250,3 +250,57 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
}
}
}
func TestResolveNextAiringOnlyAnswersForContinuingShowsWithAFutureAiring(t *testing.T) {
location := time.UTC
now := time.Date(2026, 8, 26, 12, 0, 0, 0, location)
tomorrow := now.AddDate(0, 0, 1)
yesterday := now.AddDate(0, 0, -1)
catalogue := []sonarr.Series{
{Title: "Continuing Show", Status: "continuing", NextAiring: &tomorrow},
{Title: "Ended Show", Status: "ended", NextAiring: &tomorrow},
{Title: "Cancelled Show", Status: "cancelled", NextAiring: &tomorrow},
{Title: "No Schedule Show", Status: "continuing", NextAiring: nil},
{Title: "Already Aired Show", Status: "continuing", NextAiring: &yesterday},
}
if _, info, ok := resolveNextAiring(catalogue, "Continuing Show", now, location); !ok {
t.Fatal("expected a continuing show with a future airing to resolve")
} else if info.DayLabel != "Tomorrow" {
t.Fatalf("day label = %q, want Tomorrow", info.DayLabel)
}
cases := []string{
"Ended Show", "Cancelled Show", "No Schedule Show", "Already Aired Show", "Unknown Show",
}
for _, name := range cases {
if _, _, ok := resolveNextAiring(catalogue, name, now, location); ok {
t.Fatalf("%q should not have produced a next-airing answer", name)
}
}
if _, _, ok := resolveNextAiring(catalogue, " ", now, location); ok {
t.Fatal("a blank series name should never resolve")
}
}
func TestResolveNextAiringWordingMatchesTheScheduleRow(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := now.Add(8 * time.Hour)
catalogue := []sonarr.Series{
{Title: "Northbound", Status: "continuing", NextAiring: &air},
}
_, info, ok := resolveNextAiring(catalogue, "Northbound", now, location)
if !ok {
t.Fatal("expected a resolved answer")
}
wantLabel := scheduleAirLabel(air, now, location)
wantDayLabel := scheduleAirDayLabel(air, now, location)
if info.Label != wantLabel || info.DayLabel != wantDayLabel {
t.Fatalf("wording drifted from the schedule row: got %+v, want label=%q day=%q",
info, wantLabel, wantDayLabel)
}
}