// 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) }