This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
+17 -3
View File
@@ -8,7 +8,7 @@ is in the library" and answers by reading all of it. This asks "what will somebo
on over the next few evenings", which Tracearr already knows, and reads almost nothing:
```
Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
Tracearr demand → priority queue → marker/cooldown check → tiny tail scan → history + marker
```
## Why it exists
@@ -57,6 +57,19 @@ postgres.go the store adapter
load.go whether the server is too busy for speculative work
```
## Operator controls and history
Admin Console → Credits detection controls three different bounds: the number of candidates
allowed to wait, the ordinary and maximum episode look-ahead, and the retry delay after an
inconclusive or failed speculative scan. The environment values are first-run defaults; a
saved console choice is restored on restart.
The retry delay is important. A successful marker excludes an episode naturally, but an old
worker forgot a no-match result and selected the episode again at the next ten-minute
refresh. Completed attempts now have a small durable history and no-match/failed episodes
cool down before prediction may select them again. Live playback may still raise an episode
immediately because a viewer is waiting.
## Two detectors, and the cheap one is often better
**Behavioural** clustering costs nothing: no file is opened, no decoder runs, no new row is
@@ -111,7 +124,7 @@ number belonging to neither.
| Fine pass | 750ms over ±30s — ~80 frames |
| Frame buffer | 14,400 bytes, allocated once and reused for the whole pass |
| Writes, cached marker | **0** |
| Writes, successful scan | **1** |
| Writes, completed scan | **1 history row**, plus **1 marker** only when accepted |
Against scanning the full library: 20,000 items read end to end versus a queue capped at 20
candidates, most of which are rejected by the marker check before any media is touched. Once
@@ -135,7 +148,8 @@ in which demand-driven narrowing is doing nothing, and the design would need rev
- **The queue is deliberately not durable.** Candidate priorities are rebuilt from one
Tracearr query on restart, which is cheaper and simpler than a second persistent job
scheduler. The database holds markers and nothing else.
scheduler. Completed scan history is durable because it is an operator record and the
source of retry cooldowns; it does not preserve or resume queue state.
- **A single worker, and it is not a placeholder for a pool.** Concurrent scans multiply the
two costs this exists to minimise on a machine whose real job is streaming video.
- **Live playback does not scan immediately.** `livePlaybackDelay` (45s) is what stops a
+45
View File
@@ -35,6 +35,10 @@ type Config struct {
// QueueLimit bounds the queue. Past it, low-priority speculation is discarded rather than
// queued — a backlog of candidates for episodes nobody reached is worse than no backlog.
QueueLimit int
// RetryCooldown keeps an unsuccessful speculative scan from returning on every queue
// refresh. Live playback remains immediate; this applies only to predicted work.
RetryCooldown time.Duration
}
func DefaultConfig() Config {
@@ -45,9 +49,50 @@ func DefaultConfig() Config {
UsefulWindow: 3 * 24 * time.Hour,
WeakWindow: 7 * 24 * time.Hour,
QueueLimit: 20,
RetryCooldown: 24 * time.Hour,
}
}
// NormaliseConfig makes both environment and admin-supplied tuning safe. It is exported
// because the gateway restores persisted settings before constructing the service.
func NormaliseConfig(cfg Config) Config {
defaults := DefaultConfig()
if cfg.PrefetchEpisodes <= 0 {
cfg.PrefetchEpisodes = defaults.PrefetchEpisodes
}
if cfg.PrefetchEpisodes > 10 {
cfg.PrefetchEpisodes = 10
}
if cfg.MaxPrefetchEpisodes < cfg.PrefetchEpisodes {
cfg.MaxPrefetchEpisodes = cfg.PrefetchEpisodes
}
if cfg.MaxPrefetchEpisodes > 20 {
cfg.MaxPrefetchEpisodes = 20
}
if cfg.QueueLimit <= 0 {
cfg.QueueLimit = defaults.QueueLimit
}
if cfg.QueueLimit > 100 {
cfg.QueueLimit = 100
}
if cfg.StrongWindow <= 0 {
cfg.StrongWindow = defaults.StrongWindow
}
if cfg.UsefulWindow <= 0 {
cfg.UsefulWindow = defaults.UsefulWindow
}
if cfg.WeakWindow <= 0 {
cfg.WeakWindow = defaults.WeakWindow
}
if cfg.RetryCooldown < 0 {
cfg.RetryCooldown = 0
}
if cfg.RetryCooldown > 30*24*time.Hour {
cfg.RetryCooldown = 30 * 24 * time.Hour
}
return cfg
}
// Watch is one episode one viewer played, reduced to the four things candidate generation
// needs. It is what a Tracearr session becomes on the way in, and keeping it this narrow is
// what lets every rule below be tested with a literal.
+32
View File
@@ -174,6 +174,38 @@ type CandidateSource interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// ConfigurableCandidateSource accepts live operator tuning without rebuilding the worker.
type ConfigurableCandidateSource interface {
Configure(Config)
}
// ScanAttempt is the durable operational account of one claimed candidate. Unlike the
// queue, this is history rather than scheduling state, so retaining it does not turn the
// worker into a persistent job system.
type ScanAttempt struct {
ItemID string
SeriesID string
Season int
Episode int
Reason string
Priority int
Outcome string
MarkerMs int64
Confidence float64
Method string
Frames int
Error string
StartedAt time.Time
FinishedAt time.Time
}
// HistoryRepository records completed scans and answers which speculative candidates are
// still inside their retry cooldown.
type HistoryRepository interface {
SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error
RecentScanTimes(ctx context.Context, itemIDs []string, since time.Time) (map[string]time.Time, error)
}
// Repository is the marker store. Narrow on purpose — the scheduler must not be able to
// write anything else, because "one row per successful scan and nothing else" is the
// performance claim this subsystem is making.
+17
View File
@@ -40,6 +40,23 @@ func (p Postgres) SaveMarker(ctx context.Context, marker Marker) error {
})
}
func (p Postgres) SaveScanAttempt(ctx context.Context, attempt ScanAttempt) error {
return p.Store.SaveCreditsScanAttempt(ctx, store.CreditsScanHistoryRow{
ItemID: attempt.ItemID, SeriesID: attempt.SeriesID,
Season: attempt.Season, Episode: attempt.Episode,
Reason: attempt.Reason, Priority: attempt.Priority, Outcome: attempt.Outcome,
MarkerMs: attempt.MarkerMs, Confidence: attempt.Confidence,
Method: attempt.Method, Frames: attempt.Frames, Error: attempt.Error,
StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt,
})
}
func (p Postgres) RecentScanTimes(
ctx context.Context, itemIDs []string, since time.Time,
) (map[string]time.Time, error) {
return p.Store.RecentCreditsScanTimes(ctx, itemIDs, since)
}
func (p Postgres) SeasonMarkers(
ctx context.Context, seriesID string, season, limit int,
) ([]Marker, error) {
+19
View File
@@ -136,6 +136,25 @@ func (q *Queue) Len() int {
return len(q.items)
}
// SetLimit applies an operator change immediately. The strongest candidates survive a
// reduction, using the same ordering as Claim, so changing the setting cannot reshuffle
// the meaning of priority.
func (q *Queue) SetLimit(limit int) {
if limit <= 0 {
return
}
q.mu.Lock()
defer q.mu.Unlock()
q.limit = limit
for len(q.items) > q.limit {
weakestID, _ := q.weakestLocked()
if weakestID == "" {
break
}
delete(q.items, weakestID)
}
}
// Snapshot is the queue in worker order, for the log line and the admin console.
func (q *Queue) Snapshot() []Candidate {
q.mu.Lock()
+14
View File
@@ -141,3 +141,17 @@ func TestQueueOrderIsTotalAndStable(t *testing.T) {
t.Fatalf("claimed %q; more recent demand should win a tie", candidate.ItemID)
}
}
func TestReducingQueueLimitKeepsTheStrongestCandidates(t *testing.T) {
queue := NewQueue(5)
queue.Push(queued("weak", PrioritySpeculative))
queue.Push(queued("ahead", PriorityAhead2))
queue.Push(queued("next", PriorityNext))
queue.SetLimit(2)
pending := queue.Snapshot()
if len(pending) != 2 || pending[0].ItemID != "next" || pending[1].ItemID != "ahead" {
t.Fatalf("reduced queue = %+v; want the two strongest candidates", pending)
}
}
+92 -6
View File
@@ -76,6 +76,7 @@ type LoadGauge interface {
// missing from the image.
type Deps struct {
Repository Repository
History HistoryRepository
Resolver MediaResolver
Source CandidateSource
Detector Detector
@@ -87,6 +88,7 @@ type Deps struct {
type Service struct {
repo Repository
history HistoryRepository
resolver MediaResolver
source CandidateSource
detector Detector
@@ -94,6 +96,7 @@ type Service struct {
load LoadGauge
log *slog.Logger
cfg Config
cfgMu sync.RWMutex
queue *Queue
flight *flightGroup
@@ -107,10 +110,7 @@ type Service struct {
}
func New(deps Deps) *Service {
cfg := deps.Config
if cfg.QueueLimit <= 0 {
cfg = DefaultConfig()
}
cfg := NormaliseConfig(deps.Config)
detector := deps.Detector
if detector == nil {
detector = noopDetector{}
@@ -121,6 +121,7 @@ func New(deps Deps) *Service {
}
return &Service{
repo: deps.Repository,
history: deps.History,
resolver: deps.Resolver,
source: deps.Source,
detector: detector,
@@ -181,9 +182,37 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
}
wanted = append(wanted, candidate)
}
deferred := 0
cfg := s.Configuration()
if s.history != nil && cfg.RetryCooldown > 0 && len(wanted) > 0 {
ids := make([]string, 0, len(wanted))
for _, candidate := range wanted {
ids = append(ids, candidate.ItemID)
}
recent, historyErr := s.history.RecentScanTimes(
ctx, ids, time.Now().UTC().Add(-cfg.RetryCooldown),
)
if historyErr != nil {
s.log.Debug("credits scan history unavailable", "error", historyErr)
} else if len(recent) > 0 {
eligible := wanted[:0]
for _, candidate := range wanted {
if _, coolingDown := recent[candidate.ItemID]; coolingDown {
deferred++
continue
}
eligible = append(eligible, candidate)
}
wanted = eligible
}
}
s.queue.Replace(wanted)
if len(wanted) == 0 {
if deferred > 0 {
return fmt.Sprintf("%d candidate%s cooling down, %d already known",
deferred, plural(deferred), skipped), nil
}
return "", nil
}
for _, candidate := range wanted {
@@ -191,8 +220,8 @@ func (s *Service) Refresh(ctx context.Context) (string, error) {
"item", candidate.ItemID, "reason", candidate.Reason,
"priority", candidate.Priority, "users", candidate.UserCount)
}
return fmt.Sprintf("%d candidate%s queued, %d already known",
len(wanted), plural(len(wanted)), skipped), nil
return fmt.Sprintf("%d candidate%s queued, %d cooling down, %d already known",
len(wanted), plural(len(wanted)), deferred, skipped), nil
}
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
@@ -303,10 +332,12 @@ func (s *Service) Run(ctx context.Context) {
continue
}
started := time.Now().UTC()
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
cancel()
s.queue.Release(candidate.ItemID)
s.recordAttempt(ctx, candidate, detection, stored, err, started)
switch {
case err != nil && errors.Is(err, context.Canceled):
@@ -496,6 +527,61 @@ func (s *Service) busy(ctx context.Context) bool {
return s.load != nil && s.load.Busy(ctx)
}
// Configuration is a snapshot safe to expose through the admin API.
func (s *Service) Configuration() Config {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.cfg
}
// Configure applies persisted operator tuning to future candidate refreshes and trims an
// over-full queue immediately. A scan already in flight is deliberately left alone.
func (s *Service) Configure(cfg Config) {
cfg = NormaliseConfig(cfg)
s.cfgMu.Lock()
s.cfg = cfg
s.cfgMu.Unlock()
if source, ok := s.source.(ConfigurableCandidateSource); ok {
source.Configure(cfg)
}
s.queue.SetLimit(cfg.QueueLimit)
}
func (s *Service) recordAttempt(
ctx context.Context, candidate Candidate, detection Detection, stored bool, scanErr error,
started time.Time,
) {
if s.history == nil {
return
}
outcome := "no_match"
switch {
case scanErr != nil:
outcome = "failed"
case stored:
outcome = "detected"
case detection.Found:
outcome = "unchanged"
}
errorText := ""
if scanErr != nil {
errorText = scanErr.Error()
}
finished := time.Now().UTC()
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := s.history.SaveScanAttempt(writeCtx, ScanAttempt{
ItemID: candidate.ItemID, SeriesID: candidate.SeriesID,
Season: candidate.Season, Episode: candidate.Episode,
Reason: candidate.Reason, Priority: candidate.Priority, Outcome: outcome,
MarkerMs: detection.StartMs, Confidence: detection.Confidence,
Method: detection.Method, Frames: detection.FramesSampled, Error: errorText,
StartedAt: started, FinishedAt: finished,
}); err != nil {
s.log.Debug("credits scan history write failed", "item", candidate.ItemID, "error", err)
}
}
// QueueDepth is what the console reads.
func (s *Service) QueueDepth() int { return s.queue.Len() }
+59
View File
@@ -85,6 +85,26 @@ type fakeBehaviour struct{ stops []StopEvent }
func (b fakeBehaviour) Stops(context.Context, string) ([]StopEvent, error) { return b.stops, nil }
type fixedCandidates []Candidate
func (f fixedCandidates) Candidates(context.Context) ([]Candidate, error) { return f, nil }
type fakeHistory struct {
recent map[string]time.Time
attempts []ScanAttempt
}
func (h *fakeHistory) SaveScanAttempt(_ context.Context, attempt ScanAttempt) error {
h.attempts = append(h.attempts, attempt)
return nil
}
func (h *fakeHistory) RecentScanTimes(
_ context.Context, _ []string, _ time.Time,
) (map[string]time.Time, error) {
return h.recent, nil
}
func quietLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
@@ -356,3 +376,42 @@ func TestSustainedPlaybackIsQueuedAtLivePriority(t *testing.T) {
t.Fatalf("candidate = %+v, want live playback at priority %d", pending[0], PriorityLive)
}
}
// An inconclusive scan used to be forgotten, so the ten-minute refresh selected the same
// episode for ever. History now makes that episode ineligible until its cooldown expires.
func TestRefreshDefersRecentlyScannedCandidate(t *testing.T) {
media := testMedia()
history := &fakeHistory{recent: map[string]time.Time{media.Version.ItemID: time.Now()}}
service := New(Deps{
Repository: newFakeRepo(), History: history,
Resolver: &fakeResolver{media: map[string]ResolvedMedia{media.Version.ItemID: media}},
Source: fixedCandidates{{ItemID: media.Version.ItemID, Priority: PriorityNext}},
Log: quietLog(), Config: DefaultConfig(),
})
detail, err := service.Refresh(context.Background())
if err != nil {
t.Fatal(err)
}
if service.QueueDepth() != 0 {
t.Fatal("a recently scanned episode returned to the queue")
}
if detail != "1 candidate cooling down, 0 already known" {
t.Fatalf("refresh detail = %q", detail)
}
}
func TestScanAttemptHistoryDescribesNoMatch(t *testing.T) {
history := &fakeHistory{}
service := New(Deps{
Repository: newFakeRepo(), History: history,
Resolver: &fakeResolver{media: map[string]ResolvedMedia{}},
Log: quietLog(), Config: DefaultConfig(),
})
candidate := Candidate{ItemID: "episode-7", Reason: ReasonNext, Priority: PriorityNext}
service.recordAttempt(context.Background(), candidate, Detection{FramesSampled: 42}, false, nil, time.Now().Add(-time.Second))
if len(history.attempts) != 1 || history.attempts[0].Outcome != "no_match" || history.attempts[0].Frames != 42 {
t.Fatalf("history = %+v", history.attempts)
}
}
+15 -4
View File
@@ -3,6 +3,7 @@ package credits
import (
"context"
"sort"
"sync"
"time"
)
@@ -39,6 +40,19 @@ const watchLimit = 500
type TracearrSource struct {
DB Database
Cfg Config
mu sync.RWMutex
}
func (s *TracearrSource) Configure(cfg Config) {
s.mu.Lock()
s.Cfg = NormaliseConfig(cfg)
s.mu.Unlock()
}
func (s *TracearrSource) config() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return NormaliseConfig(s.Cfg)
}
// Candidates is the whole predictive pipeline: read recent demand, group it into per-viewer
@@ -51,10 +65,7 @@ func (s *TracearrSource) Candidates(ctx context.Context) ([]Candidate, error) {
if s == nil || s.DB == nil {
return nil, nil
}
cfg := s.Cfg
if cfg.QueueLimit <= 0 {
cfg = DefaultConfig()
}
cfg := s.config()
now := time.Now().UTC()
// The decay window is the query's window too. Anything older cannot survive