0.3.01
This commit is contained in:
@@ -49,7 +49,8 @@ type homeResponse struct {
|
||||
// Rows is the home screen as the server wants it drawn: order, titles and kinds all
|
||||
// decided here, so a new row (a recommendation strip, a seasonal collection) ships
|
||||
// without touching the TV app. The client renders whatever arrives.
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
Rows []recommend.Row `json:"rows"`
|
||||
RowRelevance []homeRowRelevance `json:"rowRelevance,omitempty"`
|
||||
|
||||
// The fixed rows are also sent flat. They are what the client caches for an
|
||||
// instant cold start, and what the direct-to-Emby path still produces.
|
||||
@@ -67,6 +68,12 @@ type homeResponse struct {
|
||||
// answer to another running a different build. The client asks /v1/update instead.
|
||||
}
|
||||
|
||||
type homeRowRelevance struct {
|
||||
RowID string `json:"rowId"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// handleHome answers the entire launcher in one round trip.
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
@@ -369,7 +376,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
out.Rows = append(rows, recommendations...)
|
||||
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
|
||||
if rowStatsOK {
|
||||
out.Rows = personalizeHomeRows(out.Rows, rowStats)
|
||||
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, rowStats, now)
|
||||
} else {
|
||||
out.Rows, out.RowRelevance = rankHomeRows(out.Rows, nil, now)
|
||||
}
|
||||
assemble()
|
||||
rank := timing.Start(ctx, timing.StageRank)
|
||||
@@ -528,6 +537,75 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
|
||||
return out
|
||||
}
|
||||
|
||||
// rankHomeRows is the server-side contextual row engine. It deliberately keeps
|
||||
// Continue Watching as the household's reliable first landmark, then scores discovery
|
||||
// shelves using engagement, time context and the row's own data source. New row types can
|
||||
// participate without a client release because only the row metadata is interpreted here.
|
||||
func rankHomeRows(rows []recommend.Row, stats []store.RowStat, now time.Time) ([]recommend.Row, []homeRowRelevance) {
|
||||
byID := make(map[string]store.RowStat, len(stats))
|
||||
for _, stat := range stats {
|
||||
byID[stat.RowID] = stat
|
||||
}
|
||||
type scored struct {
|
||||
row recommend.Row
|
||||
score float64
|
||||
reason string
|
||||
position int
|
||||
}
|
||||
ranked := make([]scored, 0, len(rows))
|
||||
for position, row := range rows {
|
||||
score := 1.0
|
||||
reason := ""
|
||||
id := strings.ToLower(row.ID + " " + row.Kind + " " + row.Title)
|
||||
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 3 {
|
||||
engagement := float64(stat.Selects)*6 + float64(stat.Focuses) + float64(stat.DwellMs)/30_000
|
||||
score += (engagement + 2) / (float64(stat.Impressions) + 2)
|
||||
}
|
||||
if strings.Contains(id, "continue") {
|
||||
score += 1000
|
||||
reason = "Continue Watching"
|
||||
}
|
||||
if now.Weekday() == time.Friday && now.Hour() >= 18 && (strings.Contains(id, "movie") || strings.Contains(id, "film")) {
|
||||
score += 8
|
||||
reason = "Friday night films"
|
||||
}
|
||||
if now.Weekday() == time.Sunday && now.Hour() < 18 && (strings.Contains(id, "easy") || strings.Contains(id, "comfort")) {
|
||||
score += 7
|
||||
reason = "Something easy for Sunday"
|
||||
}
|
||||
if now.Hour() >= 20 && strings.Contains(id, "episode") {
|
||||
score += 6
|
||||
reason = "One episode before bed"
|
||||
}
|
||||
if strings.Contains(id, "for-you") || strings.Contains(id, "recommend") {
|
||||
score += 3
|
||||
if reason == "" {
|
||||
reason = "New for you"
|
||||
}
|
||||
}
|
||||
if strings.Contains(id, "latest") || strings.Contains(id, "recent") {
|
||||
score += 2
|
||||
if reason == "" {
|
||||
reason = "Recently added"
|
||||
}
|
||||
}
|
||||
ranked = append(ranked, scored{row: row, score: score, reason: reason, position: position})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].position < ranked[j].position
|
||||
})
|
||||
out := make([]recommend.Row, 0, len(ranked))
|
||||
relevance := make([]homeRowRelevance, 0, len(ranked))
|
||||
for _, item := range ranked {
|
||||
out = append(out, item.row)
|
||||
relevance = append(relevance, homeRowRelevance{RowID: item.row.ID, Score: item.score, Reason: item.reason})
|
||||
}
|
||||
return out, relevance
|
||||
}
|
||||
|
||||
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
|
||||
// time-aware general picks. Other For You shelves remain in the dedicated destination.
|
||||
func preparedHomeForYouRows(
|
||||
|
||||
Reference in New Issue
Block a user