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
+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() }