Files
memby/server/internal/recommend/prepared.go
T
2026-07-29 15:26:55 +12:00

696 lines
20 KiB
Go

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
CodecOutcomes map[string]map[string]int
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")
// 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{}
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 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, len(ranked))
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
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...)
}
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, CodecOutcomes: codecs,
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
if session.SeasonNumber != nil {
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]
if !unfinished || watched.lastActivity.After(cutoff) {
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:
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
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
series: map[string]Item{}, ambiguous: map[string]bool{},
}
for _, item := range items {
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
}
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:])
}