187 lines
7.4 KiB
Go
187 lines
7.4 KiB
Go
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]
|
||
|
|
}
|