Publish current app and server
This commit is contained in:
@@ -49,7 +49,9 @@ type PreparedProfile struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -77,6 +79,8 @@ type PreparedResult struct {
|
||||
|
||||
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.
|
||||
@@ -167,6 +171,26 @@ func (e *Engine) PrepareForYou(
|
||||
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()
|
||||
@@ -225,6 +249,18 @@ func (e *Engine) PrepareForYou(
|
||||
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)
|
||||
@@ -262,9 +298,12 @@ func (e *Engine) PrepareForYou(
|
||||
return ranked[i].item.Name < ranked[j].item.Name
|
||||
})
|
||||
|
||||
prepared := make([]PreparedCandidate, 0, len(ranked))
|
||||
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,
|
||||
@@ -294,6 +333,9 @@ func (e *Engine) PrepareForYou(
|
||||
pickups[i].BaseRank = i + 1
|
||||
}
|
||||
prepared = append(pickups, prepared...)
|
||||
if len(prepared) > maxPreparedCandidatePool {
|
||||
prepared = prepared[:maxPreparedCandidatePool]
|
||||
}
|
||||
}
|
||||
|
||||
meanCompletion := 0.0
|
||||
@@ -310,7 +352,11 @@ func (e *Engine) PrepareForYou(
|
||||
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
|
||||
TypicalSessionMinutes: medianInt(durations),
|
||||
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
|
||||
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
|
||||
StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
|
||||
CodecOutcomes: codecs,
|
||||
Weighted: BuildWeightedProfileWithConfig(
|
||||
weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
|
||||
),
|
||||
SignalsThrough: signalsThrough,
|
||||
},
|
||||
Candidates: prepared,
|
||||
@@ -390,9 +436,11 @@ func abandonedShowCandidates(
|
||||
}
|
||||
if activity.After(current.lastActivity) {
|
||||
current.lastActivity = activity
|
||||
if session.SeasonNumber != nil {
|
||||
current.lastSeason = *session.SeasonNumber
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
@@ -420,7 +468,14 @@ func abandonedShowCandidates(
|
||||
cutoff := now.Add(-abandonedShowAge)
|
||||
for seriesID, watched := range progress {
|
||||
next, unfinished := nextBySeries[seriesID]
|
||||
if !unfinished || watched.lastActivity.After(cutoff) {
|
||||
// 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})
|
||||
@@ -478,7 +533,7 @@ func abandonedShowReason(lastSeason, nextSeason int) string {
|
||||
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:
|
||||
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"
|
||||
@@ -596,15 +651,17 @@ 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{}, ambiguous: map[string]bool{},
|
||||
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":
|
||||
@@ -627,6 +684,21 @@ func newCatalogueIndex(items []Item) catalogueIndex {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user