package recommend import ( "sort" "strconv" "strings" "time" ) // Why a title suits one viewer, in that viewer's own words. // // This is deliberately separate from Score. The scorer decides *order* and is allowed to // be opaque; this decides *wording* and must never claim an affinity the profile did not // actually learn — every reason below is read straight out of the weights built from real // history, so a viewer who has watched nothing gets the honest, taste-free ones. // ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three // the strip stops reading as an explanation and starts reading as marketing. const ReasonLimit = 3 // reasonFloor is the weight below which an affinity is a coincidence rather than a // habit — one stray episode should not put a genre on the screen as a reason. const reasonFloor = 0.35 // Why returns up to limit short phrases explaining the item to this viewer, strongest // first. Never nil: a profile with nothing in it still yields the catalogue facts. func Why(profile Profile, item Item, limit int) []string { if limit <= 0 { limit = ReasonLimit } reasons := make([]string, 0, limit) add := func(reason string) { if len(reasons) < limit && reason != "" { reasons = append(reasons, reason) } } if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor { add("Because you watch " + genre) } if name, weight := heaviestPerson(profile, item); weight >= reasonFloor { add("You've watched " + name + " before") } if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor { add("More from " + studio) } // Catalogue facts, used to fill the strip out. They are true for everyone, which is // exactly why they come last: they explain the title, not the viewer. // Kept short deliberately. Three chips share one line on a 960dp TV, and this is the // one that most often lands third, where a long phrase is the one that gets clipped. if item.CommunityRating >= 7.5 { add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")") } if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 { add("A recent release") } if len(reasons) == 0 && len(item.Genres) > 0 { add(strings.TrimSpace(item.Genres[0]) + " from your library") } return reasons } // heaviest picks the wanted key with the most weight behind it, matched case-insensitively // because Emby's own tagging is not consistent about it. Ties break alphabetically so the // same profile and item always produce the same sentence. func heaviest(weights map[string]float64, wanted []string) (string, float64) { best, bestWeight := "", 0.0 for _, candidate := range wanted { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } weight := weightFold(weights, candidate) if weight <= 0 { continue } if weight > bestWeight || (weight == bestWeight && candidate < best) { best, bestWeight = candidate, weight } } return best, bestWeight } func heaviestPerson(profile Profile, item Item) (string, float64) { names := make([]string, 0, len(item.People)) for _, person := range item.People { if isExplainablePerson(person.Type) { names = append(names, person.Name) } } return heaviest(profile.PersonWeights, names) } func round1(value float64) float64 { return float64(int(value*10+0.5)) / 10 } // TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first. // Exported for the admin page, which shows what the engine believes about a household. func (p Profile) TopPeople(n int) []string { type kv struct { name string weight float64 } pairs := make([]kv, 0, len(p.PersonWeights)) for name, weight := range p.PersonWeights { pairs = append(pairs, kv{name, weight}) } sort.Slice(pairs, func(i, j int) bool { if pairs[i].weight != pairs[j].weight { return pairs[i].weight > pairs[j].weight } return pairs[i].name < pairs[j].name }) if n > len(pairs) { n = len(pairs) } out := make([]string, 0, n) for _, pair := range pairs[:n] { out = append(out, pair.name) } return out }