package transcript import ( "regexp" "strings" "github.com/terst/listen/server/internal/advertising" ) /* Advertising read out of an episode's words. A second detector, and it finds a class the audio one structurally cannot. The audio scan compares episodes mirrored within a minute of each other, so what it finds is the advertising a CDN stitched into all of them -- validated against the declared-versus-served length gap. A show that inserts nothing has no gap, which by that measure means no advertising, and the host-read sponsor block in the middle of it is invisible. Every published transcript this server can use belongs to exactly such a show, because having no inserted advertising is the condition for using one. So the two detectors do not merely fail differently; they look at disjoint sets of episodes. Nothing here is published as a skip. These are candidates, recorded and reported, and the listener's labels are what can confirm them -- the same posture the signature library's shadow run takes, and for the same reason: a detector that acts on its own first guesses makes untrustworthy skips, and a skip into the middle of the programme is worse than no skip. */ // Candidate is a stretch of an episode whose words read as advertising. type Candidate struct { StartMs int64 `json:"startMs"` EndMs int64 `json:"endMs"` // Signals are the kinds of marker that qualified it, so the candidate can be weighed // rather than taken on faith -- the same reason a published break carries its partner count. Signals []string `json:"signals"` } const ( // bridgeMs is how far apart two markers may be and still belong to one read. Measured on // the Seahawks Rewind block, where "presented by" and "12% off" are 17.7 seconds apart // with the product description between them; 25 gives that room without reaching the // programme on either side. bridgeMs = 25_000 // minCandidateMs is the shortest thing worth calling an advertisement. One advertising slot: // anything briefer is a mention rather than a break, and skipping it would clip the show. minCandidateMs = advertising.UnitMs // minKinds is how many different kinds of marker must appear. Two, because one kind // repeated says one thing: a host saying "sponsor" four times while describing a // sponsorship deal is the programme, which is the same trap the toc=false chapter rule // documents. Real examples of both are in the tests. minKinds = 2 ) // marker is one kind of advertising language. Kind rather than phrase, so several wordings of // the same thing -- "sponsored by", "brought to you by", "presented by" -- count once. type marker struct { kind string pattern *regexp.Regexp } var markers = []marker{ {"sponsor", regexp.MustCompile( `sponsored by|brought to you by|presented by|our sponsor|today's sponsor|this episode is sponsored`)}, {"code", regexp.MustCompile( `promo code|promotional code|use code|discount code|code [a-z0-9]+ at checkout|at checkout`)}, // "dot com" rather than ".com" because this is spoken text: a transcript writes the URL // the way it was said. The campaign path has to follow a domain -- a bare "slash" matches // ordinary speech, and the corpus has "a children's movie slash book for adults" to prove // it. {"url", regexp.MustCompile( `dot com|dot co dot uk|\.com[/ ]|(dot com|\.com) slash |visit [a-z0-9]+ dot `)}, {"offer", regexp.MustCompile( `\d+% off|\d+ percent off|free trial|first month free|save up to|limited time|` + `for a limited|your first order|sign up today|enroll in`)}, {"legal", regexp.MustCompile( `terms and conditions|terms apply|restrictions apply|see site for details|while supplies last`)}, } // Spot reads an episode's words and reports the stretches that sound like advertising. // // Density rather than any single phrase. One marker is a mention -- a show is routinely // "brought to you by" somebody in a single line of play-by-play, and a guest describing a // sponsorship deal says "sponsor" repeatedly -- and both of those are the programme. A real // read stacks different kinds of marker within a few seconds: who it is for, where to go, what // the offer is, and what the terms are. func Spot(cues []Cue) []Candidate { type hit struct { cue Cue kinds []string } var hits []hit for _, cue := range cues { if kinds := kindsIn(cue.Text); len(kinds) > 0 { hits = append(hits, hit{cue: cue, kinds: kinds}) } } var found []Candidate for start := 0; start < len(hits); { end := start // Grow the run while the next marker is near enough to belong to the same read. for end+1 < len(hits) && hits[end+1].cue.StartMs-hits[end].cue.EndMs <= bridgeMs { end++ } kinds := map[string]bool{} for _, h := range hits[start : end+1] { for _, k := range h.kinds { kinds[k] = true } } candidate := Candidate{ StartMs: hits[start].cue.StartMs, EndMs: hits[end].cue.EndMs, Signals: sortedKinds(kinds), } if len(kinds) >= minKinds && candidate.EndMs-candidate.StartMs >= minCandidateMs { found = append(found, candidate) } start = end + 1 } return found } func kindsIn(text string) []string { lower := strings.ToLower(text) var kinds []string for _, m := range markers { if m.pattern.MatchString(lower) { kinds = append(kinds, m.kind) } } return kinds } // sortedKinds returns the kinds in the fixed order markers are declared in, so a candidate // reads the same way every time it is reported. func sortedKinds(present map[string]bool) []string { var out []string for _, m := range markers { if present[m.kind] { out = append(out, m.kind) } } return out }