Files
memby/server/internal/recommend/prepared.go
T

767 lines
23 KiB
Go
Raw Normal View History

2026-07-29 15:26:40 +12:00
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"`
2026-08-23 09:40:45 +12:00
Completed bool `json:"completed,omitempty"`
2026-07-29 15:26:40 +12:00
}
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
2026-08-02 22:10:19 +12:00
ContextAffinity ContextAffinityProfile
2026-07-29 15:26:40 +12:00
CodecOutcomes map[string]map[string]int
2026-08-02 22:10:19 +12:00
Weighted WeightedProfile
2026-07-29 15:26:40 +12:00
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")
2026-08-02 22:10:19 +12:00
const maxPreparedCandidatePool = 750
2026-07-29 15:26:40 +12:00
// 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{}
2026-08-23 09:40:45 +12:00
addCompletedEvidence := func(item Item, sessionID string, completed bool) {
2026-07-29 15:26:40 +12:00
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...),
2026-08-23 09:40:45 +12:00
Completed: completed,
2026-07-29 15:26:40 +12:00
},
)
}
}
// 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 {
2026-08-23 09:40:45 +12:00
addCompletedEvidence(item, "", !strings.EqualFold(item.Type, "Episode"))
2026-07-29 15:26:40 +12:00
}
}
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{}
2026-08-02 22:10:19 +12:00
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,
})
}
2026-07-29 15:26:40 +12:00
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
2026-08-02 22:10:19 +12:00
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),
})
}
2026-07-29 15:26:40 +12:00
if completion >= 0.9 {
2026-08-23 09:40:45 +12:00
addCompletedEvidence(
item, session.ID, !strings.EqualFold(session.MediaType, "episode"),
)
2026-07-29 15:26:40 +12:00
}
}
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
})
2026-08-02 22:10:19 +12:00
prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool))
2026-07-29 15:26:40 +12:00
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
2026-08-02 22:10:19 +12:00
if len(prepared) == maxPreparedCandidatePool {
break
}
2026-07-29 15:26:40 +12:00
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...)
2026-08-02 22:10:19 +12:00
if len(prepared) > maxPreparedCandidatePool {
prepared = prepared[:maxPreparedCandidatePool]
}
2026-07-29 15:26:40 +12:00
}
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,
2026-08-02 22:10:19 +12:00
StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
CodecOutcomes: codecs,
Weighted: BuildWeightedProfileWithConfig(
weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
),
2026-07-29 15:26:40 +12:00
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
2026-08-02 22:10:19 +12:00
}
// 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
2026-07-29 15:26:40 +12:00
}
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]
2026-08-02 22:10:19 +12:00
// 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 {
2026-07-29 15:26:40 +12:00
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)
2026-08-02 22:10:19 +12:00
case lastSeason == 1 || lastSeason == 0 && nextSeason == 1:
2026-07-29 15:26:40 +12:00
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) {
2026-08-23 09:40:45 +12:00
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
2026-07-29 15:26:40 +12:00
}
2026-08-23 09:40:45 +12:00
seenEvidence[option.ItemID] = true
strong = append(strong, option)
2026-07-29 15:26:40 +12:00
}
}
2026-08-23 09:40:45 +12:00
if len(strong) > 0 {
evidence = strong[stableEvidenceIndex(item.ID, len(strong))]
}
useTitle := evidence.Title != "" && completedReasonCounts[evidence.ItemID] < 4
2026-07-29 15:26:40 +12:00
switch {
2026-08-23 09:40:45 +12:00
case useTitle:
verb := "watched "
kind = "recent-title"
if evidence.Completed {
verb, kind = "finished ", "completed-title"
}
reason = "Because you " + verb + evidence.Title
completedReasonCounts[evidence.ItemID]++
2026-07-29 15:26:40 +12:00
case browsed:
reason, kind = "You explored this recently", "browsed"
evidence = PreparedEvidence{}
default:
2026-08-23 09:40:45 +12:00
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{}
}
2026-07-29 15:26:40 +12:00
}
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
}
2026-08-23 09:40:45 +12:00
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
2026-07-29 15:26:40 +12:00
}
}
2026-08-23 09:40:45 +12:00
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
2026-07-29 15:26:40 +12:00
}
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
2026-08-02 22:10:19 +12:00
byID map[string]Item
2026-07-29 15:26:40 +12:00
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
2026-08-02 22:10:19 +12:00
series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{},
2026-07-29 15:26:40 +12:00
}
for _, item := range items {
2026-08-02 22:10:19 +12:00
index.byID[item.ID] = item
2026-07-29 15:26:40 +12:00
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
}
2026-08-02 22:10:19 +12:00
// 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
}
2026-07-29 15:26:40 +12:00
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:])
}