// Package skips turns a show's recent episode files into per-episode skip offsets. package skips import ( "context" "fmt" "github.com/terst/listen/server/internal/decode" "github.com/terst/listen/server/internal/detect" ) // Config is what a detection run searches and what it will trust. type Config struct { // ClipSeconds is the window searched at each end of an episode. // // Long enough to contain a pre-roll plus the show's intro behind it, because the pre-roll // varies per fetch: measured on four real Up First episodes the intro landed anywhere from // 44 to 94 seconds in, so a window that only just fits an intro would sometimes miss it. ClipSeconds int // ReferenceSeconds is the length of segment looked for -- long enough to be a sting, short // enough not to swallow the programme behind it. ReferenceSeconds int // ConfirmThreshold is how many independently-fetched episodes must agree before an offset // is trusted. See DefaultConfirmThreshold: this is the safety mechanism, not a knob. ConfirmThreshold int // Tuning is what the search within those windows is allowed to cost. Tuning detect.Tuning // SelfCheckMinScore is how closely the located segments must agree with *each other* // before offsets are published. See SelfCheck for why that is a different question from // the detection threshold, and a necessary one. SelfCheckMinScore float64 } // DefaultClipSeconds and friends are the production values, validated against real shows. const ( DefaultClipSeconds = 240 DefaultReferenceSeconds = 8 // DefaultConfirmThreshold is three, and lowering it defeats the feature. // // Two episodes fetched close together can agree on a segment that turns out to be a // dynamically-inserted ad rather than the show's intro -- found prototyping against real // Darknet Diaries episodes, where a years-old archived episode shared the same "matching" // segment while two other recent ones shared nothing. A third genuinely independent // episode is very unlikely to be assigned the same rotating ad. DefaultConfirmThreshold = 3 // DefaultSelfCheckMinScore is how well two located segments must agree to be accepted as // the same audio. Higher than the detection threshold on purpose: clearing 0.6 against a // reference is a weaker claim than being the same recording as another episode's segment, // and real matches between separately-encoded episodes measure above 0.9. DefaultSelfCheckMinScore = 0.8 ) // DefaultConfig is the production configuration. func DefaultConfig() Config { return Config{ ClipSeconds: DefaultClipSeconds, ReferenceSeconds: DefaultReferenceSeconds, ConfirmThreshold: DefaultConfirmThreshold, Tuning: detect.DefaultTuning(), SelfCheckMinScore: DefaultSelfCheckMinScore, } } // Decoder is the audio decoding this package needs, behind an interface so the orchestration is // testable without ffmpeg or real audio. type Decoder interface { // Range decodes durationMs of mono audio from startMs. durationMs of 0 means to the end. Range(ctx context.Context, path string, startMs, durationMs int64) (*decode.Clip, error) // DurationMs is the file's real length, which is routinely not what its feed claims. DurationMs(ctx context.Context, path string) (int64, error) } // Episode is one episode's audio on disk. type Episode struct { ID string Path string // DeclaredDurationMs is what the feed claims the episode is. // // Carried because the difference between it and the file's real length is how much // advertising was inserted -- a measure that comes from the feed and the file rather than // from the detector, and so the only number here that can contradict a scan. DeclaredDurationMs int64 } // Offsets is what playback needs for one episode: how much to skip at each end. // // Both are measured in that episode's own timeline, because that is the only frame of reference // that survives dynamic ad insertion -- the same episode fetched twice has its intro at // different offsets in the two files. type Offsets struct { EpisodeID string // IntroMs is where the recurring intro ends, or nil if none was confirmed. IntroMs *int64 // OutroMs is how long before the end the recurring outro starts, or nil. OutroMs *int64 } // Result is a show's detection outcome. type Result struct { Offsets []Offsets // IntroConfirmedOn and OutroConfirmedOn are how many episodes agreed, for reporting. IntroConfirmedOn int OutroConfirmedOn int // IntroCheck and OutroCheck record whether the located segments agreed with each other. // Offsets are only published when they did. IntroCheck CheckReport OutroCheck CheckReport } // Detect finds a show's recurring intro and outro across episodes and reports per-episode // offsets. // // Every episode is examined in one pass, which is the whole reason this runs on a server: on the // phone only one episode's clip fitted in memory at a time, so the same question needed a // rolling state machine that accumulated agreement over weeks and lost its progress whenever // retention reclaimed the file it was holding. func Detect(ctx context.Context, d Decoder, episodes []Episode, cfg Config) (*Result, error) { if cfg.ConfirmThreshold < 1 || len(episodes) < cfg.ConfirmThreshold { return &Result{Offsets: emptyOffsets(episodes)}, nil } durations := make(map[string]int64, len(episodes)) for _, ep := range episodes { ms, err := d.DurationMs(ctx, ep.Path) if err != nil { return nil, fmt.Errorf("episode %s: %w", ep.ID, err) } durations[ep.ID] = ms } heads, err := decodeWindows(ctx, d, episodes, durations, cfg.ClipSeconds, false) if err != nil { return nil, err } tails, err := decodeWindows(ctx, d, episodes, durations, cfg.ClipSeconds, true) if err != nil { return nil, err } referenceLen := cfg.ReferenceSeconds * sampleRateOf(heads) intro := detect.DetectRecurring(heads, referenceLen, cfg.ConfirmThreshold, cfg.Tuning) outro := detect.DetectRecurring(tails, referenceLen, cfg.ConfirmThreshold, cfg.Tuning) result := &Result{Offsets: emptyOffsets(episodes)} byID := map[string]int{} for i, o := range result.Offsets { byID[o.EpisodeID] = i } if intro != nil { result.IntroConfirmedOn = len(intro.Occurrences) result.IntroCheck = SelfCheck(intro, heads, cfg.SelfCheckMinScore) } if intro != nil && result.IntroCheck.OK { for _, occ := range intro.Occurrences { clip := clipFor(heads, occ.EpisodeID) if clip == nil { continue } // Where the intro *ends*: playback starts there, so the sting is skipped and // nothing after it is. endMs := clip.StartMs + samplesToMs(occ.Position+len(intro.Reference), clip.SampleRate) ms := endMs result.Offsets[byID[occ.EpisodeID]].IntroMs = &ms } } if outro != nil { result.OutroConfirmedOn = len(outro.Occurrences) result.OutroCheck = SelfCheck(outro, tails, cfg.SelfCheckMinScore) } if outro != nil && result.OutroCheck.OK { for _, occ := range outro.Occurrences { clip := clipFor(tails, occ.EpisodeID) if clip == nil { continue } // An outro offset is measured back from the end of the episode, which is how // playback uses it: the episode finishes when the playhead reaches that point. startMs := clip.StartMs + samplesToMs(occ.Position, clip.SampleRate) fromEnd := durations[occ.EpisodeID] - startMs if fromEnd < 0 { fromEnd = 0 } ms := fromEnd result.Offsets[byID[occ.EpisodeID]].OutroMs = &ms } } return result, nil } // decodeWindows decodes the head or tail window of every episode. func decodeWindows( ctx context.Context, d Decoder, episodes []Episode, durations map[string]int64, clipSeconds int, tail bool, ) ([]detect.Clip, error) { windowMs := int64(clipSeconds) * 1000 clips := make([]detect.Clip, 0, len(episodes)) for _, ep := range episodes { start := int64(0) if tail { start = durations[ep.ID] - windowMs if start < 0 { start = 0 } } clip, err := d.Range(ctx, ep.Path, start, windowMs) if err != nil { return nil, fmt.Errorf("episode %s: %w", ep.ID, err) } clips = append(clips, detect.Clip{ EpisodeID: ep.ID, Samples: clip.Samples, SampleRate: clip.SampleRate, StartMs: clip.StartMs, }) } return clips, nil } func emptyOffsets(episodes []Episode) []Offsets { out := make([]Offsets, len(episodes)) for i, ep := range episodes { out[i] = Offsets{EpisodeID: ep.ID} } return out } func clipFor(clips []detect.Clip, episodeID string) *detect.Clip { for i := range clips { if clips[i].EpisodeID == episodeID { return &clips[i] } } return nil } func sampleRateOf(clips []detect.Clip) int { if len(clips) == 0 { return 0 } return clips[0].SampleRate } func samplesToMs(samples, sampleRate int) int64 { if sampleRate == 0 { return 0 } return int64(samples) * 1000 / int64(sampleRate) }