summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt53
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt18
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt10
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt15
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt18
-rw-r--r--app/src/main/java/org/terst/listen/ui/TranscriptScreen.kt16
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt63
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 })
+ }
}