125 lines
4.1 KiB
Go
125 lines
4.1 KiB
Go
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
|
||
|
|
}
|