From cbf6fb8c5f3d5edb4ce8bcd89666070d0c35c49b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 28 Oct 2026 08:57:32 +0000 Subject: Read the advertising out of an episode's words A second ad detector, and it finds a class the first structurally cannot. The audio scan compares episodes mirrored within a minute of each other, so what it finds is 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 it carries no advertising, and the host-read sponsor block in the middle of it is invisible. Every published transcript the server can use belongs to exactly such a show, because having no inserted advertising is the condition for using one. The two detectors do not merely fail differently; they look at disjoint sets of episodes. **Density, not phrases.** One marker is a mention. Measured against the 38 transcripts harvested from the live library: a single "brought to you by" is a one-second tag inside play-by-play and skipping it would cut the game, and a guest describing how a sponsor came forward for his yacht event says "sponsor" four times while being the programme -- the same trap the toc=false chapter rule already documents. A real read stacks different *kinds* of marker inside a few seconds: who it is for, where to go, what the offer is, what the terms are. Two kinds and fifteen seconds, the same ad unit the length-gap rule uses. Every threshold comes from a measurement rather than a guess. The 25-second bridge is the 17.7-second gap between "presented by Delta" and "12% off" in the Seahawks Rewind read, with the product description in between. "Slash" needs a domain in front of it because the corpus contains "a children's movie slash book for adults". Across all 38 live transcripts it reports **one** candidate: that Delta block, 41:07 to 41:33, with no programme clipped at either end. That is low recall by design -- these are the shows with the least advertising, because they are the only ones whose transcripts can be used at all. The shows with real ad breaks need speech recognition, which is not built. **Nothing is published as a skip.** The candidates are stored with the words and the lines are dimmed on the transcript screen, so the reader can see what the server thinks is an advertisement and disagree with it. Dimmed rather than hidden, because a wrong call has to stay visible. Nothing in the playback path reads them -- same posture as the signature library's shadow run, and the same reason: a detector acting on its own unchecked first guesses makes skips into the middle of the programme, which is worse than no skips. Schema 18 adds the mark to the cue, defaulting to not: a line nobody judged is not an advertisement. Migrated and checked on a device. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../java/org/terst/listen/data/MigrationTest.kt | 53 ++++++++++++++++++ .../main/java/org/terst/listen/data/AppDatabase.kt | 18 ++++++- .../main/java/org/terst/listen/data/Entities.kt | 10 ++++ .../java/org/terst/listen/server/ServerSkips.kt | 15 ++++-- .../java/org/terst/listen/server/SkipServer.kt | 18 +++++++ .../java/org/terst/listen/ui/TranscriptScreen.kt | 16 ++++-- .../org/terst/listen/server/ServerSkipsTest.kt | 63 ++++++++++++++++++++++ 7 files changed, 184 insertions(+), 9 deletions(-) (limited to 'app/src') diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt index 52f07eb..49d3406 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -803,4 +803,57 @@ class MigrationTest { assertEquals("the transcript row outlived its episode", 0, it.getInt(0)) } } + + /** + * Cues gain a mark saying the server read them as advertising, and existing ones default to + * not. A mark on the words, never a range to skip -- nothing in the playback path reads it. + */ + @Test + fun migrate17To18MarksAdvertisingWithoutDisturbingExistingCues() { + helper.createDatabase(name, 17).apply { + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime) + VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0, + 0, 86400000, 1, 1, 1, 0)""" + ) + execSQL( + """INSERT INTO episodes + (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs, + positionMs, downloadState) + VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000, + 161803, 'NONE')""" + ) + execSQL( + "INSERT INTO episode_transcripts (episodeId, source, cues, note, fetchedAtMs) " + + "VALUES ('e1', 'published', 1, '', 99)" + ) + execSQL( + "INSERT INTO transcript_cues (episodeId, startMs, endMs, text) " + + "VALUES ('e1', 0, 3120, 'Welcome back.')" + ) + close() + } + + val db = helper.runMigrationsAndValidate(name, 18, true, AppDatabase.MIGRATION_17_18) + + db.query("SELECT text, advertising FROM transcript_cues WHERE episodeId = 'e1'").use { + it.moveToFirst() + assertEquals("Welcome back.", it.getString(0)) + assertEquals("a line nobody judged is not an advertisement", 0, it.getInt(1)) + } + db.execSQL( + "INSERT INTO transcript_cues (episodeId, startMs, endMs, text, advertising) " + + "VALUES ('e1', 4000, 9000, 'Our sponsor this week.', 1)" + ) + db.query("SELECT advertising FROM transcript_cues WHERE startMs = 4000").use { + it.moveToFirst() + assertEquals(1, it.getInt(0)) + } + db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { + it.moveToFirst() + assertEquals("the crown jewel must survive", 161803L, it.getLong(0)) + } + } } diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 96a25b9..5b9219d 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -34,7 +34,7 @@ class Converters { EpisodeTranscriptEntity::class, TranscriptCueEntity::class, ], - version = 17, + version = 18, exportSchema = true, ) @TypeConverters(Converters::class) @@ -469,6 +469,21 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * A cue gains a mark saying the server read it as advertising. + * + * A mark on the words, not a range to skip. Existing cues default to not: a line + * nobody has judged is not an advertisement, which is the same three-state care the + * transcript verdict itself takes. + */ + val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `transcript_cues` ADD COLUMN `advertising` INTEGER NOT NULL DEFAULT 0" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -482,6 +497,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, + MIGRATION_17_18, ) .addCallback(SEED_DEFAULT_FILTERS) // No fallbackToDestructiveMigration. A listener's playback positions and play diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt index 2cfd15f..5a81e5d 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -629,4 +629,14 @@ data class TranscriptCueEntity( val startMs: Long, val endMs: Long, val text: String, + /** + * Whether the server read this line as part of an advertisement. + * + * A mark on the words, not a range to skip. It is stored on the cue rather than as its own + * table because it is a property of the line as displayed, and the only thing that reads it + * is the screen drawing that line. Nothing in the playback path sees it, deliberately: the + * detector that produced it has never been checked against anything, and a skip into the + * middle of the programme is worse than no skip. + */ + @ColumnInfo(defaultValue = "0") val advertising: Boolean = false, ) diff --git a/app/src/main/java/org/terst/listen/server/ServerSkips.kt b/app/src/main/java/org/terst/listen/server/ServerSkips.kt index d7fb4f5..8dcd115 100644 --- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt +++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt @@ -107,12 +107,19 @@ class ServerSkips( note = "", fetchedAtMs = System.currentTimeMillis(), ), - fetched.cues.map { + fetched.cues.map { cue -> TranscriptCueEntity( episodeId = episode.id, - startMs = it.startMs, - endMs = it.endMs, - text = it.text, + startMs = cue.startMs, + endMs = cue.endMs, + text = cue.text, + // Flattened onto the cue here rather than kept as ranges. The only thing + // that reads it is the screen drawing that line, and a range the playback + // path could reach is a skip waiting to be made out of a guess that has + // never been checked against anything. + advertising = fetched.ads.any { ad -> + cue.startMs >= ad.startMs && cue.endMs <= ad.endMs + }, ) }, ) diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt index 0980aff..a05eaf7 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -97,12 +97,29 @@ data class ServerCue( val text: String = "", ) +/** + * A stretch of an episode whose words read as advertising. + * + * A candidate, never a skip. It is shown beside the transcript so the reader can see what the + * server thinks is an advertisement, and nothing in the playback path reads it -- the same + * posture the signature library's shadow run takes, because a detector acting on its own first + * guesses makes skips into the middle of the programme. + */ +@Serializable +data class ServerAdCandidate( + @SerialName("startMs") val startMs: Long = 0, + @SerialName("endMs") val endMs: Long = 0, + /** Which kinds of marker qualified it: sponsor, code, url, offer, legal. */ + val signals: List = emptyList(), +) + /** An episode's words as the server holds them. */ @Serializable data class ServerTranscript( val source: String = "", @SerialName("languageCode") val languageCode: String? = null, val cues: List = emptyList(), + val ads: List = emptyList(), ) /** What the server knows about one episode. */ @@ -322,6 +339,7 @@ class SkipServer( // to seek to. Same filtering the breaks get, because the player acts on these // numbers too -- a tap on a cue seeks to its start. cues = parsed.cues.filter { it.endMs > it.startMs && it.text.isNotBlank() }, + ads = parsed.ads.filter { it.endMs > it.startMs }, ) } diff --git a/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt index c6d6e52..81c1b84 100644 --- a/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt @@ -100,6 +100,9 @@ fun TranscriptScreen( } } +/** Advertising is dimmed, not hidden: a wrong call has to stay visible to be disagreed with. */ +private const val ADVERTISING_ALPHA = 0.45f + @Composable internal fun CueList( cues: List, @@ -119,10 +122,15 @@ internal fun CueList( cue.text, style = MaterialTheme.typography.bodyMedium, fontWeight = if (speaking) FontWeight.Bold else FontWeight.Normal, - color = if (speaking) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant + // Advertising is dimmed rather than hidden or struck through. The reader can + // see what the server thinks is an advertisement and disagree with it, which + // matters because nothing has ever checked this detector against anything -- + // and hiding the lines would make a wrong call invisible. + color = when { + cue.advertising -> MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = ADVERTISING_ALPHA) + speaking -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant }, modifier = Modifier .fillMaxWidth() diff --git a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt index ae6a23b..3b0d671 100644 --- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt +++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt @@ -453,4 +453,67 @@ class ServerSkipsTest { assertEquals(1, transcripts.cueRows.size) assertEquals(1, transcripts.rows.size) } + + /** + * Advertising the server read in the words is marked on the lines it covers. + * + * Flattened onto the cue rather than stored as ranges, deliberately. The only thing that + * reads it is the screen drawing that line; a range the playback path could reach would be + * a skip made out of a guess nothing has ever checked. + */ + @Test + fun `marks the lines the server read as advertising`() = runTest { + val transcripts = FakeTranscriptDao() + val transport = StubTransport( + null, + alsoServing = mapOf("api/transcript" to """ + {"source":"published","cues":[ + {"startMs":0,"endMs":3000,"text":"Back in a moment."}, + {"startMs":10000,"endMs":15000,"text":"Sponsored by a mattress company."}, + {"startMs":15000,"endMs":26000,"text":"Use promo code LISTEN for 20% off."}, + {"startMs":30000,"endMs":34000,"text":"Right, where were we?"}], + "ads":[{"startMs":10000,"endMs":26000,"signals":["sponsor","code"]}]} + """.trimIndent()), + ) + + subject(transport, Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + source = "published", cues = 4, + url = "https://listen.terst.org/api/transcript?episode=abc", + ), + ), + ) + + val marked = transcripts.cueRows.filter { it.advertising }.map { it.startMs } + assertEquals(listOf(10_000L, 15_000L), marked.sorted()) + } + + /** No candidates means no marks, rather than everything or nothing. */ + @Test + fun `leaves every line unmarked when the server found no advertising`() = runTest { + val transcripts = FakeTranscriptDao() + val transport = StubTransport( + null, + alsoServing = mapOf("api/transcript" to + """{"source":"asr","cues":[{"startMs":0,"endMs":3000,"text":"Only this."}]}"""), + ) + + subject(transport, Fakes(transcripts = transcripts)).record( + episode, + ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, + transcript = ServerTranscriptNote( + source = "asr", cues = 1, + url = "https://listen.terst.org/api/transcript?episode=abc", + ), + ), + ) + + assertEquals(emptyList(), transcripts.cueRows.filter { it.advertising }.map { it.startMs }) + } } -- cgit v1.2.3