Files
memby/server/internal/credits/service.go
T
2026-08-15 09:23:26 +12:00

526 lines
18 KiB
Go

package credits
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
)
// The service: one worker, one queue, and the rules about when it is allowed to run.
//
// Everything expensive in this package funnels through Process, and the shape of that
// function is the performance claim in miniature — resolve, check the marker, and in the
// common case stop there having touched no media at all. Only a candidate that survives the
// cache check reaches a decoder.
const (
// livePlaybackDelay is how long a playback has to survive before it is worth scanning
// for. Somebody browsing the launcher starts and abandons episodes constantly, and
// scanning on the Play press would turn every one of those into disk activity. Waiting
// costs nothing: the credits are forty minutes away.
livePlaybackDelay = 45 * time.Second
// idlePoll is how often the worker looks for work when the queue is empty. Coarse on
// purpose — nothing here is urgent, and a tight loop on an idle NAS is exactly the sort
// of background cost this package is supposed not to have.
idlePoll = 30 * time.Second
// busyBackoff is how long the worker stands down when the server is under load.
busyBackoff = 2 * time.Minute
// scanBudget bounds one candidate end to end. Past it the answer is not worth the
// resources, and a scan that overruns is far more likely to be a pathological file than
// a slow one.
scanBudget = 90 * time.Second
)
// ResolvedMedia is everything the service needs to turn a candidate into a scan.
type ResolvedMedia struct {
Version MediaVersion
// URL is where the bytes are — Emby's direct stream route, since the gateway has no
// filesystem access to the media.
URL string
SeriesID string
Season int
Episode int
RuntimeMs int64
// EmbyCreditsMs is a chapter marker Emby found itself, if any. When Emby already knows,
// this subsystem must do nothing at all.
EmbyCreditsMs int64
}
// MediaResolver turns an item id into something scannable. Backed by Emby; kept an interface
// so the scheduler, the queue and every rule above them can be tested with no server.
type MediaResolver interface {
Resolve(ctx context.Context, itemID string) (ResolvedMedia, error)
}
// BehaviourSource reads the stops a household already made. Backed by the tracearr_sessions
// table, which is written by the For You import and by nothing here.
type BehaviourSource interface {
Stops(ctx context.Context, itemID string) ([]StopEvent, error)
}
// LoadGauge answers whether the server is too busy for speculative work. Deliberately a
// single boolean: building a telemetry subsystem to answer it would cost more than the
// scanning it is meant to defer.
type LoadGauge interface {
Busy(ctx context.Context) bool
}
// Deps is the service's wiring. Only Repository and MediaResolver are required; a service
// with no detector runs on behavioural evidence alone, which is what happens when ffmpeg is
// missing from the image.
type Deps struct {
Repository Repository
Resolver MediaResolver
Source CandidateSource
Detector Detector
Behaviour BehaviourSource
Load LoadGauge
Log *slog.Logger
Config Config
}
type Service struct {
repo Repository
resolver MediaResolver
source CandidateSource
detector Detector
behaviour BehaviourSource
load LoadGauge
log *slog.Logger
cfg Config
queue *Queue
flight *flightGroup
mu sync.Mutex
pending map[string]*pending
// liveDelay is a field rather than the constant so a test does not have to wait
// three quarters of a minute to prove that abandonment cancels a scan.
liveDelay time.Duration
}
func New(deps Deps) *Service {
cfg := deps.Config
if cfg.QueueLimit <= 0 {
cfg = DefaultConfig()
}
detector := deps.Detector
if detector == nil {
detector = noopDetector{}
}
log := deps.Log
if log == nil {
log = slog.Default()
}
return &Service{
repo: deps.Repository,
resolver: deps.Resolver,
source: deps.Source,
detector: detector,
behaviour: deps.Behaviour,
load: deps.Load,
log: log.With("component", "credits"),
cfg: cfg,
queue: NewQueue(cfg.QueueLimit),
flight: newFlightGroup(),
pending: map[string]*pending{},
liveDelay: livePlaybackDelay,
}
}
// Marker is the read path, and the one the API calls. It is a single indexed lookup and it
// is what makes the steady state free: once a household's viewing has settled, almost every
// call to this subsystem is this function returning a row.
func (s *Service) Marker(ctx context.Context, itemID string) (Marker, bool, error) {
if s == nil || s.repo == nil || itemID == "" {
return Marker{}, false, nil
}
resolved, err := s.resolver.Resolve(ctx, itemID)
if err != nil {
return Marker{}, false, err
}
return s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
}
// Refresh rebuilds the speculative queue from demand. Registered as a scheduled task, so its
// interval is the operator's to change and its last run is visible in the console.
//
// The returned detail is the scheduler's one-line summary and is empty when nothing changed,
// which is what stops a task running every ten minutes announcing itself every ten minutes.
func (s *Service) Refresh(ctx context.Context) (string, error) {
if s.source == nil {
return "", nil
}
candidates, err := s.source.Candidates(ctx)
if err != nil {
return "", err
}
// The cache check happens here as well as in the worker, and that is the point: an
// episode whose marker already exists must never occupy a queue slot, or a household
// that has been watching one show for a week ends up with a permanently full queue of
// work that will all turn out to be unnecessary.
wanted := make([]Candidate, 0, len(candidates))
skipped := 0
for _, candidate := range candidates {
known, err := s.known(ctx, candidate.ItemID)
if err != nil {
// Trouble reading a marker is not a reason to drop the candidate; the worker
// will check again and is the one that can afford to be wrong.
s.log.Debug("marker lookup failed", "item_id", candidate.ItemID, "error", err)
}
if known {
skipped++
continue
}
wanted = append(wanted, candidate)
}
s.queue.Replace(wanted)
if len(wanted) == 0 {
return "", nil
}
for _, candidate := range wanted {
s.log.Debug("credits_candidate",
"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
}
// NotePlayback is the live signal, and the strongest one there is: somebody is watching this
// episode now, and if there is no marker they will reach the credits without a button.
//
// It does not scan immediately. A Play press is not yet a viewing — the launcher makes it
// trivially easy to start something and change your mind — so the candidate is held for
// livePlaybackDelay and raised only if the playback is still going. That is the difference
// between a subsystem that reads a file per curious button press and one that reads a file
// per episode actually watched.
func (s *Service) NotePlayback(ctx context.Context, itemID string) {
if s == nil || itemID == "" {
return
}
s.mu.Lock()
if _, waiting := s.pending[itemID]; waiting {
s.mu.Unlock()
return
}
// Detached from the request context deliberately: the HTTP request that reported the
// playback is over in milliseconds, and hanging the timer off it would cancel every
// delayed scan the instant it returned.
timerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
s.pending[itemID] = &pending{
candidate: Candidate{
ItemID: itemID,
Priority: PriorityLive,
Reason: ReasonLivePlayback,
UserCount: 1,
LastViewed: time.Now().UTC(),
},
due: time.Now().Add(s.liveDelay),
cancel: cancel,
}
delay := s.liveDelay
s.mu.Unlock()
go func() {
defer cancel()
select {
case <-timerCtx.Done():
return
case <-time.After(delay):
}
s.mu.Lock()
entry, waiting := s.pending[itemID]
delete(s.pending, itemID)
s.mu.Unlock()
if !waiting {
return
}
// One last cache check before queueing. In the settled case the marker was already
// there and this costs one indexed read instead of a queue slot.
if known, err := s.known(timerCtx, itemID); err == nil && known {
return
}
if s.queue.Push(entry.candidate) {
s.log.Debug("credits_candidate",
"item", itemID, "reason", ReasonLivePlayback, "priority", PriorityLive)
}
}()
}
// AbandonPlayback withdraws a playback that stopped before its delay elapsed. This is what
// makes the delay worth having: without it the timer would fire regardless and the episode
// somebody looked at for ten seconds would be scanned anyway.
func (s *Service) AbandonPlayback(itemID string) {
if s == nil || itemID == "" {
return
}
s.mu.Lock()
entry, waiting := s.pending[itemID]
delete(s.pending, itemID)
s.mu.Unlock()
if waiting && entry.cancel != nil {
entry.cancel()
}
}
// Run is the worker. One goroutine, for the whole gateway, for ever.
//
// A single worker is not a placeholder for a pool. Concurrent scans would multiply exactly
// the two costs this package is built to minimise — disk reads and decoder CPU — on a machine
// whose primary job is streaming video to televisions. Depth here buys nothing: the queue is
// a handful of episodes, and there are hours before anybody reaches them.
func (s *Service) Run(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
candidate, found := s.queue.Claim()
if !found {
if !sleep(ctx, idlePoll) {
return
}
continue
}
// Live playback goes ahead regardless of load: somebody is waiting, and the scan is
// a couple of minutes of ranged reads against a file the server is already serving.
// Everything else stands down.
if candidate.Priority < PriorityLive && s.busy(ctx) {
s.queue.Release(candidate.ItemID)
s.queue.Push(candidate)
s.log.Debug("credits scan deferred; server busy", "item", candidate.ItemID)
if !sleep(ctx, busyBackoff) {
return
}
continue
}
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
detection, stored, err := s.Process(scanCtx, candidate.ItemID)
cancel()
s.queue.Release(candidate.ItemID)
switch {
case err != nil && errors.Is(err, context.Canceled):
return
case err != nil:
s.log.Debug("credits scan failed",
"item", candidate.ItemID, "reason", candidate.Reason, "error", err)
case stored:
s.log.Info("credits_detected",
"item", candidate.ItemID,
"marker_ms", detection.StartMs,
"confidence", round2(detection.Confidence),
"method", detection.Method,
"frames", detection.FramesSampled,
"duration_ms", detection.Elapsed.Milliseconds())
default:
s.log.Debug("credits not detected",
"item", candidate.ItemID, "frames", detection.FramesSampled)
}
}
}
// Process is one candidate, start to finish. Single-flighted on the media version, so the
// four ways an episode can be asked for — live playback, a predicted next, another viewer's
// prediction, a refresh cycle — collapse into one scan.
//
// Returns whether anything was stored, which is not the same as whether anything was found:
// a detection below the confidence threshold, or one too close to an existing marker to be
// worth a write, is a successful scan that deliberately produces no database activity.
func (s *Service) Process(ctx context.Context, itemID string) (Detection, bool, error) {
resolved, err := s.resolver.Resolve(ctx, itemID)
if err != nil {
return Detection{}, false, err
}
fingerprint := Fingerprint(resolved.Version)
result, err := s.flight.Do(itemID+":"+fingerprint, func() (any, error) {
detection, stored, err := s.process(ctx, itemID, fingerprint, resolved)
return processResult{detection: detection, stored: stored}, err
})
outcome, _ := result.(processResult)
return outcome.detection, outcome.stored, err
}
type processResult struct {
detection Detection
stored bool
}
func (s *Service) process(
ctx context.Context, itemID, fingerprint string, resolved ResolvedMedia,
) (Detection, bool, error) {
// Emby already knows. Nothing to do, and nothing to store: the live path reads Emby's
// chapters directly, so duplicating the answer here would be a row that could only ever
// go stale.
if resolved.EmbyCreditsMs > 0 {
return Detection{Found: true, StartMs: resolved.EmbyCreditsMs, Method: MethodEmby}, false, nil
}
existing, found, err := s.repo.GetMarker(ctx, itemID, fingerprint)
if err != nil {
return Detection{}, false, err
}
// The common path, and the one everything is optimised for: already decided, no media
// access, no decoder, no write.
if found && existing.Confidence >= ConfidenceThreshold {
return Detection{
Found: true, StartMs: existing.CreditsStartMs,
Confidence: existing.Confidence, Method: existing.DetectionMethod,
}, false, nil
}
if resolved.RuntimeMs <= 0 {
return Detection{}, false, nil
}
// Behaviour first, because it is free. On a well-watched show it can settle the question
// without any media being opened at all, and where it cannot it still narrows the window
// the decoder has to read.
evidence := s.evidence(ctx, itemID, resolved.RuntimeMs)
var visual Detection
if !evidence.StandaloneMarker() || !evidence.Usable() {
history, runtimes := s.seasonHistory(ctx, resolved)
window := NarrowWindow(resolved.RuntimeMs, history, runtimes, evidence)
visual, err = s.detector.Detect(ctx, MediaInfo{
URL: resolved.URL, RuntimeMs: resolved.RuntimeMs, Window: window,
})
if err != nil {
if errors.Is(err, ErrNoFFmpeg) {
// Run on behaviour alone rather than failing. An image without a decoder is
// a deliberate deployment, not a fault.
s.log.Debug("visual credits detection unavailable")
} else {
return Detection{}, false, err
}
}
visual.Window = window
}
detection, acceptable := Combine(visual, evidence)
if !acceptable {
return visual, false, nil
}
if found && !ShouldRewrite(existing, detection) {
// Stability: the same answer, or a marginally different one, is not worth a write.
return detection, false, nil
}
// A fingerprint too weak to detect a file replacement is one whose marker could outlive
// the file it describes. Better to keep re-deriving it than to store something nothing
// will ever invalidate.
if resolved.Version.Weak() {
s.log.Debug("credits marker withheld; weak fingerprint", "item", itemID)
return detection, false, nil
}
now := time.Now().UTC()
marker := Marker{
ItemID: itemID,
MediaFingerprint: fingerprint,
CreditsStartMs: detection.StartMs,
Confidence: detection.Confidence,
DetectionMethod: detection.Method,
SeriesID: resolved.SeriesID,
Season: resolved.Season,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.SaveMarker(ctx, marker); err != nil {
return detection, false, err
}
return detection, true, nil
}
// evidence reads the household's stops. Failures are swallowed: behaviour is an optimisation
// and an enhancement, never a precondition.
func (s *Service) evidence(ctx context.Context, itemID string, runtimeMs int64) BehaviourEvidence {
if s.behaviour == nil {
return BehaviourEvidence{}
}
stops, err := s.behaviour.Stops(ctx, itemID)
if err != nil {
s.log.Debug("behavioural evidence unavailable", "item", itemID, "error", err)
return BehaviourEvidence{}
}
return AnalyseStops(stops, runtimeMs)
}
// seasonHistory reads what is already known about this episode's neighbours, and the runtime
// of each so the tail offsets can be compared. Failures degrade to a generic tail window.
func (s *Service) seasonHistory(
ctx context.Context, resolved ResolvedMedia,
) ([]Marker, func(Marker) int64) {
if resolved.SeriesID == "" || resolved.Season <= 0 {
return nil, nil
}
const historyLimit = 6
history, err := s.repo.SeasonMarkers(ctx, resolved.SeriesID, resolved.Season, historyLimit)
if err != nil || len(history) == 0 {
return nil, nil
}
// The neighbours' runtimes come from resolving them, which would be a request each. The
// episodes of one season are within a minute or two of each other, so this episode's own
// runtime is a good enough stand-in — and being slightly wrong here only widens or
// narrows a window that carries a ninety-second margin either way.
runtime := resolved.RuntimeMs
return history, func(Marker) int64 { return runtime }
}
// known is the cache check in its cheapest form: does a marker exist for the current version
// of this item. One resolve, one indexed read.
func (s *Service) known(ctx context.Context, itemID string) (bool, error) {
resolved, err := s.resolver.Resolve(ctx, itemID)
if err != nil {
return false, err
}
if resolved.EmbyCreditsMs > 0 {
return true, nil
}
marker, found, err := s.repo.GetMarker(ctx, itemID, Fingerprint(resolved.Version))
if err != nil {
return false, err
}
return found && marker.Confidence >= ConfidenceThreshold, nil
}
func (s *Service) busy(ctx context.Context) bool {
return s.load != nil && s.load.Busy(ctx)
}
// QueueDepth is what the console reads.
func (s *Service) QueueDepth() int { return s.queue.Len() }
// Pending is the queue in worker order, for the admin console.
func (s *Service) Pending() []Candidate { return s.queue.Snapshot() }
func sleep(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func plural(count int) string {
if count == 1 {
return ""
}
return "s"
}
func round2(value float64) float64 {
return float64(int(value*100+0.5)) / 100
}