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] }