diff options
Diffstat (limited to 'app/src')
7 files changed, 184 insertions, 9 deletions
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<String> = 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<ServerCue> = emptyList(), + val ads: List<ServerAdCandidate> = 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<TranscriptCueEntity>, @@ -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<Long>(), transcripts.cueRows.filter { it.advertising }.map { it.startMs }) + } } |
