App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+200
-20
@@ -172,6 +172,9 @@ func (s *Server) personalizeTitles(
|
||||
location := s.cfg.SonarrLocation
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
if progressRow(row.ID) {
|
||||
continue
|
||||
}
|
||||
compatibility := map[string]float64{}
|
||||
for _, raw := range row.Items {
|
||||
var marker struct {
|
||||
@@ -208,14 +211,27 @@ func (s *Server) personalizeTitles(
|
||||
for _, value := range ranked {
|
||||
items = append(items, recommend.EnrichRankedItem(value))
|
||||
}
|
||||
// Mandatory progress rows must remain useful even before a profile is prepared.
|
||||
if len(items) > 0 || row.ID != "continue" && row.ID != "next-up" {
|
||||
row.Items = items
|
||||
}
|
||||
row.Items = items
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// progressRow marks the row that answers "what was I watching?" rather than "what might
|
||||
// I like?" — and it is the only row whose order is not ours to decide.
|
||||
//
|
||||
// Continue Watching is Emby's resume list interleaved with Next Up, most-recently-watched
|
||||
// first, which is the whole usefulness of it. Ranking it by taste reordered that: an
|
||||
// episode carries none of the studio, cast or collection fields a film's payload does,
|
||||
// its Type has no affinity evidence behind it, and a 22-minute runtime fits a household
|
||||
// session profile built from features badly — so films sorted to the front and the show
|
||||
// somebody was two episodes into sorted past the visible cards. Finishing an episode then
|
||||
// looked like the series had vanished, because the one place it could be found was ordered
|
||||
// by something other than having just been watched. The diversity caps and the exploration
|
||||
// shuffle compound it for the same reason.
|
||||
func progressRow(id string) bool {
|
||||
return id == "continue"
|
||||
}
|
||||
|
||||
func (s *Server) personalizeSearch(
|
||||
ctx context.Context,
|
||||
sess store.Session,
|
||||
@@ -259,9 +275,8 @@ func (s *Server) weightedConfig() recommend.WeightedConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// deduplicateRows gives the earliest row ownership of a title. Continue Watching and
|
||||
// Next Up keep their landmarks; later discovery shelves fill with their remaining
|
||||
// unique posters.
|
||||
// deduplicateRows gives the earliest row ownership of a title. Continue Watching keeps
|
||||
// its landmarks; later discovery shelves fill with their remaining unique posters.
|
||||
func deduplicateRows(rows []recommend.Row) []recommend.Row {
|
||||
seen := map[string]bool{}
|
||||
for rowIndex := range rows {
|
||||
@@ -317,8 +332,6 @@ func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row {
|
||||
switch row.ID {
|
||||
case "continue":
|
||||
score = 1_000
|
||||
case "next-up":
|
||||
score = 100
|
||||
case "latest-movies":
|
||||
score = 90
|
||||
}
|
||||
@@ -341,7 +354,7 @@ func selectPersonalizedRows(rows []recommend.Row) []recommend.Row {
|
||||
out := make([]recommend.Row, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
switch row.ID {
|
||||
case "continue", "next-up", "latest-movies", "favorites":
|
||||
case "continue", "latest-movies", "favorites":
|
||||
out = append(out, row)
|
||||
continue
|
||||
}
|
||||
@@ -432,7 +445,21 @@ func (s *Server) handleRecommendationPreferences(
|
||||
return
|
||||
}
|
||||
}
|
||||
peopleCount := len(preferences.Actors) + len(preferences.Actresses) + len(preferences.Directors)
|
||||
if peopleCount > 60 {
|
||||
writeError(w, http.StatusBadRequest, "too many onboarding people")
|
||||
return
|
||||
}
|
||||
for _, names := range [][]string{preferences.Actors, preferences.Actresses, preferences.Directors} {
|
||||
for _, name := range names {
|
||||
if strings.TrimSpace(name) == "" || len(name) > 160 {
|
||||
writeError(w, http.StatusBadRequest, "invalid onboarding person")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
preferences.Completed = true
|
||||
preferences.Prompted = true
|
||||
raw, _ := json.Marshal(preferences)
|
||||
if err := s.store.SetRecommendationOnboarding(
|
||||
r.Context(), sess.EmbyUserID, raw,
|
||||
@@ -449,9 +476,21 @@ func (s *Server) handleRecommendationPreferences(
|
||||
}
|
||||
|
||||
type recommendationOnboardingResponse struct {
|
||||
Completed bool `json:"completed"`
|
||||
Ratings map[string]int `json:"ratings"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Completed bool `json:"completed"`
|
||||
Prompted bool `json:"prompted"`
|
||||
Ratings map[string]int `json:"ratings"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Movies []json.RawMessage `json:"movies"`
|
||||
Shows []json.RawMessage `json:"shows"`
|
||||
Actors []recommendationOnboardingPerson `json:"actors"`
|
||||
Actresses []recommendationOnboardingPerson `json:"actresses"`
|
||||
Directors []recommendationOnboardingPerson `json:"directors"`
|
||||
}
|
||||
|
||||
type recommendationOnboardingPerson struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageTag string `json:"imageTag"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRecommendationPreferencesGet(
|
||||
@@ -466,9 +505,16 @@ func (s *Server) handleRecommendationPreferencesGet(
|
||||
if preferences.Ratings == nil {
|
||||
preferences.Ratings = map[string]int{}
|
||||
}
|
||||
if preferences.Completed {
|
||||
// Older TVs only understand completed. Treat an uninvited profile as complete on the
|
||||
// wire so server-side prompt control also suppresses the legacy automatic flow. The
|
||||
// stored value remains false; queueing a prompt changes Prompted and the next request
|
||||
// receives the real incomplete state.
|
||||
if preferences.Completed || !preferences.Prompted {
|
||||
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
|
||||
Completed: true, Ratings: preferences.Ratings, Items: []json.RawMessage{},
|
||||
Completed: true, Prompted: preferences.Prompted, Ratings: preferences.Ratings, Items: []json.RawMessage{},
|
||||
Movies: []json.RawMessage{}, Shows: []json.RawMessage{},
|
||||
Actors: []recommendationOnboardingPerson{}, Actresses: []recommendationOnboardingPerson{},
|
||||
Directors: []recommendationOnboardingPerson{},
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -477,7 +523,7 @@ func (s *Server) handleRecommendationPreferencesGet(
|
||||
writeError(w, http.StatusInternalServerError, "could not load rating choices")
|
||||
return
|
||||
}
|
||||
candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 24)
|
||||
candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 40)
|
||||
row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"}
|
||||
for _, item := range candidates {
|
||||
row.Items = append(row.Items, item.Raw)
|
||||
@@ -488,17 +534,136 @@ func (s *Server) handleRecommendationPreferencesGet(
|
||||
items := []json.RawMessage{}
|
||||
if len(filtered) == 1 {
|
||||
items = filtered[0].Items
|
||||
if len(items) > 16 {
|
||||
items = items[:16]
|
||||
if len(items) > 32 {
|
||||
items = items[:32]
|
||||
}
|
||||
}
|
||||
movies, shows := []json.RawMessage{}, []json.RawMessage{}
|
||||
visible := recommend.Decode(items)
|
||||
for _, item := range visible {
|
||||
if strings.EqualFold(item.Type, "Movie") && len(movies) < 16 {
|
||||
movies = append(movies, item.Raw)
|
||||
}
|
||||
if strings.EqualFold(item.Type, "Series") && len(shows) < 16 {
|
||||
shows = append(shows, item.Raw)
|
||||
}
|
||||
}
|
||||
actors, actresses, directors := recommendationOnboardingPeople(visible, 16)
|
||||
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
|
||||
Completed: preferences.Completed,
|
||||
Prompted: preferences.Prompted,
|
||||
Ratings: preferences.Ratings,
|
||||
Items: items,
|
||||
Movies: movies, Shows: shows,
|
||||
Actors: actors, Actresses: actresses, Directors: directors,
|
||||
})
|
||||
}
|
||||
|
||||
// recommendationOnboardingPeople turns the cast and crew already visible to this user
|
||||
// into recognisable portrait choices. Emby identifies all performers as Actor, so the
|
||||
// actress split uses a deliberately curated, case-insensitive list; unfamiliar names
|
||||
// remain in Actors rather than being guessed from a name.
|
||||
func recommendationOnboardingPeople(items []recommend.Item, limit int) (
|
||||
[]recommendationOnboardingPerson, []recommendationOnboardingPerson, []recommendationOnboardingPerson,
|
||||
) {
|
||||
type candidate struct {
|
||||
person recommendationOnboardingPerson
|
||||
appearances int
|
||||
bestRating float64
|
||||
}
|
||||
groups := [3]map[string]*candidate{{}, {}, {}}
|
||||
for _, item := range items {
|
||||
seen := map[string]bool{}
|
||||
for _, person := range item.People {
|
||||
name := strings.TrimSpace(person.Name)
|
||||
key := strings.ToLower(name)
|
||||
if name == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
group := -1
|
||||
switch strings.ToLower(strings.TrimSpace(person.Type)) {
|
||||
case "director":
|
||||
group = 2
|
||||
case "actor":
|
||||
if onboardingActresses[key] {
|
||||
group = 1
|
||||
} else {
|
||||
group = 0
|
||||
}
|
||||
}
|
||||
if group < 0 {
|
||||
continue
|
||||
}
|
||||
value := groups[group][key]
|
||||
if value == nil {
|
||||
value = &candidate{person: recommendationOnboardingPerson{ID: person.ID, Name: name, ImageTag: person.PrimaryImageTag}}
|
||||
groups[group][key] = value
|
||||
}
|
||||
value.appearances++
|
||||
if item.CommunityRating > value.bestRating {
|
||||
value.bestRating = item.CommunityRating
|
||||
}
|
||||
if value.person.ID == "" && person.ID != "" {
|
||||
value.person.ID, value.person.ImageTag = person.ID, person.PrimaryImageTag
|
||||
}
|
||||
}
|
||||
}
|
||||
output := func(values map[string]*candidate) []recommendationOnboardingPerson {
|
||||
all := make([]*candidate, 0, len(values))
|
||||
for _, value := range values {
|
||||
all = append(all, value)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].appearances != all[j].appearances {
|
||||
return all[i].appearances > all[j].appearances
|
||||
}
|
||||
if all[i].bestRating != all[j].bestRating {
|
||||
return all[i].bestRating > all[j].bestRating
|
||||
}
|
||||
return strings.ToLower(all[i].person.Name) < strings.ToLower(all[j].person.Name)
|
||||
})
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
out := make([]recommendationOnboardingPerson, 0, len(all))
|
||||
for _, value := range all {
|
||||
out = append(out, value.person)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return output(groups[0]), output(groups[1]), output(groups[2])
|
||||
}
|
||||
|
||||
var onboardingActresses = map[string]bool{
|
||||
"amy adams": true, "cate blanchett": true, "viola davis": true, "zendaya": true,
|
||||
"michelle yeoh": true, "lupita nyong'o": true, "florence pugh": true, "saoirse ronan": true,
|
||||
"margot robbie": true, "emma stone": true, "scarlett johansson": true, "natalie portman": true,
|
||||
"jessica chastain": true, "octavia spencer": true, "regina king": true, "taraji p. henson": true,
|
||||
"tilda swinton": true, "frances mcdormand": true, "olivia colman": true, "kate winslet": true,
|
||||
"nicole kidman": true, "toni collette": true, "kirsten dunst": true, "rachel weisz": true,
|
||||
"ana de armas": true, "anya taylor-joy": true, "aunjanue ellis-taylor": true, "danai gurira": true,
|
||||
"gemma chan": true, "greta lee": true, "janelle monáe": true, "kerry washington": true,
|
||||
"ming-na wen": true, "rosamund pike": true, "ruth negga": true, "sandra oh": true,
|
||||
"salma hayek": true, "sonoya mizuno": true, "tessa thompson": true,
|
||||
"thandiwe newton": true, "zoë saldaña": true, "meryl streep": true, "jodie foster": true,
|
||||
"sigourney weaver": true, "angela bassett": true, "gillian anderson": true, "elisabeth moss": true,
|
||||
"jennifer coolidge": true, "quinta brunson": true, "ayo edebiri": true, "bella ramsey": true,
|
||||
"emily blunt": true, "jennifer lawrence": true, "anne hathaway": true, "rachel mcadams": true,
|
||||
"charlize theron": true, "halle berry": true, "brie larson": true, "rebecca ferguson": true,
|
||||
"julia roberts": true, "sandra bullock": true, "reese witherspoon": true, "jennifer aniston": true,
|
||||
"laura dern": true, "julianne moore": true, "glenn close": true, "helen mirren": true,
|
||||
"judi dench": true, "maggie smith": true, "kathy bates": true, "carey mulligan": true,
|
||||
"alicia vikander": true, "noomi rapace": true, "marion cotillard": true, "léa seydoux": true,
|
||||
"penélope cruz": true, "deepika padukone": true, "priyanka chopra jonas": true, "awkwafina": true,
|
||||
"constance wu": true, "zoë kravitz": true, "gwendoline christie": true, "emilia clarke": true,
|
||||
"lena headey": true, "sarah snook": true, "jodie comer": true, "issa rae": true,
|
||||
"uzo aduba": true, "natasha lyonne": true, "catherine o'hara": true, "jean smart": true,
|
||||
"melanie lynskey": true, "lucy lawless": true, "rose mciver": true, "thomasin mckenzie": true,
|
||||
"keisha castle-hughes": true, "rena owen": true, "elizabeth debicki": true, "sarah paulson": true,
|
||||
"jenna ortega": true, "hailee steinfeld": true, "millie bobby brown": true, "kristen stewart": true,
|
||||
}
|
||||
|
||||
// recommendationOnboardingCandidates selects recognisable, well-rated titles while
|
||||
// keeping movies, series and primary genres mixed. It is deterministic so returning to
|
||||
// an unfinished onboarding screen does not reshuffle the choices.
|
||||
@@ -515,6 +680,7 @@ func recommendationOnboardingCandidates(items []recommend.Item, limit int) []rec
|
||||
buckets := map[string][]recommend.Item{"movie": {}, "series": {}}
|
||||
typeCounts := map[string]int{}
|
||||
genreCounts := map[string]int{}
|
||||
eraCounts := map[string]int{}
|
||||
perType := max(1, limit/2)
|
||||
for _, item := range items {
|
||||
kind := strings.ToLower(strings.TrimSpace(item.Type))
|
||||
@@ -526,12 +692,26 @@ func recommendationOnboardingCandidates(items []recommend.Item, limit int) []rec
|
||||
if len(item.Genres) > 0 {
|
||||
genre = strings.ToLower(strings.TrimSpace(item.Genres[0]))
|
||||
}
|
||||
if genre != "" && genreCounts[genre] >= 3 {
|
||||
genreKey := kind + ":" + genre
|
||||
era := "classic"
|
||||
if item.ProductionYear >= 2020 {
|
||||
era = "current"
|
||||
} else if item.ProductionYear >= 2000 {
|
||||
era = "modern"
|
||||
} else if item.ProductionYear >= 1980 {
|
||||
era = "catalogue"
|
||||
}
|
||||
eraKey := kind + ":" + era
|
||||
if genre != "" && genreCounts[genreKey] >= max(2, perType/4) {
|
||||
continue
|
||||
}
|
||||
if eraCounts[eraKey] >= max(3, perType/2) {
|
||||
continue
|
||||
}
|
||||
buckets[kind] = append(buckets[kind], item)
|
||||
typeCounts[kind]++
|
||||
genreCounts[genre]++
|
||||
genreCounts[genreKey]++
|
||||
eraCounts[eraKey]++
|
||||
if typeCounts["movie"]+typeCounts["series"] == limit {
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user