package recommend import ( "sort" "strconv" "strings" "time" ) const ReasonLimit = 3 const reasonFloor = 0.35 const decadeReasonFloor = 1.5 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. func Why(profile Profile, item Item, limit int) []string { if limit <= 0 { limit = ReasonLimit } reasons := make([]string, 0, limit) for _, candidate := range personalReasons(profile, item) { if len(reasons) == limit { return reasons } reasons = append(reasons, candidate.Text) } if len(reasons) < limit && item.CommunityRating >= 7.5 { reasons = append(reasons, "Well rated ("+ strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")") } if len(reasons) < limit && item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 { reasons = append(reasons, "A recent release") } if len(reasons) == 0 { reasons = append(reasons, "Recommended from your library") } return reasons } 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 } 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 > bestWeight || weight == bestWeight && weight > 0 && candidate < best { 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 }