Files

187 lines
7.4 KiB
Go
Raw Permalink Normal View History

2026-08-15 09:23:26 +12:00
package credits
import (
"sort"
"time"
)
// What the household already told us, without anything having to read a media file.
//
// This is the cheapest evidence in the package and on a well-watched show it is also the
// best. Where three people independently stopped an episode within a few seconds of each
// other, near the end but not at it, they stopped for a reason, and the reason is that the
// story finished and the credits started. Tracearr has recorded every one of those stops
// already — so this costs one indexed query, no file access, and no new writes.
const (
// behaviourFloor is how far into the file a stop has to be before it says anything about
// credits. The same three-quarters the television's own chapter-name rule uses, and for
// the same reason: somebody abandoning an episode twenty minutes in is telling us they
// did not like it, not where the credits are.
behaviourFloor = 0.75
// behaviourTailGuard is how close to the end counts as "watched it out". Those sessions
// are the majority and they carry no positional information at all — a viewer who sat
// through the credits stopped at the end of the file, wherever the credits began.
behaviourTailGuard = 15 * time.Second
// behaviourCluster is how far apart two stops can be and still be the same moment.
// Players report progress on a timer, so two people leaving at the same cut are
// routinely a few seconds apart in the record.
behaviourCluster = 30 * time.Second
// BehaviourMinUsers is what it takes to write a marker on behaviour alone. Three
// independent people agreeing to within half a minute is not a coincidence; two might
// be a couple watching one television twice, or one person on two devices.
BehaviourMinUsers = 3
// behaviourNarrowMinUsers is the weaker bar for merely *narrowing* a scan. Being wrong
// about where to look costs a wider scan; being wrong about a marker costs somebody the
// end of the episode, so the two bars are deliberately different.
behaviourNarrowMinUsers = 2
)
// StopEvent is one viewer leaving one episode, reduced to what matters. Built from Tracearr
// sessions that are already in Postgres — this subsystem records nothing of its own.
type StopEvent struct {
UserKey string
PositionMs int64
RuntimeMs int64
// NextEpisode marks a stop that ran straight into the following episode. It is the
// strongest form of this signal: somebody who pressed next was unambiguously looking at
// credits rather than deciding to go to bed.
NextEpisode bool
}
// BehaviourEvidence is what a set of stops came to.
type BehaviourEvidence struct {
Found bool
StartMs int64
// UserCount is distinct viewers in the winning cluster, which is the whole strength of
// the finding: the same person stopping in the same place four times is one observation.
UserCount int
// NextEpisodeCount is how many of them rolled into the next episode.
NextEpisodeCount int
// SpreadMs is how tightly they agreed. A cluster three seconds wide is worth more than
// one twenty-eight seconds wide, and confidence reads it.
SpreadMs int64
}
// Usable means this is good enough to steer a scan.
func (e BehaviourEvidence) Usable() bool {
return e.Found && e.UserCount >= behaviourNarrowMinUsers && e.StartMs > 0
}
// StandaloneMarker means this is good enough to *be* a marker, with no media read at all.
// The bar is higher than Usable's, and a next-episode transition is what lets a two-viewer
// cluster clear it: pressing next is an explicit statement that the episode had ended.
func (e BehaviourEvidence) StandaloneMarker() bool {
if !e.Found || e.StartMs <= 0 {
return false
}
if e.UserCount >= BehaviourMinUsers {
return true
}
return e.UserCount >= behaviourNarrowMinUsers && e.NextEpisodeCount >= e.UserCount
}
// AnalyseStops finds the moment a household agrees an episode ends.
//
// A sliding cluster rather than an average of everything: the tail of an episode contains
// two quite different populations — people who left at the credits and people who left part
// way through them — and averaging the two produces a position that is inside the credits
// but later than they began, which is the one failure mode that shows a Skip Credits button
// too late to be useful. The tightest agreement is the transition; the stragglers are noise.
func AnalyseStops(stops []StopEvent, runtimeMs int64) BehaviourEvidence {
if runtimeMs <= 0 || len(stops) == 0 {
return BehaviourEvidence{}
}
floor := int64(float64(runtimeMs) * behaviourFloor)
ceiling := runtimeMs - behaviourTailGuard.Milliseconds()
if ceiling <= floor {
return BehaviourEvidence{}
}
// One observation per viewer: the earliest qualifying stop they made. Somebody who
// stopped at the credits, resumed, and stopped again at the very end has told us where
// the credits were exactly once.
earliest := map[string]StopEvent{}
for _, stop := range stops {
if stop.PositionMs < floor || stop.PositionMs > ceiling {
continue
}
previous, seen := earliest[stop.UserKey]
if !seen || stop.PositionMs < previous.PositionMs {
// A next-episode transition is never downgraded by a later plain stop from the
// same viewer, but an earlier plain stop still wins on position.
stop.NextEpisode = stop.NextEpisode || (seen && previous.NextEpisode &&
previous.PositionMs == stop.PositionMs)
earliest[stop.UserKey] = stop
} else if stop.NextEpisode && previous.PositionMs-stop.PositionMs <= 0 {
previous.NextEpisode = true
earliest[stop.UserKey] = previous
}
}
if len(earliest) < behaviourNarrowMinUsers {
return BehaviourEvidence{}
}
observations := make([]StopEvent, 0, len(earliest))
for _, stop := range earliest {
observations = append(observations, stop)
}
sort.Slice(observations, func(a, b int) bool {
return observations[a].PositionMs < observations[b].PositionMs
})
// The widest cluster wins, and the earliest of equally wide ones — the credits began at
// the first moment the household agreed on, not the last.
tolerance := behaviourCluster.Milliseconds()
best := BehaviourEvidence{}
for start := range observations {
end := start
for end+1 < len(observations) &&
observations[end+1].PositionMs-observations[start].PositionMs <= tolerance {
end++
}
count := end - start + 1
if count < behaviourNarrowMinUsers || count < best.UserCount {
continue
}
spread := observations[end].PositionMs - observations[start].PositionMs
if count == best.UserCount && spread >= best.SpreadMs {
continue
}
nextCount := 0
for _, stop := range observations[start : end+1] {
if stop.NextEpisode {
nextCount++
}
}
best = BehaviourEvidence{
Found: true,
StartMs: medianPosition(observations[start : end+1]),
UserCount: count,
NextEpisodeCount: nextCount,
SpreadMs: spread,
}
}
return best
}
// medianPosition is the middle of a cluster. The median rather than the mean for the reason
// the season estimate uses one: a single straggler inside the tolerance must not move the
// answer, and with an even count the lower middle is taken because looking slightly early is
// the harmless direction to be wrong in.
func medianPosition(stops []StopEvent) int64 {
if len(stops) == 0 {
return 0
}
positions := make([]int64, 0, len(stops))
for _, stop := range stops {
positions = append(positions, stop.PositionMs)
}
sort.Slice(positions, func(a, b int) bool { return positions[a] < positions[b] })
return positions[(len(positions)-1)/2]
}