summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt11
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt2
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt34
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt46
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt4
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt77
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt79
7 files changed, 252 insertions, 1 deletions
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 2ca227c..8dbaa32 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -386,6 +386,17 @@ interface MidrollDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRange(range: EpisodeMidrollEntity)
+ /**
+ * Drops every detected range for one episode, so a fresh answer replaces the previous one
+ * rather than adding to it.
+ *
+ * The skip server is the authority on what an episode's ad breaks are, and advertising
+ * rotates between fetches: left to accumulate, the phone would go on skipping stretches
+ * that are no longer advertisements.
+ */
+ @Query("DELETE FROM episode_midrolls WHERE episodeId = :episodeId")
+ suspend fun deleteRanges(episodeId: String)
+
/** Downloaded episodes of this show [MidrollDetector][org.terst.listen.fingerprint.MidrollDetector]
* has never looked at, oldest download first -- so a backlog of downloads is worked through
* in the order they actually landed, not newest-first forever leaving the rest unchecked. */
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index e425ccf..07ffab2 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -134,7 +134,7 @@ class AppGraph(private val context: Context) {
episodes,
libraryDir,
ChaquopyAudioSource(context),
- serverAudio = ServerSkips(skipServer, subscriptions, fingerprints, playbackState),
+ serverAudio = ServerSkips(skipServer, subscriptions, fingerprints, playbackState, midrolls),
)
}
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 edff77b..ed70d07 100644
--- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt
+++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
@@ -1,6 +1,8 @@
package org.terst.listen.server
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.MidrollDao
import org.terst.listen.data.PlaybackStateDao
import org.terst.listen.data.ShowFingerprintDao
import org.terst.listen.data.SubscriptionDao
@@ -18,6 +20,7 @@ class ServerSkips(
private val subscriptions: SubscriptionDao,
private val fingerprints: ShowFingerprintDao,
private val playbackState: PlaybackStateDao,
+ private val midrolls: MidrollDao,
) : ServerAudio {
/**
@@ -52,5 +55,36 @@ class ServerSkips(
// dynamic ad insertion, and this is the number that places the outro offset and decides
// when an episode counts as finished.
info.durationMs?.let { playbackState.updateDuration(episode.id, it) }
+
+ recordBreaks(episode, info)
+ }
+
+ /**
+ * Stores the ad breaks as skip ranges, replacing whatever was there.
+ *
+ * Into `episode_midrolls`, which [org.terst.listen.playback.MidrollSkip] and
+ * [org.terst.listen.playback.PlayheadPolicy] have read since the on-device detector existed
+ * -- so playback needs no changes at all and there is no migration. Only the origin of the
+ * ranges has moved.
+ *
+ * Replaced rather than merged because the server is the authority and advertising rotates
+ * between fetches: accumulating them would leave the phone skipping stretches that are no
+ * longer advertisements.
+ */
+ private suspend fun recordBreaks(episode: EpisodeEntity, info: ServerEpisode) {
+ midrolls.deleteRanges(episode.id)
+ info.breaks.forEachIndexed { index, range ->
+ midrolls.upsertRange(
+ EpisodeMidrollEntity(
+ episodeId = episode.id,
+ // The server does not name its breaks, and nothing on the phone needs it
+ // to: the id only has to be stable within an episode so a replacement
+ // overwrites rather than duplicates.
+ candidateId = "server-$index",
+ startMs = range.startMs,
+ endMs = range.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 a480fb5..2e013de 100644
--- a/app/src/main/java/org/terst/listen/server/SkipServer.kt
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -23,6 +23,14 @@ interface SkipTransport {
suspend fun post(url: String, token: String, body: String): Boolean
}
+/** One stretch of an episode to skip. Deserialised straight off the wire -- the shape the
+ * server sends and the shape the app wants are the same two numbers. */
+@Serializable
+data class ServerBreak(
+ @SerialName("startMs") val startMs: Long = 0,
+ @SerialName("endMs") val endMs: Long = 0,
+)
+
/** What the server knows about one episode. */
data class ServerEpisode(
/** Whether the audio can be played from the server now. */
@@ -39,6 +47,32 @@ data class ServerEpisode(
* found yet" is the mistake the app's own readout used to make.
*/
val detected: Boolean,
+ /**
+ * The advertising this episode shares with the others mirrored alongside it.
+ *
+ * The thing the server detects reliably, and the reason intro offsets are no longer
+ * published: a segment "confirmed" on 8 of 8 episodes turned out to be an advertisement,
+ * proved by searching for it in an ad-free copy of the same episode, where it scored
+ * r = 0.0621. Mirroring eight episodes in one minute makes them share whatever campaign is
+ * running, which destroys the independence intro detection needs -- and makes ad detection
+ * work.
+ */
+ val breaks: List<ServerBreak> = emptyList(),
+ /**
+ * What the scan accounted for, against what the feed's declared length implies is there.
+ *
+ * The second figure comes from the feed and the file rather than from the detector, so it
+ * is the only number able to contradict a scan. Measured on Radiolab: 53:29 found against
+ * 59:39 implied.
+ */
+ val foundAdMs: Long? = null,
+ val insertedAdMs: Long? = null,
+ /**
+ * Matches were found but were too extensive to be advertising, so none were published.
+ * Distinct from nothing having matched, and kept distinct because collapsing those two is
+ * what made the app's skip readout useless.
+ */
+ val adsRefused: Boolean = false,
)
/**
@@ -111,6 +145,13 @@ class SkipServer(
introMs = parsed.introMs,
outroMs = parsed.outroMs,
detected = parsed.detected,
+ // A zero-length or inverted range would be a skip to nowhere, or backwards:
+ // playback seeks to a break's end, so an inverted one would move the playhead back
+ // and the episode would never finish.
+ breaks = parsed.breaks.filter { it.endMs > it.startMs },
+ foundAdMs = parsed.foundAdMs,
+ insertedAdMs = parsed.insertedAdMs,
+ adsRefused = parsed.adsRefused,
)
}
@@ -131,8 +172,13 @@ class SkipServer(
@SerialName("introMs") val introMs: Long? = null,
@SerialName("outroMs") val outroMs: Long? = null,
val detected: Boolean = false,
+ val breaks: List<ServerBreak> = emptyList(),
+ @SerialName("foundAdMs") val foundAdMs: Long? = null,
+ @SerialName("insertedAdMs") val insertedAdMs: Long? = null,
+ @SerialName("adsRefused") val adsRefused: Boolean = false,
)
+
private companion object {
/** Tolerant of fields this app does not know about, so the server can grow. */
val lenient = Json { ignoreUnknownKeys = true; isLenient = true }
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index 14d1394..a1dab05 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -345,6 +345,10 @@ class FakeMidrollDao : MidrollDao {
rangeRows += range
}
+ override suspend fun deleteRanges(episodeId: String) {
+ rangeRows.removeAll { it.episodeId == episodeId }
+ }
+
override suspend fun getUncheckedDownloaded(subscriptionId: String, downloaded: DownloadState) =
episodeRows.filter {
it.subscriptionId == subscriptionId && it.downloadState == downloaded && it.midrollCheckedAtMs == null
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 57c03f3..11bba35 100644
--- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
+++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
@@ -7,6 +7,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeMidrollDao
import org.terst.listen.data.FakePlaybackStateDao
import org.terst.listen.data.FakeShowFingerprintDao
import org.terst.listen.data.FakeSubscriptionDao
@@ -53,6 +54,7 @@ class ServerSkipsTest {
subscriptions: FakeSubscriptionDao = FakeSubscriptionDao().apply { rows += show },
episodes: FakeEpisodeDao = FakeEpisodeDao().apply { rows += episode },
fingerprints: FakeShowFingerprintDao = FakeShowFingerprintDao(),
+ midrolls: FakeMidrollDao = FakeMidrollDao(),
): ServerSkips {
// Both fakes write through to the same episode rows, the way the real foreign table
// does, so a test sees the value where production would put it.
@@ -62,6 +64,7 @@ class ServerSkipsTest {
subscriptions,
fingerprints,
FakePlaybackStateDao(episodes.rows),
+ midrolls,
)
}
@@ -152,4 +155,78 @@ class ServerSkipsTest {
assertEquals(0L, episodes.rows.single { it.id == "e1" }.detectedIntroMs)
}
+
+ /*
+ * Ad breaks go into the table playback already reads.
+ *
+ * MidrollSkip and PlayheadPolicy have consumed episode_midrolls since the on-device
+ * detector existed, so nothing in playback changes -- only where the ranges come from. That
+ * is also why there is no migration: the columns and the table were already there.
+ */
+ @Test
+ fun `records the ad breaks as skip ranges`() = runTest {
+ val midrolls = FakeMidrollDao()
+
+ subject(StubTransport(null), midrolls = midrolls).record(
+ episode,
+ ServerEpisode(
+ ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null,
+ detected = true,
+ breaks = listOf(ServerBreak(0, 142_000), ServerBreak(900_000, 1_020_000)),
+ ),
+ )
+
+ val ranges = midrolls.rangeRows.filter { it.episodeId == "e1" }.sortedBy { it.startMs }
+ assertEquals(2, ranges.size)
+ assertEquals(0L, ranges[0].startMs)
+ assertEquals(142_000L, ranges[0].endMs)
+ assertEquals(900_000L, ranges[1].startMs)
+ assertEquals(1_020_000L, ranges[1].endMs)
+ }
+
+ /**
+ * The server is the authority on an episode's breaks, so a fresh answer replaces the old
+ * one. Left to accumulate, a campaign rotating between fetches would leave the phone
+ * skipping stretches that are no longer advertising.
+ */
+ @Test
+ fun `replaces the previous breaks rather than adding to them`() = runTest {
+ val midrolls = FakeMidrollDao()
+ val skips = subject(StubTransport(null), midrolls = midrolls)
+
+ skips.record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = null,
+ outroMs = null, detected = true, breaks = listOf(ServerBreak(0, 60_000))),
+ )
+ skips.record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = null,
+ outroMs = null, detected = true, breaks = listOf(ServerBreak(10_000, 70_000))),
+ )
+
+ val ranges = midrolls.rangeRows.filter { it.episodeId == "e1" }
+ assertEquals(1, ranges.size)
+ assertEquals(10_000L, ranges.single().startMs)
+ }
+
+ /** An episode the server published no breaks for must not keep stale ones. */
+ @Test
+ fun `clears the breaks when the server published none`() = runTest {
+ val midrolls = FakeMidrollDao()
+ val skips = subject(StubTransport(null), midrolls = midrolls)
+
+ skips.record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = null,
+ outroMs = null, detected = true, breaks = listOf(ServerBreak(0, 60_000))),
+ )
+ skips.record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null, introMs = null,
+ outroMs = null, detected = true, breaks = emptyList()),
+ )
+
+ assertTrue(midrolls.rangeRows.none { it.episodeId == "e1" })
+ }
}
diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
index bdf9d6a..320306a 100644
--- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
+++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
@@ -191,4 +191,83 @@ class SkipServerTest {
assertEquals("https://listen.terst.org/api/subscriptions", transport.posts.single().first)
}
+
+ /*
+ * Ad breaks, which are what the server actually detects reliably.
+ *
+ * Intro detection on the server reported a segment confirmed on 8 of 8 Radiolab episodes
+ * and was wrong -- the segment was an advertisement, proved by searching for it in an
+ * ad-free copy of the same episode, where it scored r = 0.0621. Mirroring eight episodes in
+ * one minute makes them share whatever ad campaign is running, which destroys the
+ * independence the confirmation threshold needs.
+ *
+ * The same property makes ad detection reliable, and unlike intro detection it can be
+ * checked against something the detector did not produce: the feed declares the ad-free
+ * length, the file is longer, and the difference is the advertising. Measured on Radiolab,
+ * the scan accounted for 53:29 of the 59:39 that gap implies.
+ */
+ @Test
+ fun `reads the ad breaks the server found`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """
+ {"status":"ready","audioUrl":"/audio/abc","detected":true,
+ "breaks":[{"startMs":0,"endMs":142000},{"startMs":900000,"endMs":1020000}],
+ "foundAdMs":391000,"insertedAdMs":373000}
+ """.trimIndent()
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals(2, got?.breaks?.size)
+ assertEquals(0L, got?.breaks?.get(0)?.startMs)
+ assertEquals(142000L, got?.breaks?.get(0)?.endMs)
+ assertEquals(900000L, got?.breaks?.get(1)?.startMs)
+ assertEquals(391000L, got?.foundAdMs)
+ assertEquals(373000L, got?.insertedAdMs)
+ }
+
+ @Test
+ fun `an episode with no breaks reports an empty list rather than null`() = runTest {
+ val transport = FakeTransport().apply { response = """{"status":"ready","audioUrl":"/audio/abc"}""" }
+
+ assertEquals(emptyList<ServerBreak>(), server(transport).episode("https://f", "g1", "", "")?.breaks)
+ }
+
+ /**
+ * Matches were found but were too extensive to be advertising, so the server published none.
+ * A different state from nothing having matched, and the app must be able to tell them
+ * apart -- collapsing the two is exactly what made the skip readout useless before.
+ */
+ @Test
+ fun `a refusal is reported distinctly from having found nothing`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """{"status":"ready","audioUrl":"/audio/abc","detected":true,"adsRefused":true}"""
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertTrue("refusal lost", got?.adsRefused == true)
+ assertEquals(emptyList<ServerBreak>(), got?.breaks)
+ }
+
+ /**
+ * A zero-length or inverted range would be a skip to nowhere, or backwards. Dropped here
+ * rather than trusted: playback seeks to a break's end, so an inverted one would move the
+ * playhead backwards and the episode would never finish.
+ */
+ @Test
+ fun `discards a break that does not describe a real stretch`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """
+ {"status":"ready","audioUrl":"/audio/abc",
+ "breaks":[{"startMs":1000,"endMs":1000},{"startMs":5000,"endMs":2000},
+ {"startMs":7000,"endMs":9000}]}
+ """.trimIndent()
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals(1, got?.breaks?.size)
+ assertEquals(7000L, got?.breaks?.single()?.startMs)
+ }
}