This commit is contained in:
ponzischeme89
2026-08-23 09:40:45 +12:00
parent 9a1f44da46
commit 766cea2199
19 changed files with 828 additions and 164 deletions
+9 -2
View File
@@ -33,7 +33,7 @@ const (
maxPreparedCandidates = 750
// Increment only when stored eligibility, scoring, or explanation behavior changes.
preparedAlgorithmVersion = "2026-07-31.2"
preparedAlgorithmVersion = "2026-08-23.1"
)
type ImportResult struct {
@@ -676,7 +676,14 @@ func buildPreparedRows(
"for-you:because:",
func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
func(item store.PreparedForYouItem) string {
return "Because you finished " + item.ReasonSourceTitle
switch item.ReasonKind {
case "completed-title":
return "Because you finished " + item.ReasonSourceTitle
case "favourite-title":
return "Because you like " + item.ReasonSourceTitle
default:
return "Because you watched " + item.ReasonSourceTitle
}
},
2,
func(item store.PreparedForYouItem) bool {
+1
View File
@@ -214,6 +214,7 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T)
CompatibilityScore: 0.8,
CompatibilityLabel: "Direct plays well on this TV",
RecommendationReason: "Because you finished " + sourceTitle,
ReasonKind: "completed-title",
ReasonGenre: genre, ReasonSourceItemID: sourceID,
ReasonSourceTitle: sourceTitle,
})
+21 -22
View File
@@ -444,28 +444,11 @@ func explainRecommendation(
compatibility compatibilityProfile,
browsed bool,
) (string, string) {
top := profile.TopGenres(5)
matched := ""
for _, wanted := range top {
for _, genre := range item.Genres {
if strings.EqualFold(wanted, genre) {
matched = genre
break
}
}
if matched != "" {
break
}
}
reasons := make([]string, 0, 3)
if browsed {
reasons = append(reasons, "You explored this recently")
} else if matched != "" {
reasons = append(reasons, "Matches your "+matched+" viewing")
} else if len(profile.Seeds) > 0 {
reasons = append(reasons, "Inspired by "+profile.Seeds[0].Name)
} else {
reasons = append(reasons, "Matches your recent viewing")
reasons = append(reasons, strongestPersonalReason(profile, item).Text)
}
if availableMinutes > 0 && item.RuntimeMinutes() > 0 {
reasons = append(reasons, "fits your "+strconv.Itoa(availableMinutes)+"-minute window")
@@ -758,7 +741,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed str
ID: definition.ID,
Title: definition.Title,
Kind: definition.Kind,
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
})
}
return rows
@@ -1054,11 +1037,17 @@ func (e *Engine) similarRow(
return Row{}, false
}
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, "Because you watched "+seed.Name, "",
))
}
return Row{
ID: "similar:" + seed.ID,
Title: "Because you watched " + seed.Name,
Kind: "similar",
Items: Raws(items),
Items: raws,
}, true
}
@@ -1208,7 +1197,7 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended",
Title: "Recommended from your watching history",
Kind: "recommended",
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
}, true
}
@@ -1240,6 +1229,16 @@ func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile
ID: "recommended",
Title: "Recommended from your watching history",
Kind: "recommended",
Items: Raws(items),
Items: enrichRecommendationReasons(profile, items),
}, true
}
func enrichRecommendationReasons(profile Profile, items []Item) []json.RawMessage {
raws := make([]json.RawMessage, 0, len(items))
for _, item := range items {
raws = append(raws, enrichRecommendation(
item.Raw, strongestPersonalReason(profile, item).Text, "",
))
}
return raws
}
+8 -2
View File
@@ -248,6 +248,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") {
t.Fatalf("unexpected first row: %+v", rows[0])
}
if !strings.Contains(string(rows[0].Items[0]), `"MembyRecommendationReason":"Because you watched `) {
t.Fatalf("similar recommendation did not carry its server reason: %s", rows[0].Items[0])
}
last := rows[len(rows)-1]
if last.Kind != "recommended" || last.Title != "Recommended from your watching history" {
t.Fatalf("unexpected history row: %+v", last)
@@ -255,6 +258,9 @@ func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) {
if last.ID != "recommended" {
t.Fatalf("history row id should be stable, got %q", last.ID)
}
if !strings.Contains(string(last.Items[0]), `"MembyRecommendationReason":`) {
t.Fatalf("history recommendation did not carry a server reason: %s", last.Items[0])
}
}
func TestBuildForYouFiltersTimeAndAddsExplanation(t *testing.T) {
@@ -485,7 +491,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
evidence := map[string][]PreparedEvidence{
"drama": {{
ItemID: "arrival", Title: "Arrival",
Genres: []string{"Drama", "Science Fiction"},
Genres: []string{"Drama", "Science Fiction"}, Completed: true,
}},
}
counts := map[string]int{}
@@ -503,7 +509,7 @@ func TestPreparedExplanationsLimitOneSourceAndMixReasonKinds(t *testing.T) {
if kinds["completed-title"] == 0 || kinds["completed-title"] > 4 {
t.Fatalf("completed-title reasons = %d, want 1..4", kinds["completed-title"])
}
if kinds["genre"] == 0 {
if kinds["theme"] == 0 {
t.Fatalf("reason kinds were not mixed: %+v", kinds)
}
}
+267 -57
View File
@@ -7,63 +7,288 @@ import (
"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
const decadeReasonFloor = 1.5
// 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.
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)
add := func(reason string) {
if len(reasons) < limit && reason != "" {
reasons = append(reasons, reason)
for _, candidate := range personalReasons(profile, item) {
if len(reasons) == limit {
return reasons
}
reasons = append(reasons, candidate.Text)
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
add("Because you watch " + genre)
if len(reasons) < limit && item.CommunityRating >= 7.5 {
reasons = append(reasons, "Well rated ("+
strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64)+")")
}
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor {
add("You've watched " + name + " before")
if len(reasons) < limit && item.ProductionYear > 0 &&
time.Now().Year()-item.ProductionYear <= 1 {
reasons = append(reasons, "A recent release")
}
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")
if len(reasons) == 0 {
reasons = append(reasons, "Recommended 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 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 {
@@ -72,32 +297,17 @@ func heaviest(weights map[string]float64, wanted []string) (string, float64) {
continue
}
weight := weightFold(weights, candidate)
if weight <= 0 {
continue
}
if weight > bestWeight || (weight == bestWeight && candidate < best) {
if weight > bestWeight || weight == bestWeight && weight > 0 && 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
+105 -8
View File
@@ -30,17 +30,17 @@ const thrillerHistory = `{
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
}`
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) {
func TestWhyPrefersTheRelatedTitleOverItsGenres(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"],
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller","Crime"],
"People":[{"Name":"Denis Villeneuve","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" {
t.Fatalf("expected the genre reason first, got %v", reasons)
if len(reasons) == 0 || reasons[0] != "Because you watched Sicario" {
t.Fatalf("expected the related viewing reason first, got %v", reasons)
}
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
t.Fatalf("expected the shared director to be named, got %v", reasons)
@@ -73,7 +73,7 @@ func TestWhyAlwaysSaysSomething(t *testing.T) {
reasons := Why(Profile{}, candidate, ReasonLimit)
if len(reasons) != 1 || reasons[0] != "Drama from your library" {
if len(reasons) != 1 || reasons[0] != "Recommended from your library" {
t.Fatalf("an empty profile should still explain the title, got %v", reasons)
}
}
@@ -98,9 +98,9 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
}
want := []string{
"Because you watch Thriller",
"You've watched Emily Blunt before",
"More from Lionsgate",
"Because you watched Sicario",
"Because you like crime thrillers",
"Because you watch Emily Blunt",
}
for i, reason := range want {
if reasons[i] != reason {
@@ -109,6 +109,103 @@ func TestWhyIsCappedAndOrdered(t *testing.T) {
}
}
func TestWhyDoesNotTreatDramaAloneAsATitleRelationship(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"A Drama","Type":"Movie","Genres":["Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Another Drama","Type":"Movie","Genres":["Drama"]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if reasons[0] != "Because you watch Drama" {
t.Fatalf("broad genre should be the fallback, got %v", reasons)
}
if strings.Contains(strings.Join(reasons, "|"), "A Drama") {
t.Fatalf("Drama alone invented a title relationship: %v", reasons)
}
}
func TestWhyUsesSpecificSubgenreBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"Crime One","Type":"Movie","Genres":["Crime","Drama"]
}`)
// Do not retain title evidence here: this test isolates the learned theme wording.
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Crime Two","Type":"Series","Genres":["Drama","Crime"]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "Because you like crime dramas" {
t.Fatalf("specific theme reason = %v", got)
}
}
func TestWhyUsesCreatorBeforeBroadGenre(t *testing.T) {
profile := explainProfile(t, `{
"Id":"h1","Name":"The First","Type":"Movie","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"The Second","Type":"Series","Genres":["Drama"],
"People":[{"Name":"Vince Gilligan","Type":"Writer"}]
}`)
if got := Why(profile, candidate, 1); len(got) != 1 || got[0] != "More from Vince Gilligan" {
t.Fatalf("creator reason = %v", got)
}
}
func TestWhyDistinguishesFinishedTitlesFromEpisodes(t *testing.T) {
finished := explainItem(t, `{
"Id":"h1","Name":"Breaking Bad","Type":"Series","Genres":["Crime","Drama"],
"UserData":{"Played":true}
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"Better Call Saul","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile([]Item{finished}, nil), candidate, 1); got[0] != "Because you finished Breaking Bad" {
t.Fatalf("completed title reason = %v", got)
}
episodeHistory := explainItem(t, `{
"Id":"ep1","Name":"Pilot","Type":"Episode","SeriesId":"show-1",
"SeriesName":"Breaking Bad","Genres":["Crime","Drama"],"UserData":{"Played":true}
}`)
if got := Why(BuildProfile([]Item{episodeHistory}, nil), candidate, 1); got[0] != "Because you watched Breaking Bad" {
t.Fatalf("completed episode overstated series completion: %v", got)
}
}
func TestWhyCanUseARepeatedViewingEra(t *testing.T) {
profile := explainProfile(t,
`{"Id":"h1","Name":"One","Type":"Movie","ProductionYear":2003,"Genres":["Drama"]}`,
`{"Id":"h2","Name":"Two","Type":"Movie","ProductionYear":2007,"Genres":["Drama"]}`,
)
profile.ReasonEvidence = nil
candidate := explainItem(t, `{
"Id":"c1","Name":"Three","Type":"Movie","ProductionYear":2005,"Genres":["Drama"]
}`)
if got := Why(profile, candidate, 1); got[0] != "Because you've been watching 2000s dramas" {
t.Fatalf("viewing-era reason = %v", got)
}
}
func TestWhyCanNameAFavouriteTitle(t *testing.T) {
favourite := explainItem(t, `{
"Id":"h1","Name":"The Wire","Type":"Series","Genres":["Crime","Drama"]
}`)
candidate := explainItem(t, `{
"Id":"c1","Name":"We Own This City","Type":"Series","Genres":["Crime","Drama"]
}`)
if got := Why(BuildProfile(nil, []Item{favourite}), candidate, 1); got[0] != "Because you like The Wire" {
t.Fatalf("favourite-title reason = %v", got)
}
}
func TestWhyIsStableAcrossCalls(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
+53 -54
View File
@@ -25,6 +25,7 @@ type PreparedEvidence struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Genres []string `json:"genres,omitempty"`
Completed bool `json:"completed,omitempty"`
}
type PreparedTitleAffinity struct {
@@ -128,7 +129,7 @@ func (e *Engine) PrepareForYou(
index := newCatalogueIndex(catalogue)
evidenceByGenre := map[string][]PreparedEvidence{}
evidenceSeen := map[string]bool{}
addCompletedEvidence := func(item Item, sessionID string) {
addCompletedEvidence := func(item Item, sessionID string, completed bool) {
itemID := item.ID
title := item.Name
if strings.EqualFold(item.Type, "Episode") &&
@@ -152,6 +153,7 @@ func (e *Engine) PrepareForYou(
ItemID: itemID,
Title: title,
Genres: append([]string(nil), item.Genres...),
Completed: completed,
},
)
}
@@ -160,7 +162,7 @@ func (e *Engine) PrepareForYou(
// explanation pool breadth even when Tracearr title matching is sparse.
for _, item := range history {
if item.UserData.Played {
addCompletedEvidence(item, "")
addCompletedEvidence(item, "", !strings.EqualFold(item.Type, "Episode"))
}
}
titleAffinity := map[string]PreparedTitleAffinity{}
@@ -263,7 +265,9 @@ func (e *Engine) PrepareForYou(
}
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
addCompletedEvidence(
item, session.ID, !strings.EqualFold(session.MediaType, "episode"),
)
}
}
@@ -559,49 +563,46 @@ func explainPreparedRecommendation(
evidenceByGenre map[string][]PreparedEvidence,
completedReasonCounts map[string]int,
) (reason, label, kind, genre string, evidence PreparedEvidence) {
for _, wanted := range profile.TopGenres(5) {
for _, candidateGenre := range item.Genres {
if strings.EqualFold(wanted, candidateGenre) {
genre = candidateGenre
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(wanted))]
strong := make([]PreparedEvidence, 0, len(options))
for _, option := range options {
if strongEvidenceMatch(item, option) {
strong = append(strong, option)
}
}
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
break
strong := make([]PreparedEvidence, 0)
seenEvidence := map[string]bool{}
for _, candidateGenre := range item.Genres {
options := evidenceByGenre[strings.ToLower(strings.TrimSpace(candidateGenre))]
for _, option := range options {
if seenEvidence[option.ItemID] || !strongEvidenceMatch(item, option) {
continue
}
}
if genre != "" {
break
seenEvidence[option.ItemID] = true
strong = append(strong, option)
}
}
// Keep specific evidence prominent without letting it monopolise a row. One third
// of otherwise eligible cards deliberately uses the broader genre explanation,
// and no completed title can explain more than four candidates in a prepared pool.
useCompleted := evidence.Title != "" &&
stableEvidenceIndex("reason-kind:"+item.ID, 3) != 0 &&
completedReasonCounts[evidence.ItemID] < 4
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4
switch {
case useTitle:
verb := "watched "
kind = "recent-title"
if evidence.Completed {
verb, kind = "finished ", "completed-title"
}
reason = "Because you " + verb + evidence.Title
completedReasonCounts[evidence.ItemID]++
case browsed:
reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{}
case useCompleted:
reason, kind = "Because you finished "+evidence.Title, "completed-title"
completedReasonCounts[evidence.ItemID]++
case genre != "":
reason, kind = "Matches your "+genre+" viewing", "genre"
evidence = PreparedEvidence{}
case len(profile.Seeds) > 0:
reason, kind = "Inspired by "+profile.Seeds[0].Name, "recent-title"
evidence = PreparedEvidence{}
default:
reason, kind = "Matches your recent viewing", "generic"
evidence = PreparedEvidence{}
selected := strongestAvailableReason(profile, item, completedReasonCounts)
reason, kind, genre = selected.Text, selected.Kind, selected.Genre
if selected.SourceID != "" {
completedReasonCounts[selected.SourceID]++
evidence = PreparedEvidence{
ItemID: selected.SourceID, Title: selected.SourceTitle,
Completed: selected.Kind == "completed-title",
}
} else {
evidence = PreparedEvidence{}
}
}
switch score := compatibilityScore(item, compatibility); {
case score > 0.2:
@@ -615,24 +616,22 @@ func explainPreparedRecommendation(
return reason, label, kind, genre, evidence
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared := 0
broadOnly := true
for _, candidateGenre := range item.Genres {
for _, evidenceGenre := range evidence.Genres {
if !strings.EqualFold(strings.TrimSpace(candidateGenre), strings.TrimSpace(evidenceGenre)) {
continue
}
shared++
switch strings.ToLower(strings.TrimSpace(candidateGenre)) {
case "action", "adventure", "comedy", "drama", "thriller":
default:
broadOnly = false
}
break
func strongestAvailableReason(
profile Profile,
item Item,
titleReasonCounts map[string]int,
) personalReason {
for _, reason := range personalReasons(profile, item) {
if reason.SourceID == "" || titleReasonCounts[reason.SourceID] < 4 {
return reason
}
}
return shared >= 2 || shared == 1 && !broadOnly
return personalReason{Text: "Recommended from your library", Kind: "generic"}
}
func strongEvidenceMatch(item Item, evidence PreparedEvidence) bool {
shared, specific := sharedGenreCount(item.Genres, evidence.Genres)
return shared >= 2 || specific >= 1
}
func stableEvidenceIndex(itemID string, size int) int {
+37
View File
@@ -109,6 +109,11 @@ type Profile struct {
// good reason to *tell* someone about a title and a poor reason to rank by it: two
// films sharing an actor are often nothing alike.
PersonWeights map[string]float64
// DecadeWeights and ReasonEvidence retain just enough of the source history for the
// explanation layer to say why a particular title fits. They do not participate in
// ranking: ordering and wording remain deliberately separate concerns.
DecadeWeights map[int]float64
ReasonEvidence []ReasonEvidence
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
@@ -116,6 +121,11 @@ type Profile struct {
Seeds []Seed
}
type ReasonEvidence struct {
Item Item
Favourite bool
}
func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 }
// Decode parses raw Emby items, keeping the original payload attached.
@@ -141,14 +151,26 @@ func BuildProfile(history, favorites []Item) Profile {
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
PersonWeights: map[string]float64{},
DecadeWeights: map[int]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
seedSeen := map[string]bool{}
tasteSeen := map[string]bool{}
reasonSeen := map[string]bool{}
for i, item := range history {
profile.markSeen(item)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if item.SeriesID != "" {
reasonID = item.SeriesID
}
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{Item: reasonItem})
}
// Several episodes of one series are evidence for one taste, not several
// independent tastes. Keep the newest occurrence's recency weight and still
@@ -176,6 +198,15 @@ func BuildProfile(history, favorites []Item) Profile {
for _, item := range favorites {
profile.absorb(item, favoriteWeight)
reasonItem := item
reasonItem.Raw = nil
reasonID := item.ID
if reasonID != "" && !reasonSeen[reasonID] {
reasonSeen[reasonID] = true
profile.ReasonEvidence = append(profile.ReasonEvidence, ReasonEvidence{
Item: reasonItem, Favourite: true,
})
}
}
return profile
}
@@ -223,6 +254,12 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
p.PersonWeights[name] += weight
}
}
if item.ProductionYear > 0 {
if p.DecadeWeights == nil {
p.DecadeWeights = map[int]float64{}
}
p.DecadeWeights[item.ProductionYear/10*10] += weight
}
}
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as