2026-08-15 09:23:26 +12:00
|
|
|
// Package credits discovers where an episode's closing credits begin, for the small number
|
|
|
|
|
// of episodes a household is actually about to watch.
|
|
|
|
|
//
|
|
|
|
|
// It is deliberately not a library scanner. A scanner asks "what is in the library" and
|
|
|
|
|
// answers by reading all of it; this asks "what will somebody press Play on in the next few
|
|
|
|
|
// evenings" and reads almost nothing. Tracearr already records what the house watches and
|
|
|
|
|
// when, so the question has an answer that costs one indexed query — and the whole design
|
|
|
|
|
// follows from taking that answer seriously:
|
|
|
|
|
//
|
|
|
|
|
// Tracearr demand → priority queue → marker cached? → tiny tail scan → one write → never again
|
|
|
|
|
//
|
|
|
|
|
// The two halves are kept apart on purpose. Candidate generation knows about viewers,
|
|
|
|
|
// series and velocity and nothing about video; the detector is handed a file and a window
|
|
|
|
|
// and never learns why that episode was chosen. That is what lets the scheduler be tested
|
|
|
|
|
// with no media and the detector be benchmarked with no database.
|
|
|
|
|
//
|
|
|
|
|
// Emby's own chapter markers still win where they exist (`api/intro.go`). This fills in the
|
|
|
|
|
// rest of a library, which on Emby 4.10 is most of it: a survey of the 20,000-item library
|
|
|
|
|
// this ships to found no CreditsStart markers at all.
|
|
|
|
|
package credits
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Detection methods, stored on the marker so a later reading can tell what produced it and
|
|
|
|
|
// whether new evidence is worth preferring.
|
|
|
|
|
const (
|
|
|
|
|
// MethodVisual is a tail scan alone: the picture changed into something structurally
|
|
|
|
|
// credit-shaped and nothing else agreed or disagreed.
|
|
|
|
|
MethodVisual = "visual_tail"
|
|
|
|
|
// MethodBehaviour is viewers alone. Where enough people stopped an episode, or pressed
|
|
|
|
|
// next, within a few seconds of each other, that agreement is evidence no decoder can
|
|
|
|
|
// produce — and it costs no media access whatsoever.
|
|
|
|
|
MethodBehaviour = "behaviour"
|
|
|
|
|
// MethodCombined is both, agreeing. This is the marker worth trusting.
|
|
|
|
|
MethodCombined = "combined"
|
|
|
|
|
// MethodManual is an operator's correction, which nothing automatic may overwrite.
|
|
|
|
|
MethodManual = "manual"
|
|
|
|
|
// MethodEmby is a chapter marker Emby detected itself. Recorded for completeness where
|
|
|
|
|
// this subsystem stores one; the live path reads Emby's chapters directly.
|
|
|
|
|
MethodEmby = "emby"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Why a candidate was generated. It travels with the candidate purely so a log line and the
|
|
|
|
|
// benchmark can say whether demand-driven selection is earning its keep — nothing dispatches
|
|
|
|
|
// on it, and the detector never sees it.
|
|
|
|
|
const (
|
|
|
|
|
ReasonLivePlayback = "live-playback"
|
|
|
|
|
ReasonNext = "tracearr-next"
|
|
|
|
|
ReasonBinge = "tracearr-binge-prefetch"
|
|
|
|
|
ReasonMultiUser = "multi-user-demand"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// The priority model. Every number the scheduler orders by comes from here rather than
|
|
|
|
|
// being written into the rule that produced it, so the hierarchy can be read in one place
|
|
|
|
|
// and changed without hunting through the candidate builder.
|
|
|
|
|
const (
|
|
|
|
|
// PriorityLive is playback that has already begun with no marker to offer. It outranks
|
|
|
|
|
// everything because the viewer is in front of the television now, and the alternative
|
|
|
|
|
// to answering is a Skip Credits button that never appears.
|
|
|
|
|
PriorityLive = 1000
|
|
|
|
|
// PriorityNext is the episode an active viewer is most likely to start next.
|
|
|
|
|
PriorityNext = 800
|
|
|
|
|
// PriorityAhead2 and PriorityAhead3 are the tail of the look-ahead window. They decay
|
|
|
|
|
// fast on purpose: being three episodes wrong about somebody's evening is ordinary.
|
|
|
|
|
PriorityAhead2 = 500
|
|
|
|
|
PriorityAhead3 = 300
|
|
|
|
|
// PrioritySpeculative is the floor — a candidate worth doing if the machine is otherwise
|
|
|
|
|
// idle and worth discarding the moment the queue is under pressure.
|
|
|
|
|
PrioritySpeculative = 100
|
|
|
|
|
|
|
|
|
|
// MultiUserBonus is added once per additional viewer approaching the same episode, so
|
|
|
|
|
// two households converging on one episode outrank a single viewer's next episode
|
|
|
|
|
// without ever overtaking live playback.
|
|
|
|
|
MultiUserBonus = 100
|
|
|
|
|
// MaxPredictedPriority caps everything the predictor can produce. Live playback sits
|
|
|
|
|
// above it by construction, which is the one ordering guarantee worth enforcing rather
|
|
|
|
|
// than hoping the arithmetic preserves.
|
|
|
|
|
MaxPredictedPriority = PriorityLive - 1
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Candidate is one episode somebody is likely to watch, and the case for scanning it.
|
|
|
|
|
//
|
|
|
|
|
// Deliberately small and deliberately transient: candidates live in RAM, are rebuilt from
|
|
|
|
|
// Tracearr on every cycle, and are never written to the database. A restart rebuilds the
|
|
|
|
|
// queue from the same query that built it the first time, which is cheaper and far simpler
|
|
|
|
|
// than keeping a second job scheduler durable.
|
|
|
|
|
type Candidate struct {
|
|
|
|
|
ItemID string
|
|
|
|
|
SeriesID string
|
|
|
|
|
// Season and Episode are carried for the log line and for season-history narrowing;
|
|
|
|
|
// nothing routes on them.
|
|
|
|
|
Season int
|
|
|
|
|
Episode int
|
|
|
|
|
|
|
|
|
|
Priority int
|
|
|
|
|
Reason string
|
|
|
|
|
// UserCount is how many distinct viewers this candidate was raised for. It is what
|
|
|
|
|
// MultiUserBonus is computed from, and it is the honest measure of how much one scan is
|
|
|
|
|
// worth.
|
|
|
|
|
UserCount int
|
|
|
|
|
// LastViewed is the most recent activity that justified this candidate. Decay is
|
|
|
|
|
// measured from it, so a series somebody abandoned falls out of the queue on its own
|
|
|
|
|
// rather than needing anything to remember that they did.
|
|
|
|
|
LastViewed time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MediaInfo is everything the detector is told. No item id, no viewer, no reason — a
|
|
|
|
|
// detector that knew why an episode was chosen would be one that could be tuned to agree
|
|
|
|
|
// with the predictor rather than with the media.
|
|
|
|
|
type MediaInfo struct {
|
|
|
|
|
// URL is where the bytes are. The gateway has no filesystem access to the media, so in
|
|
|
|
|
// practice this is Emby's direct stream route and every read of it is a ranged request.
|
|
|
|
|
URL string
|
|
|
|
|
// RuntimeMs is the file's duration as Emby records it. The scan window is derived from
|
|
|
|
|
// it, so a title with no runtime cannot be scanned at all — which is correct: without
|
|
|
|
|
// one there is no tail to seek to.
|
|
|
|
|
RuntimeMs int64
|
|
|
|
|
// Window is where to look. Its provenance rides along because "did season history
|
|
|
|
|
// actually narrow this" is the question the benchmark exists to answer.
|
|
|
|
|
Window ScanWindow
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detection is what a detector came to. A detector that found nothing returns Found false
|
|
|
|
|
// rather than an error: most of a library legitimately has no detectable credits roll, and
|
|
|
|
|
// treating the common case as a failure would fill the log with news of nothing.
|
|
|
|
|
type Detection struct {
|
|
|
|
|
Found bool
|
|
|
|
|
StartMs int64
|
|
|
|
|
Confidence float64
|
|
|
|
|
Method string
|
|
|
|
|
|
|
|
|
|
// Window is where the detector was told to look, carried back out so the benchmark can
|
|
|
|
|
// report whether season history or behaviour actually narrowed anything. Nothing
|
|
|
|
|
// downstream routes on it.
|
|
|
|
|
Window ScanWindow
|
|
|
|
|
|
|
|
|
|
// The accounting the benchmark prints. Zero is a fine value for any of them.
|
|
|
|
|
FramesSampled int
|
|
|
|
|
BytesRead int64
|
|
|
|
|
Elapsed time.Duration
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Marker is what is stored, and it is the only durable output of this whole subsystem.
|
|
|
|
|
//
|
|
|
|
|
// Keyed by item *and* fingerprint: if Sonarr replaces the file, the fingerprint moves, the
|
|
|
|
|
// old row stops matching and the episode becomes a candidate again with no invalidation
|
|
|
|
|
// pass having to notice.
|
|
|
|
|
type Marker struct {
|
|
|
|
|
ItemID string
|
|
|
|
|
MediaFingerprint string
|
|
|
|
|
CreditsStartMs int64
|
|
|
|
|
Confidence float64
|
|
|
|
|
DetectionMethod string
|
|
|
|
|
// SeriesID and Season are stored only so a season's markers can be read back as one
|
|
|
|
|
// indexed query. That read is what narrows the next episode's scan from ten minutes of
|
|
|
|
|
// file to two, which makes them the cheapest two columns in the schema.
|
|
|
|
|
SeriesID string
|
|
|
|
|
Season int
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detector turns a file and a window into a position, or into nothing.
|
|
|
|
|
type Detector interface {
|
|
|
|
|
Detect(ctx context.Context, media MediaInfo) (Detection, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CandidateSource produces the queue's input. Tracearr is the implementation that matters;
|
|
|
|
|
// live playback pushes candidates in directly rather than through this.
|
|
|
|
|
type CandidateSource interface {
|
|
|
|
|
Candidates(ctx context.Context) ([]Candidate, error)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 12:13:51 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 09:23:26 +12:00
|
|
|
// 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.
|
|
|
|
|
type Repository interface {
|
|
|
|
|
// GetMarker answers whether this exact media version has already been decided. It is
|
|
|
|
|
// the first thing every candidate hits and, once a library has settled, is the only
|
|
|
|
|
// thing most of them ever hit.
|
|
|
|
|
GetMarker(ctx context.Context, itemID, fingerprint string) (Marker, bool, error)
|
|
|
|
|
// SaveMarker upserts. Called at most once per scan, and skipped entirely when
|
|
|
|
|
// ShouldRewrite says the new evidence is not worth a write.
|
|
|
|
|
SaveMarker(ctx context.Context, marker Marker) error
|
|
|
|
|
// SeasonMarkers returns what is already known about a season, newest first. This is the
|
|
|
|
|
// single most valuable optimisation in the package: it turns a ten-minute tail scan
|
|
|
|
|
// into a two-minute one.
|
|
|
|
|
SeasonMarkers(ctx context.Context, seriesID string, season int, limit int) ([]Marker, error)
|
|
|
|
|
}
|