696 lines
20 KiB
Go
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:])
|
||
|
|
}
|