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
+219 -44
View File
@@ -30,6 +30,10 @@ const (
unchangedPagesToStop = 2
preparedPoolReadLimit = 240
preparedRowSize = 20
maxPreparedCandidates = 750
// Increment only when stored eligibility, scoring, or explanation behavior changes.
preparedAlgorithmVersion = "2026-07-31.2"
)
type ImportResult struct {
@@ -37,6 +41,7 @@ type ImportResult struct {
Pages int `json:"pages"`
Seen int `json:"seen"`
Changed int `json:"changed"`
Dirtied int64 `json:"dirtied"`
Removed int64 `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
@@ -51,6 +56,8 @@ type Service struct {
log *slog.Logger
minRebuildAge time.Duration
refreshAge time.Duration
location *time.Location
now func() time.Time
mu sync.Mutex
importRunning bool
@@ -74,7 +81,15 @@ func New(
return &Service{
store: st, tracearr: tracearrClient, engine: engine, log: log,
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
building: map[string]bool{},
location: time.Local, now: time.Now, building: map[string]bool{},
}
}
// ConfigureTimeContext sets the household timezone used for day/time viewing habits.
func (s *Service) ConfigureTimeContext(location *time.Location) {
if location != nil {
s.location = location
s.engine.Location = location
}
}
@@ -137,6 +152,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
}()
unchangedPages := 0
terminalIdentities := map[string]store.RecommendationIdentity{}
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
if err != nil {
@@ -157,7 +173,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
ServerID: value.ServerID, SessionID: value.SessionID,
})
}
current, err := s.store.TracearrFingerprints(ctx, keys)
current, err := s.store.TracearrSessionSignals(ctx, keys)
if err != nil {
return result, err
}
@@ -166,10 +182,19 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
key := store.TracearrSessionKey{
ServerID: session.ServerID, SessionID: session.SessionID,
}
if !bytes.Equal(current[key], session.SourceFingerprint) {
previous, exists := current[key]
if !bytes.Equal(previous.Fingerprint, session.SourceFingerprint) {
pageChanged = true
result.Changed++
}
if tracearrSessionTerminal(session) && (!exists || !previous.Terminal) {
identity := store.RecommendationIdentity{
TracearrUserID: session.UserID,
Username: session.Username,
}
terminalIdentities[identity.TracearrUserID+"|"+
strings.ToLower(identity.Username)] = identity
}
}
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
return result, err
@@ -201,40 +226,45 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
}
result.Removed = removed
}
if result.Changed > 0 || result.Removed > 0 {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
return result, err
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return result, err
}
for _, user := range users {
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
return result, err
}
}
affected := make([]store.RecommendationIdentity, 0, len(terminalIdentities))
for _, identity := range terminalIdentities {
affected = append(affected, identity)
}
dirtied, err := s.store.MarkForYouProfilesDirtyByTracearrIdentity(ctx, affected)
if err != nil {
return result, err
}
result.Dirtied = dirtied
s.log.Info("Tracearr import finished",
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
"changed", result.Changed, "removed", result.Removed)
"changed", result.Changed, "dirtied", result.Dirtied, "removed", result.Removed)
return result, nil
}
func tracearrSessionTerminal(session store.TracearrSession) bool {
state := strings.ToLower(strings.TrimSpace(session.State))
return session.Watched || session.StoppedAt != nil ||
state == "stopped" || state == "completed" || state == "complete" || state == "ended"
}
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
if !s.beginBuild(sess.EmbyUserID) {
return nil
}
defer s.endBuild(sess.EmbyUserID)
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
_, poolBuiltAt, dirtySince, algorithmVersion, err :=
s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
if err != nil {
return err
}
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
algorithmChanged := algorithmVersion != preparedAlgorithmVersion
if !force && !algorithmChanged && poolBuiltAt != nil &&
time.Since(*poolBuiltAt) < s.minRebuildAge {
return nil
}
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
if !force && !algorithmChanged && dirtySince == nil && poolBuiltAt != nil &&
time.Since(*poolBuiltAt) < s.refreshAge {
return nil
}
@@ -294,11 +324,69 @@ func (s *Service) PreparedRows(
if len(items) == 0 {
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
}
items = rankPreparedForTime(items, s.now(), s.location)
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
return rows, len(rows) > 0, stale, nil
}
func rankPreparedForTime(
items []store.PreparedForYouItem,
now time.Time,
location *time.Location,
) []store.PreparedForYouItem {
if len(items) < 2 || len(items[0].ContextAffinity) == 0 {
return items
}
var profile recommend.ContextAffinityProfile
if err := json.Unmarshal(items[0].ContextAffinity, &profile); err != nil ||
len(profile.Slots) == 0 {
return items
}
type contextualItem struct {
item store.PreparedForYouItem
score float64
contextRaw float64
confidence float64
}
ranked := make([]contextualItem, 0, len(items))
var maxContext float64
for _, value := range items {
decoded := recommend.Decode([]json.RawMessage{value.Payload})
raw, confidence := 0.0, 0.0
if len(decoded) == 1 && value.ReasonKind != "pick-up" {
raw, confidence = profile.Score(decoded[0], now, location)
if raw > maxContext {
maxContext = raw
}
}
ranked = append(ranked, contextualItem{
item: value, score: value.BaseScore,
contextRaw: raw, confidence: confidence,
})
}
if maxContext == 0 {
return items
}
for i := range ranked {
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
if ranked[i].contextRaw > 0 && ranked[i].confidence >= 0.4 {
ranked[i].item.RecommendationReason += " · fits what you watch around this time"
}
}
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.BaseRank < ranked[j].item.BaseRank
})
out := make([]store.PreparedForYouItem, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.item)
}
return out
}
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
@@ -328,6 +416,28 @@ func (s *Service) RebuildAll(ctx context.Context, force bool) error {
return nil
}
// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
// startup migrations deterministic without rebuilding every fresh profile on every boot.
func (s *Service) RebuildOutdated(ctx context.Context) error {
users, err := s.recommendationUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
_, _, _, version, stateErr := s.store.ForYouProfileTimes(ctx, user.EmbyUserID)
if stateErr != nil {
return stateErr
}
if version == preparedAlgorithmVersion {
continue
}
if err := s.Rebuild(ctx, user, true); err != nil {
s.log.Warn("outdated For You rebuild failed", "user", user.EmbyUserID, "error", err)
}
}
return nil
}
func (s *Service) MarkAllDirty(ctx context.Context) {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
s.log.Warn("could not mark For You profiles dirty", "error", err)
@@ -427,6 +537,15 @@ func buildPreparedRows(
if len(items) == 0 {
return nil
}
eligibleItems := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if item.ReasonKind == "pick-up" &&
!strings.Contains(item.RecommendationReason, "season 1") {
continue
}
eligibleItems = append(eligibleItems, item)
}
items = eligibleItems
used := map[string]bool{}
rows := make([]recommend.Row, 0, 6)
appendRowWithMinimum := func(
@@ -465,13 +584,16 @@ func buildPreparedRows(
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range items {
if item.ReasonKind == "pick-up" {
// The reason guard also keeps an already-prepared pool from an older server
// version from briefly promoting later-season lapses after deployment.
if item.ReasonKind == "pick-up" &&
strings.Contains(item.RecommendationReason, "season 1") {
pickups = append(pickups, item)
}
}
// A pickup is valuable even when only one genuinely abandoned, unfinished series
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
appendRowWithMinimum("for-you:pick-up", "Pick this show up again", pickups, 1)
topTitle := "Top picks for you"
if minutes > 0 {
@@ -542,6 +664,33 @@ func buildPreparedRows(
return strings.TrimSpace(item.ReasonGenre) != ""
},
)
beforeBed := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if item.ReasonKind != "pick-up" && item.RuntimeMinutes >= 15 &&
item.RuntimeMinutes <= 50 {
decoded := recommend.Decode([]json.RawMessage{item.Payload})
if len(decoded) == 1 && strings.EqualFold(decoded[0].Type, "Series") {
beforeBed = append(beforeBed, item)
}
}
}
appendRow(
"for-you:one-episode-before-bed",
"One episode before bed",
beforeBed,
)
// The prepared pool contains only unseen candidates. Taking the remaining strong
// matches produces a genuine library-discovery shelf; request-time impression
// fatigue then rotates repeatedly ignored posters out of its leading positions.
hidden := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.ReasonKind != "pick-up" {
hidden = append(hidden, item)
}
}
appendRow("for-you:hidden", "Hidden in your library", hidden)
compatible := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
@@ -569,48 +718,62 @@ func rowKey(value string) string {
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery, refreshEvery time.Duration,
importEvery, fullEvery time.Duration,
rebuildHour int,
) {
if importEvery <= 0 {
var importC <-chan time.Time
var importTicker *time.Ticker
if importEvery > 0 {
importTicker = time.NewTicker(importEvery)
importC = importTicker.C
defer importTicker.Stop()
} else {
s.log.Info("Tracearr auto-import disabled")
return
}
importTicker := time.NewTicker(importEvery)
defer importTicker.Stop()
var fullC, refreshC <-chan time.Time
var fullTicker, refreshTicker *time.Ticker
var fullC <-chan time.Time
var fullTicker *time.Ticker
if fullEvery > 0 {
fullTicker = time.NewTicker(fullEvery)
fullC = fullTicker.C
defer fullTicker.Stop()
}
if refreshEvery > 0 {
refreshTicker = time.NewTicker(refreshEvery)
refreshC = refreshTicker.C
defer refreshTicker.Stop()
}
nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
rebuildTimer := time.NewTimer(time.Until(nextRebuild))
defer rebuildTimer.Stop()
s.log.Info("For You daily rebuild scheduled", "next", nextRebuild)
for {
select {
case <-ctx.Done():
return
case <-importTicker.C:
case <-importC:
if _, err := s.Import(ctx, false); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-fullC:
if _, err := s.Import(ctx, true); err != nil {
s.log.Warn("scheduled full Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-refreshC:
_ = s.RebuildAll(ctx, false)
case <-rebuildTimer.C:
if err := s.RebuildAll(ctx, true); err != nil {
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
}
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
}
}
}
func nextDailyRebuild(now time.Time, location *time.Location, hour int) time.Time {
if location == nil {
location = time.UTC
}
local := now.In(location)
next := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, location)
if !next.After(local) {
next = next.AddDate(0, 0, 1)
}
return next
}
func (s *Service) beginBuild(userID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
@@ -706,6 +869,14 @@ func storedResult(
if err != nil {
return store.RecommendationProfile{}, nil, err
}
contextAffinity, err := json.Marshal(result.Profile.ContextAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
weightedProfile, err := json.Marshal(result.Profile.Weighted)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
profile := store.RecommendationProfile{
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
TracearrUsername: result.Profile.TracearrUsername,
@@ -713,10 +884,14 @@ func storedResult(
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
ContextAffinity: contextAffinity, CodecOutcomes: codecs,
WeightedProfile: weightedProfile,
AlgorithmVersion: preparedAlgorithmVersion,
SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
}
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
for _, value := range result.Candidates {
candidateCount := min(len(result.Candidates), maxPreparedCandidates)
candidates := make([]store.ForYouCandidate, 0, candidateCount)
for _, value := range result.Candidates[:candidateCount] {
candidates = append(candidates, store.ForYouCandidate{
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,