This commit is contained in:
ponzischeme89
2026-08-21 09:54:44 +12:00
parent f1164db2c5
commit 5467fba0eb
39 changed files with 7589 additions and 5537 deletions
+8
View File
@@ -26,6 +26,7 @@ const (
featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser"
featureTVCalendar = "tv_calendar"
featureContinueWatching = "continue_watching"
featureWatchTimeDigest = "watch_time_digest"
featureViewers = "viewers"
)
@@ -135,6 +136,13 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
},
{
Key: featureContinueWatching, Name: "Continue Watching", Area: "Home",
Description: "Show resumable films and episodes on Home. Turning it off hides the " +
"row and stops the gateway loading its resume and Next Up feeds.",
DefaultEnabled: true, MinimumProtocol: 1,
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
},
{
// No capability, because nothing on the television has to understand this: the
// summary is an ordinary entry in My Alerts, which every build that has that page
+14
View File
@@ -61,6 +61,20 @@ func TestGenreBrowserIsServerControlledAndOffByDefault(t *testing.T) {
}
}
func TestContinueWatchingIsServerControlledAndOnByDefault(t *testing.T) {
definition, ok := knownFeature(featureContinueWatching)
if !ok || definition.Area != "Home" {
t.Fatalf("Continue Watching feature definition = %+v, found=%v", definition, ok)
}
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, ProtocolVersion); !got.Enabled {
t.Fatalf("Continue Watching default = %+v, want enabled", got)
}
disabled := store.FeaturePolicy{Overrides: map[string]bool{featureContinueWatching: false}}
if got := evaluateFeature(disabled, definition, ProtocolVersion); got.Enabled || got.Source != "override" {
t.Fatalf("Continue Watching override = %+v", got)
}
}
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
+79 -46
View File
@@ -76,6 +76,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
hero := supportsHomeHero(r)
now := time.Now()
featurePolicy := s.currentFeaturePolicy(ctx)
continueDefinition, _ := knownFeature(featureContinueWatching)
continueWatching := evaluateFeature(
featurePolicy,
continueDefinition,
ProtocolVersion,
).Enabled
// The hero revision is part of the key rather than something to invalidate. An
// operator's change therefore makes the entries built under the old policy simply
// unreachable, and they age out on their own TTL — where the sweep it replaced dropped
@@ -86,6 +93,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
viewerKeyOf(ctx, sess),
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":c"+strconv.FormatBool(continueWatching)+
":f"+strconv.FormatInt(featurePolicy.Revision, 10)+
":hr"+heroRev+":d"+sess.DeviceID,
)
@@ -104,6 +113,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
var (
mu sync.Mutex
failures int
rowAttempts int
out homeResponse
sonarrRow *recommend.Row
radarrRow *recommend.Row
@@ -120,6 +130,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
// on rather than only that it was waiting on Emby. They are concurrent, so a single
// summed `emby=` would be the one number that could not answer that.
run := func(name string, dest *[]json.RawMessage, fetch func(context.Context) (*emby.ItemsResult, error)) {
rowAttempts++
wg.Add(1)
go func() {
defer wg.Done()
@@ -144,16 +155,18 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
viewer := viewerOf(ctx, sess)
shadow := !viewer.IsMain() && s.store != nil
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
}
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
if continueWatching {
run("resume", &out.ContinueWatching, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerContinueRow(ctx, cred, viewer.ID, limit)
}
return s.emby.ResumeItems(ctx, cred, rowParams(url.Values{
"Recursive": {"true"},
"MediaTypes": {"Video"},
"Limit": {itoa(limit)},
}, fieldsContinue))
})
}
run("favourites", &out.Favorites, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerFavouritesRow(ctx, cred, viewer.ID, limit)
@@ -167,38 +180,42 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsRow))
})
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
}
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
})
if continueWatching {
run("nextup", &out.NextUp, func(ctx context.Context) (*emby.ItemsResult, error) {
if shadow {
return s.viewerNextUpRow(ctx, cred, viewer.ID, limit)
}
return s.emby.NextUp(ctx, cred, rowParams(url.Values{
"Limit": {itoa(limit)},
}, fieldsContinue))
})
}
// What orders the Next Up half of Continue Watching. A failure here is not a failed
// row: the merge falls back to putting the resume items first, which is the order
// the launcher had before the two rows became one.
wg.Add(1)
go func() {
defer wg.Done()
// What orders the two halves of the merge. For a shadow viewer it is one grouped
// query over their own state rather than a lookback over the account's plays —
// the same question, asked of the system that holds the answer.
var played map[string]time.Time
var err error
if shadow {
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
} else {
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
}
if err != nil {
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
return
}
mu.Lock()
seriesPlayed = played
mu.Unlock()
}()
if continueWatching {
wg.Add(1)
go func() {
defer wg.Done()
// What orders the two halves of the merge. For a shadow viewer it is one grouped
// query over their own state rather than a lookback over the account's plays —
// the same question, asked of the system that holds the answer.
var played map[string]time.Time
var err error
if shadow {
played, err = s.store.ViewerWatchedSeries(ctx, viewer.ID, continuePlayLookback)
} else {
played, err = s.recentlyPlayedSeries(timing.WithLabel(ctx, "emby.recent"), cred)
}
if err != nil {
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
return
}
mu.Lock()
seriesPlayed = played
mu.Unlock()
}()
}
run("latest", &out.LatestMovies, func(ctx context.Context) (*emby.ItemsResult, error) {
newReleaseDays := s.weightedConfig().NewReleaseDays
if newReleaseDays < 1 {
@@ -298,7 +315,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
wg.Wait()
upstream()
if failures == 4 {
if rowAttempts > 0 && failures == rowAttempts {
writeError(w, http.StatusBadGateway, "could not reach the emby server")
return
}
@@ -307,11 +324,15 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
// One row, not two: an episode finished a minute ago should be followed by the next
// one at the front of Continue Watching rather than moving the show to a different
// row. NextUp stays on the wire for televisions that predate the merge.
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
if continueWatching {
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
}
// A current show with an episode today is more immediately useful than a long-lived
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
// promotion, not a replacement ranking for Continue Watching.
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
if continueWatching {
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
}
// Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home
@@ -321,7 +342,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
s.refreshRecommendationsInBackground(sess)
}
assemble := timing.Start(ctx, timing.StageRows)
rows := baseRows(out)
rows := visibleBaseRows(out, continueWatching)
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
if len(forYouRows) > 0 {
nearContinue = append(nearContinue, forYouRows...)
@@ -337,9 +358,13 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
nearContinue = append(nearContinue, *radarrRow)
}
if len(nearContinue) > 0 {
// Personalised discovery and the upcoming schedule are most useful immediately
// after Continue Watching, before the broader library collections.
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
// Personalised discovery and the upcoming schedule are most useful at the start of
// Home: after Continue Watching when it is present, before broader collections.
insertAt := 0
if continueWatching {
insertAt = 1
}
rows = append(rows[:insertAt], append(nearContinue, rows[insertAt:]...)...)
}
out.Rows = append(rows, recommendations...)
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
@@ -733,6 +758,14 @@ func baseRows(h homeResponse) []recommend.Row {
}
}
func visibleBaseRows(h homeResponse, continueWatching bool) []recommend.Row {
rows := baseRows(h)
if continueWatching {
return rows
}
return rows[1:]
}
type homeForYouWindow struct {
ID string
Title string
+12
View File
@@ -33,6 +33,18 @@ func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) {
}
}
func TestDisabledContinueWatchingIsNotRendered(t *testing.T) {
rows := visibleBaseRows(homeResponse{}, false)
for _, row := range rows {
if row.Kind == "continue" {
t.Fatal("disabled Continue Watching row was rendered")
}
}
if len(rows) != 2 {
t.Fatalf("base row count = %d, want favourites and latest only", len(rows))
}
}
func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) {
raw := func(value string) json.RawMessage { return json.RawMessage(value) }
items := []json.RawMessage{