2026-08-02 22:10:19 +12:00
|
|
|
package recommend
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const ReasonLimit = 3
|
|
|
|
|
const reasonFloor = 0.35
|
2026-08-23 09:40:45 +12:00
|
|
|
const decadeReasonFloor = 1.5
|
2026-08-02 22:10:19 +12:00
|
|
|
|
2026-08-23 09:40:45 +12:00
|
|
|
type personalReason struct {
|
|
|
|
|
Text string
|
|
|
|
|
Kind string
|
|
|
|
|
Genre string
|
|
|
|
|
SourceID string
|
|
|
|
|
SourceTitle string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Why returns short, human-readable reasons in evidence-strength order. Ranking stays
|
|
|
|
|
// separate: none of the scorer's technical values or reason codes reach this wording.
|
2026-08-02 22:10:19 +12:00
|
|
|
func Why(profile Profile, item Item, limit int) []string {
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
limit = ReasonLimit
|
|
|
|
|
}
|
|
|
|
|
reasons := make([]string, 0, limit)
|
2026-08-23 09:40:45 +12:00
|
|
|
for _, candidate := range personalReasons(profile, item) {
|
|
|
|
|
if len(reasons) == limit {
|
|
|
|
|
return reasons
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-08-23 09:40:45 +12:00
|
|
|
reasons = append(reasons, candidate.Text)
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-08-23 09:40:45 +12:00
|
|
|
if len(reasons) < limit && item.CommunityRating >= 7.5 {
|
|
|
|
|
reasons = append(reasons, "Well rated ("+
|
|
|
|
|
strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")")
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-08-23 09:40:45 +12:00
|
|
|
if len(reasons) < limit && item.ProductionYear > 0 &&
|
|
|
|
|
time.Now().Year()-item.ProductionYear <= 1 {
|
|
|
|
|
reasons = append(reasons, "A recent release")
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
2026-08-23 09:40:45 +12:00
|
|
|
if len(reasons) == 0 {
|
|
|
|
|
reasons = append(reasons, "Recommended from your library")
|
2026-08-02 22:10:19 +12:00
|
|
|
}
|
|
|
|
|
return reasons
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 09:40:45 +12:00
|
|
|
func strongestPersonalReason(profile Profile, item Item) personalReason {
|
|
|
|
|
if reasons := personalReasons(profile, item); len(reasons) > 0 {
|
|
|
|
|
return reasons[0]
|
|
|
|
|
}
|
|
|
|
|
return personalReason{Text: "Recommended from your library", Kind: "generic"}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// personalReasons is the human-facing priority: related viewing, specific themes,
|
|
|
|
|
// viewing era, cast/creator, studio, then a broad genre as the final personal fallback.
|
|
|
|
|
func personalReasons(profile Profile, item Item) []personalReason {
|
|
|
|
|
reasons := make([]personalReason, 0, 6)
|
|
|
|
|
if reason := relatedViewingReason(profile, item); reason.Text != "" {
|
|
|
|
|
reasons = append(reasons, reason)
|
|
|
|
|
}
|
|
|
|
|
if reason := themeReason(profile, item); reason.Text != "" {
|
|
|
|
|
reasons = append(reasons, reason)
|
|
|
|
|
}
|
|
|
|
|
if reason := decadeReason(profile, item); reason.Text != "" {
|
|
|
|
|
reasons = append(reasons, reason)
|
|
|
|
|
}
|
|
|
|
|
if reason := personReason(profile, item); reason.Text != "" {
|
|
|
|
|
reasons = append(reasons, reason)
|
|
|
|
|
}
|
|
|
|
|
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
|
|
|
|
|
reasons = append(reasons, personalReason{Text: "More from " + studio, Kind: "studio"})
|
|
|
|
|
}
|
|
|
|
|
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
|
|
|
|
|
reasons = append(reasons, personalReason{
|
|
|
|
|
Text: "Because you watch " + genre, Kind: "genre", Genre: genre,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return uniquePersonalReasons(reasons)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func relatedViewingReason(profile Profile, candidate Item) personalReason {
|
|
|
|
|
bestIndex, bestStrength := -1, 0
|
|
|
|
|
for index, evidence := range profile.ReasonEvidence {
|
|
|
|
|
if sameTitle(evidence.Item, candidate) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
strength := titleRelationshipStrength(evidence.Item, candidate)
|
|
|
|
|
if strength > bestStrength {
|
|
|
|
|
bestIndex, bestStrength = index, strength
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bestIndex < 0 {
|
|
|
|
|
return personalReason{}
|
|
|
|
|
}
|
|
|
|
|
evidence := profile.ReasonEvidence[bestIndex]
|
|
|
|
|
title := evidenceTitle(evidence.Item)
|
|
|
|
|
verb, kind := "watched ", "recent-title"
|
|
|
|
|
switch {
|
|
|
|
|
case evidence.Favourite:
|
|
|
|
|
verb, kind = "like ", "favourite-title"
|
|
|
|
|
case evidence.Item.UserData.Played && !strings.EqualFold(evidence.Item.Type, "Episode"):
|
|
|
|
|
verb, kind = "finished ", "completed-title"
|
|
|
|
|
}
|
|
|
|
|
return personalReason{
|
|
|
|
|
Text: "Because you " + verb + title, Kind: kind,
|
|
|
|
|
SourceID: evidenceID(evidence.Item), SourceTitle: title,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func titleRelationshipStrength(source, candidate Item) int {
|
|
|
|
|
if source.CollectionName != "" && candidate.CollectionName != "" &&
|
|
|
|
|
strings.EqualFold(strings.TrimSpace(source.CollectionName), strings.TrimSpace(candidate.CollectionName)) {
|
|
|
|
|
return 140
|
|
|
|
|
}
|
|
|
|
|
shared, specific := sharedGenreCount(source.Genres, candidate.Genres)
|
|
|
|
|
switch {
|
|
|
|
|
case shared >= 2:
|
|
|
|
|
return 100 + specific*10
|
|
|
|
|
case specific >= 1:
|
|
|
|
|
return 80
|
|
|
|
|
default:
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sharedGenreCount(left, right []string) (shared, specific int) {
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
for _, a := range left {
|
|
|
|
|
a = strings.TrimSpace(a)
|
|
|
|
|
if a == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, b := range right {
|
|
|
|
|
if !strings.EqualFold(a, strings.TrimSpace(b)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
key := strings.ToLower(a)
|
|
|
|
|
if !seen[key] {
|
|
|
|
|
seen[key] = true
|
|
|
|
|
shared++
|
|
|
|
|
if !isBroadGenre(a) {
|
|
|
|
|
specific++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return shared, specific
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func themeReason(profile Profile, item Item) personalReason {
|
|
|
|
|
type match struct {
|
|
|
|
|
name string
|
|
|
|
|
weight float64
|
|
|
|
|
}
|
|
|
|
|
matches := make([]match, 0, len(item.Genres))
|
|
|
|
|
for _, genre := range item.Genres {
|
|
|
|
|
genre = strings.TrimSpace(genre)
|
|
|
|
|
if weight := weightFold(profile.GenreWeights, genre); genre != "" && weight >= reasonFloor {
|
|
|
|
|
matches = append(matches, match{genre, weight})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.SliceStable(matches, func(i, j int) bool {
|
|
|
|
|
iBroad, jBroad := isBroadGenre(matches[i].name), isBroadGenre(matches[j].name)
|
|
|
|
|
if iBroad != jBroad {
|
|
|
|
|
return !iBroad
|
|
|
|
|
}
|
|
|
|
|
return matches[i].weight > matches[j].weight
|
|
|
|
|
})
|
|
|
|
|
if len(matches) == 0 || isBroadGenre(matches[0].name) {
|
|
|
|
|
return personalReason{}
|
|
|
|
|
}
|
|
|
|
|
primary := matches[0].name
|
|
|
|
|
phrase := strings.ToLower(primary)
|
|
|
|
|
for _, candidate := range matches[1:] {
|
|
|
|
|
if isBroadGenre(candidate.name) {
|
|
|
|
|
phrase += " " + pluralGenre(candidate.name)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return personalReason{
|
|
|
|
|
Text: "Because you like " + phrase, Kind: "theme", Genre: primary,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decadeReason(profile Profile, item Item) personalReason {
|
|
|
|
|
if item.ProductionYear <= 0 {
|
|
|
|
|
return personalReason{}
|
|
|
|
|
}
|
|
|
|
|
decade := item.ProductionYear / 10 * 10
|
|
|
|
|
if profile.DecadeWeights[decade] < decadeReasonFloor {
|
|
|
|
|
return personalReason{}
|
|
|
|
|
}
|
|
|
|
|
noun := "titles"
|
|
|
|
|
if strings.EqualFold(item.Type, "Series") {
|
|
|
|
|
noun = "series"
|
|
|
|
|
} else if strings.EqualFold(item.Type, "Movie") {
|
|
|
|
|
noun = "films"
|
|
|
|
|
}
|
|
|
|
|
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor && isBroadGenre(genre) {
|
|
|
|
|
noun = pluralGenre(genre)
|
|
|
|
|
}
|
|
|
|
|
return personalReason{
|
|
|
|
|
Text: "Because you've been watching " + strconv.Itoa(decade) + "s " + noun,
|
|
|
|
|
Kind: "decade",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func personReason(profile Profile, item Item) personalReason {
|
|
|
|
|
best := Person{}
|
|
|
|
|
bestWeight := 0.0
|
|
|
|
|
for _, person := range item.People {
|
|
|
|
|
if !isExplainablePerson(person.Type) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
weight := weightFold(profile.PersonWeights, person.Name)
|
|
|
|
|
if weight > bestWeight || weight == bestWeight && person.Name < best.Name {
|
|
|
|
|
best, bestWeight = person, weight
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bestWeight < reasonFloor {
|
|
|
|
|
return personalReason{}
|
|
|
|
|
}
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(best.Type)) {
|
|
|
|
|
case "director", "writer":
|
|
|
|
|
return personalReason{Text: "More from " + best.Name, Kind: "creator"}
|
|
|
|
|
default:
|
|
|
|
|
return personalReason{Text: "Because you watch " + best.Name, Kind: "person"}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func evidenceTitle(item Item) string {
|
|
|
|
|
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
|
|
|
|
|
return strings.TrimSpace(item.SeriesName)
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(item.Name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func evidenceID(item Item) string {
|
|
|
|
|
if item.SeriesID != "" {
|
|
|
|
|
return item.SeriesID
|
|
|
|
|
}
|
|
|
|
|
return item.ID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func sameTitle(left, right Item) bool {
|
|
|
|
|
if evidenceID(left) != "" && evidenceID(left) == evidenceID(right) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
leftKey, rightKey := left.SeenKey(), right.SeenKey()
|
|
|
|
|
return leftKey != "" && leftKey == rightKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isBroadGenre(genre string) bool {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(genre)) {
|
|
|
|
|
case "action", "adventure", "comedy", "drama", "family", "thriller":
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pluralGenre(genre string) string {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(genre)) {
|
|
|
|
|
case "comedy":
|
|
|
|
|
return "comedies"
|
|
|
|
|
case "family":
|
|
|
|
|
return "family titles"
|
|
|
|
|
case "action", "adventure":
|
|
|
|
|
return strings.ToLower(strings.TrimSpace(genre)) + " titles"
|
|
|
|
|
default:
|
|
|
|
|
return strings.ToLower(strings.TrimSpace(genre)) + "s"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func uniquePersonalReasons(reasons []personalReason) []personalReason {
|
|
|
|
|
out := make([]personalReason, 0, len(reasons))
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
for _, reason := range reasons {
|
|
|
|
|
key := strings.ToLower(strings.TrimSpace(reason.Text))
|
|
|
|
|
if key == "" || seen[key] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[key] = true
|
|
|
|
|
out = append(out, reason)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
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)
|
2026-08-23 09:40:45 +12:00
|
|
|
if weight > bestWeight || weight == bestWeight && weight > 0 && candidate < best {
|
2026-08-02 22:10:19 +12:00
|
|
|
best, bestWeight = candidate, weight
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return best, bestWeight
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func round1(value float64) float64 {
|
|
|
|
|
return float64(int(value*10+0.5)) / 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|