This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+42 -63
View File
@@ -99,6 +99,20 @@ func (s *Service) Running() bool {
return s.importRunning || len(s.building) > 0
}
// Ping asks Tracearr for the smallest thing it will answer with.
//
// It exists so the integrations console can report whether Tracearr is reachable without
// reaching past this service for the client: the service owns the connection, and a
// console holding its own copy of the client would be a second place the address and key
// could be wrong. One user, one page — the answer is discarded and only the error matters.
func (s *Service) Ping(ctx context.Context) error {
if s == nil || s.tracearr == nil {
return errors.New("tracearr is not configured")
}
_, err := s.tracearr.Users(ctx, 1, 1)
return err
}
func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
return s.store.ForYouStats(ctx)
}
@@ -403,17 +417,35 @@ func (s *Service) RefreshAsync(sess store.Session, force bool) {
}()
}
func (s *Service) RebuildAll(ctx context.Context, force bool) error {
// RebuildResult is what one pass over the household did.
//
// Counted because a rebuild that quietly failed for every viewer and one that succeeded
// for every viewer are the same "no error" from outside: a per-user failure is logged and
// swallowed on purpose — one viewer's broken profile must not stop the rest being built —
// so the count is the only thing that can say it happened.
type RebuildResult struct {
Users int
Built int
Failed int
Skipped int
}
func (s *Service) RebuildAll(ctx context.Context, force bool) (RebuildResult, error) {
result := RebuildResult{}
users, err := s.recommendationUsers(ctx)
if err != nil {
return err
return result, err
}
result.Users = len(users)
for _, user := range users {
if err := s.Rebuild(ctx, user, force); err != nil {
result.Failed++
s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
continue
}
result.Built++
}
return nil
return result, nil
}
// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
@@ -767,66 +799,13 @@ func importDue(
return false, false
}
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery time.Duration,
rebuildHour int,
paused ...func() bool,
) {
// One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
// Two independent tickers measured process uptime, which is what let a restart reset
// the cadence and a bounced container import far more often than configured.
checkEvery := importEvery
if checkEvery <= 0 || (fullEvery > 0 && fullEvery < checkEvery) {
checkEvery = fullEvery
}
var importC <-chan time.Time
if checkEvery > 0 {
importTicker := time.NewTicker(checkEvery)
importC = importTicker.C
defer importTicker.Stop()
s.log.Info("Tracearr auto-import scheduled",
"incremental", importEvery.String(), "full", fullEvery.String())
} else {
s.log.Info("Tracearr auto-import disabled")
}
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 <-importC:
if len(paused) > 0 && paused[0] != nil && paused[0]() {
continue
}
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
}
case <-rebuildTimer.C:
if len(paused) == 0 || paused[0] == nil || !paused[0]() {
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
}
// Schedule used to own both the Tracearr import ticker and the daily rebuild timer, and
// nextDailyRebuild was when the household's off-peak hour next came round. Both are gone:
// the import and the rebuild are scheduler tasks now (see api.RegisterIntegrationTasks),
// because a ticker in here could report nothing to an operator, could not be started by
// hand, and — since the tasks carry an integration id — could not be counted towards
// Tracearr's run history. The "is the rebuild due" rule moved with the work, to
// api.forYouRebuildDue, and is still pure and still tested.
func (s *Service) beginBuild(userID string) bool {
s.mu.Lock()
-11
View File
@@ -179,17 +179,6 @@ func TestImportDueRespectsDisabledIntervals(t *testing.T) {
full, due)
}
}
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
next := nextDailyRebuild(now, location, 4)
want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
if !next.Equal(want) {
t.Fatalf("next rebuild = %v, want %v", next, want)
}
}
func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
result := recommend.PreparedResult{
Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),