Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+80 -8
View File
@@ -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)