0.2.64 update
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user