package recommend import ( "context" "encoding/json" "errors" "fmt" "math" "net/url" "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/emby" "github.com/ponzischeme89/memby/server/internal/tracearr" ) type PreparedLibrarySource interface { AllRecommendationCandidates(ctx context.Context) ([]json.RawMessage, error) } type PreparedEvidence struct { SessionID string `json:"sessionId"` ItemID string `json:"itemId"` Title string `json:"title"` Genres []string `json:"genres,omitempty"` } type PreparedTitleAffinity struct { Weight float64 `json:"weight"` Title string `json:"title"` SessionID string `json:"sessionId,omitempty"` } type PreparedSessionMapping struct { ServerID string SessionID string ItemID string SeriesID string } type PreparedProfile struct { TracearrUserID string TracearrUsername string SourceSessionCount int MeanCompletionRatio float64 TypicalSessionMinutes int GenreAffinity map[string]float64 TitleAffinity map[string]PreparedTitleAffinity StudioAffinity map[string]float64 ContextAffinity ContextAffinityProfile CodecOutcomes map[string]map[string]int Weighted WeightedProfile SignalsThrough *time.Time } type PreparedCandidate struct { ItemID string BaseRank int BaseScore float64 RuntimeMinutes int AffinityScore float64 CompatibilityScore float64 CompatibilityLabel string ReasonKind string ReasonGenre string ReasonSourceSessionID string ReasonSourceItemID string ReasonSourceTitle string RecommendationReason string } type PreparedResult struct { Profile PreparedProfile Candidates []PreparedCandidate Mappings []PreparedSessionMapping } var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable") const maxPreparedCandidatePool = 750 // PrepareForYou performs the expensive work outside a television request. It consumes // locally imported Tracearr sessions and the complete imported catalogue, producing a // compact profile and an intentionally over-provisioned ranked pool. func (e *Engine) PrepareForYou( ctx context.Context, cred emby.Credentials, username string, sessions []tracearr.Session, ) (PreparedResult, error) { sessions = recommendationSessions(sessions) history, favorites, err := e.gatherSignals(ctx, cred) if err != nil { return PreparedResult{}, err } profile := BuildProfile(history, favorites) library, ok := e.Library.(PreparedLibrarySource) if !ok { return PreparedResult{}, ErrPreparedLibraryUnavailable } raws, err := library.AllRecommendationCandidates(ctx) if err != nil { return PreparedResult{}, err } catalogue := Decode(raws) if len(catalogue) == 0 { return PreparedResult{}, ErrPreparedLibraryUnavailable } browsed := map[string]bool{} if e.Behavior != nil { browsedRaws, browseErr := e.Behavior.BrowsingCandidates( ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 30, ) if browseErr != nil { e.log.Warn("browsing signals unavailable during For You rebuild", "error", browseErr) } else { for i, item := range Decode(browsedRaws) { profile.absorbTaste(item, 0.55*powDecay(0.92, i)) browsed[item.ID] = true } } } index := newCatalogueIndex(catalogue) evidenceByGenre := map[string][]PreparedEvidence{} evidenceSeen := map[string]bool{} addCompletedEvidence := func(item Item, sessionID string) { itemID := item.ID title := item.Name if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" { title = item.SeriesName if item.SeriesID != "" { itemID = item.SeriesID } } for _, genre := range item.Genres { genreKey := strings.ToLower(strings.TrimSpace(genre)) evidenceKey := genreKey + "|" + itemID if genreKey == "" || itemID == "" || evidenceSeen[evidenceKey] { continue } evidenceSeen[evidenceKey] = true evidenceByGenre[genreKey] = append( evidenceByGenre[genreKey], PreparedEvidence{ SessionID: sessionID, ItemID: itemID, Title: title, Genres: append([]string(nil), item.Genres...), }, ) } } // Emby's played history is already a trustworthy completion signal and gives the // explanation pool breadth even when Tracearr title matching is sparse. for _, item := range history { if item.UserData.Played { addCompletedEvidence(item, "") } } titleAffinity := map[string]PreparedTitleAffinity{} mappings := make([]PreparedSessionMapping, 0, len(sessions)) var completionTotal float64 durations := make([]int, 0, len(sessions)) var signalsThrough *time.Time tracearrUserID := "" tracearrUsername := strings.TrimSpace(username) titleSignalCount := map[string]int{} contextAffinity := NewContextAffinityProfile() weightedEvidence := make([]ViewingEvidence, 0, len(history)+len(favorites)+len(sessions)) for _, item := range history { item = index.evidenceItem(item) completion := 0.0 if item.UserData.Played { completion = 1 } else if item.RunTimeTicks > 0 { completion = float64(item.UserData.PlaybackPositionTicks) / float64(item.RunTimeTicks) } weightedEvidence = append(weightedEvidence, ViewingEvidence{ Item: item, Completion: completion, Repeat: maxInt(1, item.UserData.PlayCount), }) } for _, item := range favorites { item = index.evidenceItem(item) weightedEvidence = append(weightedEvidence, ViewingEvidence{ Item: item, Completion: 0, Favorite: true, }) } for i, session := range sessions { completion := session.Completion() completionTotal += completion if minutes := int(int64(session.DurationMs) / 60_000); minutes > 0 { durations = append(durations, minutes) } if started, ok := parseTracearrTime(session.StartedAt); ok && (signalsThrough == nil || started.After(*signalsThrough)) { value := started signalsThrough = &value } if tracearrUserID == "" { tracearrUserID = strings.TrimSpace(session.User.ID) } if tracearrUsername == "" { tracearrUsername = strings.TrimSpace(session.User.Username) } if completion > 0 { profile.SeenTitles[tracearrSeenKey(session)] = true } item, matched := index.match(session) if !matched { continue } seriesID := item.SeriesID if strings.EqualFold(session.MediaType, "episode") && strings.EqualFold(item.Type, "Series") { seriesID = item.ID } mappings = append(mappings, PreparedSessionMapping{ ServerID: session.ServerID, SessionID: session.ID, ItemID: item.ID, SeriesID: seriesID, }) // Episode-heavy programmes should be strong signals, but not dozens of // independent votes. Each repeat contributes less than the previous one. repeats := titleSignalCount[item.ID] titleSignalCount[item.ID] = repeats + 1 weight := (0.2 + completion) * math.Pow(0.985, float64(i)) * math.Pow(0.65, float64(repeats)) profile.absorbTaste(item, weight) if completion > 0 { if item.ID != "" { profile.Seen[item.ID] = true } if item.SeriesID != "" { profile.Seen[item.SeriesID] = true } } current := titleAffinity[item.ID] current.Weight += weight current.Title = item.Name if current.SessionID == "" { current.SessionID = session.ID } titleAffinity[item.ID] = current if started, ok := parseTracearrTime(session.StartedAt); ok { contextAffinity.Add(item, started, completion, i, e.Location) weightedEvidence = append(weightedEvidence, ViewingEvidence{ Item: item, Completion: completion, Repeat: repeats + 1, OccurredAt: started, SessionMinutes: int(int64(session.DurationMs) / 60_000), }) } else { weightedEvidence = append(weightedEvidence, ViewingEvidence{ Item: item, Completion: completion, Repeat: repeats + 1, SessionMinutes: int(int64(session.DurationMs) / 60_000), }) } if completion >= 0.9 { addCompletedEvidence(item, session.ID) } } compatibility := buildCompatibilityProfile(sessions) type scored struct { item Item base float64 compatibility float64 score float64 } ranked := make([]scored, 0, len(catalogue)) seenCandidates := map[string]bool{} for _, candidate := range catalogue { if candidate.ID == "" || seenCandidates[candidate.ID] { continue } seenCandidates[candidate.ID] = true base := profile.Score(candidate) if base < 0 { continue } compatibilityValue := compatibilityScore(candidate, compatibility) ranked = append(ranked, scored{ item: candidate, base: base, compatibility: compatibilityValue, score: base + compatibilityValue*1.4, }) } sort.SliceStable(ranked, func(i, j int) bool { if ranked[i].score != ranked[j].score { return ranked[i].score > ranked[j].score } return ranked[i].item.Name < ranked[j].item.Name }) prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool)) completedReasonCounts := map[string]int{} for rank, entry := range ranked { if len(prepared) == maxPreparedCandidatePool { break } reason, label, kind, genre, evidence := explainPreparedRecommendation( profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre, completedReasonCounts, ) prepared = append(prepared, PreparedCandidate{ ItemID: entry.item.ID, BaseRank: rank + 1, BaseScore: entry.score, RuntimeMinutes: entry.item.RuntimeMinutes(), AffinityScore: entry.base, CompatibilityScore: entry.compatibility, CompatibilityLabel: label, ReasonKind: kind, ReasonGenre: genre, ReasonSourceSessionID: evidence.SessionID, ReasonSourceItemID: evidence.ItemID, ReasonSourceTitle: evidence.Title, RecommendationReason: reason, }) } pickups := e.prepareAbandonedShows(ctx, cred, index, sessions, compatibility, time.Now()) if len(pickups) > 0 { for i := range prepared { prepared[i].BaseRank += len(pickups) } for i := range pickups { pickups[i].BaseRank = i + 1 } prepared = append(pickups, prepared...) if len(prepared) > maxPreparedCandidatePool { prepared = prepared[:maxPreparedCandidatePool] } } meanCompletion := 0.0 if len(sessions) > 0 { meanCompletion = completionTotal / float64(len(sessions)) } codecs := map[string]map[string]int{ "direct": compatibility.directCodecs, "transcode": compatibility.transcodeCodecs, } return PreparedResult{ Profile: PreparedProfile{ TracearrUserID: tracearrUserID, TracearrUsername: tracearrUsername, SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion, TypicalSessionMinutes: medianInt(durations), GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity, StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity, CodecOutcomes: codecs, Weighted: BuildWeightedProfileWithConfig( weightedEvidence, time.Now(), e.Location, e.WeightedConfig, ), SignalsThrough: signalsThrough, }, Candidates: prepared, Mappings: mappings, }, nil } const ( abandonedShowAge = 21 * 24 * time.Hour maxPickupShows = 20 ) type abandonedShowProgress struct { item Item lastActivity time.Time lastSeason int completedEpisodes map[string]bool } // prepareAbandonedShows adds watched series back into the otherwise-unwatched candidate // pool. Emby Next Up is the completion boundary: if Emby has no next episode for this // user, the show is complete and cannot appear here. func (e *Engine) prepareAbandonedShows( ctx context.Context, cred emby.Credentials, index catalogueIndex, sessions []tracearr.Session, compatibility compatibilityProfile, now time.Time, ) []PreparedCandidate { nextUpSource, ok := e.source.(NextUpSource) if !ok { return nil } result, err := nextUpSource.NextUp(ctx, cred, url.Values{ "Limit": {"5000"}, "Fields": {"SeriesName,SeriesId,ParentIndexNumber,IndexNumber,RunTimeTicks"}, "EnableImages": {"false"}, "EnableUserData": {"true"}, "EnableTotalRecordCount": {"false"}, }) if err != nil { e.log.Warn("could not check abandoned shows against Emby Next Up", "error", err) return nil } return abandonedShowCandidates(index, sessions, Decode(result.Items), compatibility, now) } func abandonedShowCandidates( index catalogueIndex, sessions []tracearr.Session, nextUp []Item, compatibility compatibilityProfile, now time.Time, ) []PreparedCandidate { progress := map[string]*abandonedShowProgress{} for _, session := range sessions { if !strings.EqualFold(session.MediaType, "episode") || strings.TrimSpace(session.ShowTitle) == "" || session.Completion() < 0.1 { continue } series, matched := index.match(session) if !matched || series.ID == "" || !strings.EqualFold(series.Type, "Series") { continue } activity, ok := tracearrActivityTime(session) if !ok { continue } current := progress[series.ID] if current == nil { current = &abandonedShowProgress{ item: series, completedEpisodes: map[string]bool{}, } progress[series.ID] = current } if activity.After(current.lastActivity) { current.lastActivity = activity } // Track the furthest season ever reached, not merely the season from the most // recent replay. Any season-two evidence disqualifies a first-season pickup. if session.SeasonNumber != nil && *session.SeasonNumber > current.lastSeason { current.lastSeason = *session.SeasonNumber } if session.Completion() >= 0.9 && session.SeasonNumber != nil && session.EpisodeNumber != nil { key := strconv.Itoa(*session.SeasonNumber) + ":" + strconv.Itoa(*session.EpisodeNumber) current.completedEpisodes[key] = true } } nextBySeries := map[string]Item{} for _, episode := range nextUp { // Specials do not mean the main programme is unfinished. if episode.SeriesID == "" || episode.ParentIndexNumber <= 0 { continue } if _, exists := nextBySeries[episode.SeriesID]; !exists { nextBySeries[episode.SeriesID] = episode } } type pickup struct { progress *abandonedShowProgress next Item } eligible := make([]pickup, 0, len(progress)) cutoff := now.Add(-abandonedShowAge) for seriesID, watched := range progress { next, unfinished := nextBySeries[seriesID] // This shelf is intentionally about promising shows abandoned in or just after // their first season. Later-season lapses are ordinary Next Up material. Some // Tracearr episode records lack a season number, so a season-one Next Up is also // sufficient evidence that the viewer is still at the beginning. firstSeasonAbandonment := watched.lastSeason == 1 && next.ParentIndexNumber <= 2 || watched.lastSeason == 0 && next.ParentIndexNumber == 1 if !unfinished || watched.lastActivity.After(cutoff) || !firstSeasonAbandonment { continue } eligible = append(eligible, pickup{progress: watched, next: next}) } sort.SliceStable(eligible, func(i, j int) bool { iLaterSeason := eligible[i].progress.lastSeason > 1 jLaterSeason := eligible[j].progress.lastSeason > 1 if iLaterSeason != jLaterSeason { return iLaterSeason } if !eligible[i].progress.lastActivity.Equal(eligible[j].progress.lastActivity) { return eligible[i].progress.lastActivity.After(eligible[j].progress.lastActivity) } return len(eligible[i].progress.completedEpisodes) > len(eligible[j].progress.completedEpisodes) }) if len(eligible) > maxPickupShows { eligible = eligible[:maxPickupShows] } out := make([]PreparedCandidate, 0, len(eligible)) for _, candidate := range eligible { watched := candidate.progress next := candidate.next reason := abandonedShowReason(watched.lastSeason, next.ParentIndexNumber) compatibilityValue := compatibilityScore(watched.item, compatibility) out = append(out, PreparedCandidate{ ItemID: watched.item.ID, RuntimeMinutes: next.RuntimeMinutes(), BaseScore: float64(len(watched.completedEpisodes)), AffinityScore: float64(len(watched.completedEpisodes)), CompatibilityScore: compatibilityValue, CompatibilityLabel: compatibilityLabelForScore(compatibilityValue), ReasonKind: "pick-up", ReasonSourceItemID: next.ID, ReasonSourceTitle: next.Name, RecommendationReason: reason, }) } return out } func tracearrActivityTime(session tracearr.Session) (time.Time, bool) { if stopped, ok := parseTracearrTime(session.StoppedAt); ok { return stopped, true } return parseTracearrTime(session.StartedAt) } func abandonedShowReason(lastSeason, nextSeason int) string { switch { case lastSeason == 1 && nextSeason > 1: return fmt.Sprintf("You finished season 1 · season %d is waiting", nextSeason) case lastSeason > 1 && nextSeason > lastSeason: return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason) case lastSeason > 1: return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason) case lastSeason == 1 || lastSeason == 0 && nextSeason == 1: return "You left this in season 1 · pick it up again" default: return "You left this unfinished · pick it up again" } } func compatibilityLabelForScore(score float64) string { switch { case score > 0.2: return "Direct plays well on this TV" case score < -0.2: return "May need transcoding on this TV" default: return "TV compatibility not yet learned" } } func explainPreparedRecommendation( profile Profile, item Item, compatibility compatibilityProfile, browsed bool, 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 } } if genre != "" { break } } // 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 switch { 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{} } switch score := compatibilityScore(item, compatibility); { case score > 0.2: label = "Direct plays well on this TV" reason += " · " + label case score < -0.2: label = "May need transcoding on this TV" default: label = "TV compatibility not yet learned" } 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 } } return shared >= 2 || shared == 1 && !broadOnly } func stableEvidenceIndex(itemID string, size int) int { if size <= 1 { return 0 } var hash uint32 = 2166136261 for _, value := range []byte(itemID) { hash ^= uint32(value) hash *= 16777619 } return int(hash % uint32(size)) } type catalogueIndex struct { movieExact map[string]Item movieLoose map[string]Item series map[string]Item byID map[string]Item ambiguous map[string]bool } func newCatalogueIndex(items []Item) catalogueIndex { index := catalogueIndex{ movieExact: map[string]Item{}, movieLoose: map[string]Item{}, series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{}, } for _, item := range items { index.byID[item.ID] = item key := normalizePreparedTitle(item.Name) switch item.Type { case "Movie": if item.ProductionYear > 0 { index.movieExact[key+"|"+itoa(item.ProductionYear)] = item } if _, exists := index.movieLoose[key]; exists { index.ambiguous["movie|"+key] = true } else { index.movieLoose[key] = item } case "Series": if _, exists := index.series[key]; exists { index.ambiguous["series|"+key] = true } else { index.series[key] = item } } } return index } // evidenceItem promotes episode evidence to its series metadata. Emby episode rows often // omit People and studio details even when those fields were requested, while the parent // Series record contains the canonical cast and production metadata. func (i catalogueIndex) evidenceItem(item Item) Item { if !strings.EqualFold(item.Type, "Episode") || item.SeriesID == "" { return item } parent, ok := i.byID[item.SeriesID] if !ok { return item } parent.UserData = item.UserData return parent } func (i catalogueIndex) match(session tracearr.Session) (Item, bool) { if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" { key := normalizePreparedTitle(session.ShowTitle) if i.ambiguous["series|"+key] { return Item{}, false } item, ok := i.series[key] return item, ok } key := normalizePreparedTitle(session.MediaTitle) if session.Year != nil && *session.Year > 0 { if item, ok := i.movieExact[key+"|"+itoa(*session.Year)]; ok { return item, true } } if i.ambiguous["movie|"+key] { return Item{}, false } item, ok := i.movieLoose[key] return item, ok } func normalizePreparedTitle(value string) string { var b strings.Builder for _, r := range strings.ToLower(value) { if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } func parseTracearrTime(value string) (time.Time, bool) { parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)) if err != nil { return time.Time{}, false } return parsed.UTC(), true } func medianInt(values []int) int { if len(values) == 0 { return 0 } copyValues := append([]int(nil), values...) sort.Ints(copyValues) mid := len(copyValues) / 2 if len(copyValues)%2 == 1 { return copyValues[mid] } return (copyValues[mid-1] + copyValues[mid]) / 2 } func itoa(value int) string { if value == 0 { return "0" } var buf [20]byte pos := len(buf) for value > 0 { pos-- buf[pos] = byte('0' + value%10) value /= 10 } return string(buf[pos:]) }