Files
memby/server/internal/credits/credits.go
2026-08-16 12:13:51 +12:00

225 lines
9.9 KiB
Go

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