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.kt64
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt46
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt40
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt105
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt2
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/Decimate.kt24
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt8
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt39
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt90
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt79
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt226
-rw-r--r--app/src/main/java/org/terst/listen/playback/MidrollSkip.kt18
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt26
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt4
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt33
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt38
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt35
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt87
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt68
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt182
-rw-r--r--app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt46
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt3
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt38
24 files changed, 1282 insertions, 21 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 1f4db52..09b669f 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -269,4 +269,68 @@ class MigrationTest {
assertEquals(2, cursor.getInt(0))
}
}
+
+ /**
+ * Extending the skip feature to midroll ad breaks: the new columns default to null (no
+ * pending discovery episode, nothing checked yet for an existing episode), and both new
+ * tables are actually usable, not just present.
+ */
+ @Test
+ fun migrates7To8AddingMidrollTablesAndColumns() {
+ helper.createDatabase(name, 7).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1)"""
+ )
+ db.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,
+ 313131, 'DOWNLOADED')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 8, true, AppDatabase.MIGRATION_7_8)
+
+ db.query("SELECT midrollCheckedAtMs, positionMs FROM episodes WHERE id = 'e1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals("nothing checked yet for an existing episode", true, cursor.isNull(0))
+ assertEquals("the crown jewel must survive", 313131L, cursor.getLong(1))
+ }
+
+ db.query("SELECT midrollPendingEpisodeId, title FROM subscriptions WHERE id = 's1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals(true, cursor.isNull(0))
+ assertEquals("A Show", cursor.getString(1))
+ }
+
+ db.execSQL(
+ """INSERT INTO show_midrolls
+ (subscriptionId, id, referenceClipPcm, sampleRate, confirmedCount,
+ lastConfirmedEpisodeId, updatedAtMs)
+ VALUES ('s1', 'c1', X'0102', 8000, 2, 'e1', 5000)"""
+ )
+ db.execSQL(
+ """INSERT INTO episode_midrolls (episodeId, candidateId, startMs, endMs)
+ VALUES ('e1', 'c1', 60000, 90000)"""
+ )
+
+ db.query("SELECT confirmedCount FROM show_midrolls WHERE subscriptionId = 's1' AND id = 'c1'")
+ .use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals(2, cursor.getInt(0))
+ }
+ db.query("SELECT startMs, endMs FROM episode_midrolls WHERE episodeId = 'e1' AND candidateId = 'c1'")
+ .use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals(60000L, cursor.getLong(0))
+ assertEquals(90000L, cursor.getLong(1))
+ }
+ }
}
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 cdfbf02..e8769d3 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -27,8 +27,10 @@ class Converters {
QueueEntity::class,
RuleEntity::class,
ShowFingerprintEntity::class,
+ ShowMidrollEntity::class,
+ EpisodeMidrollEntity::class,
],
- version = 7,
+ version = 8,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -38,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun queue(): QueueDao
abstract fun rules(): RuleDao
abstract fun fingerprints(): ShowFingerprintDao
+ abstract fun midrolls(): MidrollDao
companion object {
@Volatile private var instance: AppDatabase? = null
@@ -188,6 +191,45 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Extends the skip feature's fingerprinting to midroll ad breaks: several candidates per
+ * show rather than one fixed intro/outro slot each (see [ShowMidrollEntity]), the
+ * confirmed ranges detected per episode, a per-episode "already checked" marker, and the
+ * one-episode-id pending pointer discovery compares the next download against.
+ */
+ val MIGRATION_7_8 = object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `midrollCheckedAtMs` INTEGER")
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `midrollPendingEpisodeId` TEXT")
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `show_midrolls` (
+ `subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL,
+ `referenceClipPcm` BLOB NOT NULL, `sampleRate` INTEGER NOT NULL,
+ `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT,
+ `updatedAtMs` INTEGER NOT NULL,
+ PRIMARY KEY(`subscriptionId`, `id`),
+ FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`)
+ ON DELETE CASCADE)"""
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` " +
+ "ON `show_midrolls` (`subscriptionId`)"
+ )
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `episode_midrolls` (
+ `episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL,
+ `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL,
+ PRIMARY KEY(`episodeId`, `candidateId`),
+ FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`)
+ ON DELETE CASCADE)"""
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` " +
+ "ON `episode_midrolls` (`episodeId`)"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -198,7 +240,7 @@ abstract class AppDatabase : RoomDatabase() {
// deliberate addition, and the list is the record of them.
.addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6,
- MIGRATION_6_7,
+ MIGRATION_6_7, MIGRATION_7_8,
)
.addCallback(SEED_DEFAULT_FILTERS)
// No fallbackToDestructiveMigration. A listener's playback positions and play
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 7bc54fc..541d6ef 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -308,6 +308,46 @@ interface ShowFingerprintDao {
suspend fun setDetectedOutroMs(id: String, ms: Long)
}
+/**
+ * Everything [org.terst.listen.fingerprint.MidrollUpdater] needs: a show's tracked candidates,
+ * the confirmed ranges detected in one episode, and which downloaded episodes still need
+ * checking -- the same "lives here, not on EpisodeDao" reasoning as [ShowFingerprintDao].
+ */
+@Dao
+interface MidrollDao {
+ @Query("SELECT * FROM show_midrolls WHERE subscriptionId = :subscriptionId")
+ suspend fun getCandidates(subscriptionId: String): List<ShowMidrollEntity>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertCandidate(candidate: ShowMidrollEntity)
+
+ @Query("DELETE FROM show_midrolls WHERE subscriptionId = :subscriptionId AND id = :id")
+ suspend fun deleteCandidate(subscriptionId: String, id: String)
+
+ @Query("SELECT * FROM episode_midrolls WHERE episodeId = :episodeId")
+ suspend fun getRanges(episodeId: String): List<EpisodeMidrollEntity>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertRange(range: EpisodeMidrollEntity)
+
+ /** 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. */
+ @Query(
+ """SELECT * FROM episodes
+ WHERE subscriptionId = :subscriptionId AND downloadState = :downloaded
+ AND midrollCheckedAtMs IS NULL
+ ORDER BY downloadedAtMs ASC"""
+ )
+ suspend fun getUncheckedDownloaded(
+ subscriptionId: String,
+ downloaded: DownloadState = DownloadState.DOWNLOADED,
+ ): List<EpisodeEntity>
+
+ @Query("UPDATE episodes SET midrollCheckedAtMs = :ms WHERE id = :id")
+ suspend fun setMidrollCheckedAtMs(id: String, ms: Long)
+}
+
@Dao
interface QueueDao {
@Query(
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 014e3a0..4f5242d 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -97,6 +97,20 @@ data class SubscriptionEntity(
* Changing it would mean recreating the table for a value nothing reads.
*/
@ColumnInfo(defaultValue = "0") val skipAdChapters: Boolean = true,
+
+ /**
+ * The one episode set aside to compare the *next* newly-downloaded episode against, when
+ * looking for a midroll segment neither has been matched to yet.
+ *
+ * Unlike [ShowFingerprintEntity.pendingClipPcm] this stores only an id, never audio: a
+ * pending clip for intro/outro is a few seconds, cheap to persist between runs, but a
+ * pending clip here is a *whole episode* -- tens of megabytes of PCM doubles for an hour of
+ * audio, uselessly large to keep in a database row. [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater]
+ * re-decodes this episode's file on demand instead, and if retention has already reclaimed
+ * it, simply moves the pending slot on to whatever is newest -- the same graceful "nothing to
+ * compare against yet" path as never having had a pending episode at all.
+ */
+ val midrollPendingEpisodeId: String? = null,
)
/** One a day, the cadence a drip subscription is for. */
@@ -160,6 +174,15 @@ data class EpisodeEntity(
val speedOverride: Float? = null,
/**
+ * When [MidrollDetector][org.terst.listen.fingerprint.MidrollDetector] last checked this
+ * episode against its show's tracked candidates, or null if it never has. Per-episode rather
+ * than a show-level cursor: downloads do not always land in publish order, and a marker per
+ * episode means a backfilled older download still gets checked instead of being silently
+ * skipped by a pointer that already moved past it.
+ */
+ val midrollCheckedAtMs: Long? = null,
+
+ /**
* The fetched Podcasting 2.0 chapter file, verbatim, or null if there is none yet.
*
* Stored as the raw document rather than parsed rows: it is only ever read whole, for one
@@ -313,6 +336,88 @@ data class ShowFingerprintEntity(
}
/**
+ * A show's progress toward one trusted, automatically-detected midroll ad break.
+ *
+ * One row per *candidate*, not one per show like [ShowFingerprintEntity] -- a show can have
+ * several distinct midroll breaks running at once (and Peter's own Darknet Diaries prototype run
+ * found exactly that: a recurring segment independent of the fixed intro), so [id] is a generated
+ * identifier rather than a fixed kind. [MidrollScanner] is what ever produces a new row; this
+ * table only ever tracks candidates that already exist.
+ */
+@Entity(
+ tableName = "show_midrolls",
+ primaryKeys = ["subscriptionId", "id"],
+ foreignKeys = [
+ ForeignKey(
+ entity = SubscriptionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["subscriptionId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["subscriptionId"])],
+)
+data class ShowMidrollEntity(
+ val subscriptionId: String,
+ val id: String,
+ /** The matched segment itself, 16-bit PCM via [org.terst.listen.fingerprint.PcmCodec]. */
+ val referenceClipPcm: ByteArray,
+ val sampleRate: Int,
+ val confirmedCount: Int,
+ val lastConfirmedEpisodeId: String?,
+ val updatedAtMs: Long,
+) {
+ // See ShowFingerprintEntity's identical override for why: a ByteArray property breaks the
+ // generated equals/hashCode, comparing references rather than contents.
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ShowMidrollEntity) return false
+ return subscriptionId == other.subscriptionId &&
+ id == other.id &&
+ referenceClipPcm.contentEquals(other.referenceClipPcm) &&
+ sampleRate == other.sampleRate &&
+ confirmedCount == other.confirmedCount &&
+ lastConfirmedEpisodeId == other.lastConfirmedEpisodeId &&
+ updatedAtMs == other.updatedAtMs
+ }
+
+ override fun hashCode(): Int {
+ var result = subscriptionId.hashCode()
+ result = 31 * result + id.hashCode()
+ result = 31 * result + referenceClipPcm.contentHashCode()
+ result = 31 * result + sampleRate
+ result = 31 * result + confirmedCount
+ result = 31 * result + (lastConfirmedEpisodeId?.hashCode() ?: 0)
+ result = 31 * result + updatedAtMs.hashCode()
+ return result
+ }
+}
+
+/**
+ * One confirmed midroll range detected in one episode -- where to jump from and to, in
+ * milliseconds into the episode. An episode can carry several, one per candidate that matched it.
+ */
+@Entity(
+ tableName = "episode_midrolls",
+ primaryKeys = ["episodeId", "candidateId"],
+ foreignKeys = [
+ ForeignKey(
+ entity = EpisodeEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["episodeId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["episodeId"])],
+)
+data class EpisodeMidrollEntity(
+ val episodeId: String,
+ val candidateId: String,
+ val startMs: Long,
+ val endMs: Long,
+)
+
+/**
* One entry in the single Up Next queue.
*
* Position is a plain ordinal rewritten on reorder. A personal queue is short enough that the
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 ddcf820..fb77ab6 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -51,6 +51,7 @@ class AppGraph(private val context: Context) {
val queueDao: QueueDao get() = db.queue()
val ruleDao: RuleDao get() = db.rules()
val fingerprints: ShowFingerprintDao get() = db.fingerprints()
+ val midrolls: MidrollDao get() = db.midrolls()
val libraryDir: File by lazy {
File(context.filesDir, "audio").also { it.mkdirs() }
@@ -71,6 +72,7 @@ class AppGraph(private val context: Context) {
val advancer: PlaybackAdvancer by lazy {
PlaybackAdvancer(episodes, subscriptions, queue, sources = object : PlaybackSources {
override suspend fun chapters(episode: EpisodeEntity) = chapters.chapters(episode)
+ override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id)
override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) =
downloader.download(episode, kind).map { }
})
diff --git a/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt b/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt
new file mode 100644
index 0000000..9b7acd4
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt
@@ -0,0 +1,24 @@
+package org.terst.listen.fingerprint
+
+/**
+ * Downsamples by averaging fixed-size blocks, rather than dropping samples.
+ *
+ * Only used as a cheap pre-filter ahead of [MidrollScanner]'s coarse pass: averaging keeps the
+ * signal's overall shape (which is all a coarse alignment search needs) where naive decimation
+ * (keeping every Nth sample) would alias, and correctness of the *real* match still comes from
+ * [AudioCorrelation] re-checking at full resolution afterwards.
+ */
+object Decimate {
+
+ fun byAveraging(samples: DoubleArray, factor: Int): DoubleArray {
+ require(factor >= 1) { "factor must be at least 1" }
+ if (factor == 1) return samples
+ val outLength = samples.size / factor
+ return DoubleArray(outLength) { i ->
+ var sum = 0.0
+ val base = i * factor
+ for (j in 0 until factor) sum += samples[base + j]
+ sum / factor
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
index 6a71d83..dc627fa 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt
@@ -19,6 +19,14 @@ interface AudioClipSource {
/** Null if the episode has nothing playable yet, or decoding otherwise fails. */
suspend fun decodeHead(episode: EpisodeEntity): DecodedClip?
suspend fun decodeTail(episode: EpisodeEntity): DecodedClip?
+
+ /**
+ * The episode's *entire* decoded audio, for [MidrollUpdater] -- a midroll ad break can land
+ * anywhere in the body, so unlike [decodeHead]/[decodeTail] there is no fixed short window to
+ * ask for. Null under the same conditions, plus whenever the episode's length is not known
+ * up front (needed to size the decode).
+ */
+ suspend fun decodeFull(episode: EpisodeEntity): DecodedClip?
}
/**
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
index be140f9..133d391 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt
@@ -25,22 +25,35 @@ class MediaCodecClipSource : AudioClipSource {
override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? {
val path = episode.localPath ?: return null
- return decode(path, startMs = 0)
+ return decode(path, startMs = 0, CLIP_SECONDS)
}
override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? {
val path = episode.localPath ?: return null
val totalMs = episode.durationMs ?: return null
val startMs = (totalMs - CLIP_SECONDS * MS_PER_SECOND).coerceAtLeast(0)
- return decode(path, startMs)
+ return decode(path, startMs, CLIP_SECONDS)
}
- private suspend fun decode(path: String, startMs: Long): DecodedClip? =
+ /**
+ * Unlike [decodeHead]/[decodeTail], there is no fixed window: a midroll ad can be anywhere,
+ * so the whole episode is decoded. [EpisodeEntity.durationMs] sizes the buffer up front for
+ * the same reason [decodeToPcm] does for a short clip -- a boxed, growable collection for
+ * what can be hours of samples would be real, avoidable garbage on a phone.
+ */
+ override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? {
+ val path = episode.localPath ?: return null
+ val totalMs = episode.durationMs ?: return null
+ val clipSeconds = (totalMs / MS_PER_SECOND + FULL_DECODE_MARGIN_SECONDS).toInt()
+ return decode(path, startMs = 0, clipSeconds)
+ }
+
+ private suspend fun decode(path: String, startMs: Long, clipSeconds: Int): DecodedClip? =
withContext(Dispatchers.Default) {
- runCatching { decodeBlocking(path, startMs) }.getOrNull()
+ runCatching { decodeBlocking(path, startMs, clipSeconds) }.getOrNull()
}
- private fun decodeBlocking(path: String, startMs: Long): DecodedClip {
+ private fun decodeBlocking(path: String, startMs: Long, clipSeconds: Int): DecodedClip {
val extractor = MediaExtractor()
try {
extractor.setDataSource(path)
@@ -51,7 +64,7 @@ class MediaCodecClipSource : AudioClipSource {
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
extractor.seekTo(startMs * MICROS_PER_MS, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
- val raw = decodeToPcm(extractor, format, inputRate, channels)
+ val raw = decodeToPcm(extractor, format, inputRate, channels, clipSeconds)
val mono = downmix(raw, channels)
val resampled = resample(mono, inputRate, TARGET_SAMPLE_RATE)
return DecodedClip(resampled, TARGET_SAMPLE_RATE, startMs)
@@ -68,8 +81,14 @@ class MediaCodecClipSource : AudioClipSource {
error("No audio track in this file")
}
- /** Interleaved PCM16 samples for up to [CLIP_SECONDS] of decoded audio. */
- private fun decodeToPcm(extractor: MediaExtractor, format: MediaFormat, inputRate: Int, channels: Int): ShortArray {
+ /** Interleaved PCM16 samples for up to [clipSeconds] of decoded audio. */
+ private fun decodeToPcm(
+ extractor: MediaExtractor,
+ format: MediaFormat,
+ inputRate: Int,
+ channels: Int,
+ clipSeconds: Int,
+ ): ShortArray {
val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME on the audio track")
val codec = MediaCodec.createDecoderByType(mime)
try {
@@ -78,7 +97,7 @@ class MediaCodecClipSource : AudioClipSource {
// Sized to the clip length up front rather than grown: a boxed collection for
// several million samples would be real, avoidable garbage on a phone.
- val target = ShortArray((CLIP_SECONDS.toLong() * inputRate * channels).toInt())
+ val target = ShortArray((clipSeconds.toLong() * inputRate * channels).toInt())
var written = 0
var inputDone = false
var outputDone = false
@@ -171,6 +190,8 @@ class MediaCodecClipSource : AudioClipSource {
private companion object {
const val CLIP_SECONDS = 240
+ // durationMs is the feed's own claim, occasionally a little short of the real file.
+ const val FULL_DECODE_MARGIN_SECONDS = 30
const val TARGET_SAMPLE_RATE = 8_000
const val TIMEOUT_US = 10_000L
const val MICROS_PER_MS = 1_000L
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt
new file mode 100644
index 0000000..b33f577
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt
@@ -0,0 +1,90 @@
+package org.terst.listen.fingerprint
+
+/**
+ * Decides whether each of a show's tracked midroll candidates still holds up against one more
+ * episode, and reports which ones are trusted enough to act on.
+ *
+ * The confirm/discard shape is exactly [IntroOutroDetector]'s -- the same Darknet Diaries trap
+ * applies here too, a segment two episodes happen to share by coincidence must not survive a
+ * genuinely independent episode that lacks it. The difference is scale, not kind: a show can have
+ * several distinct midroll breaks at once, each tracked as its own [Candidate] rather than one
+ * fixed intro/outro slot, and *finding* a new one means scanning a whole episode body (see
+ * [MidrollScanner]) rather than comparing two short fixed-position clips -- which is a separate,
+ * heavier concern this object deliberately knows nothing about. [MidrollUpdater] owns discovery;
+ * this only ever judges candidates that already exist.
+ */
+object MidrollDetector {
+
+ private const val CONFIRM_THRESHOLD = 3
+ private const val MIN_SCORE = 0.6
+
+ data class Candidate(
+ val id: String,
+ val referenceClip: DoubleArray,
+ val confirmedCount: Int,
+ val lastConfirmedEpisodeId: String? = null,
+ )
+
+ /** Where a candidate matched in the episode just checked, in samples. */
+ data class Detected(val candidateId: String, val positionInClip: Int, val lengthSamples: Int)
+
+ /**
+ * [detected] is only the candidates that crossed [confirmThreshold] this round -- the ones
+ * safe to actually act on. [allMatches] is every survivor's match this round, tentative ones
+ * included: [MidrollUpdater] needs that wider set to recognise a fresh discovery hit as the
+ * same still-unconfirmed candidate rather than minting a duplicate for the same segment.
+ */
+ data class Result(
+ val candidates: List<Candidate>,
+ val detected: List<Detected>,
+ val allMatches: List<Detected>,
+ )
+
+ /** What checking one candidate against the episode decided. */
+ private sealed interface Outcome {
+ data class Kept(val candidate: Candidate, val match: Detected?) : Outcome
+ data object Discarded : Outcome
+ }
+
+ /**
+ * Checks every candidate in [candidates] against [fullClip] (a whole episode's decoded
+ * audio), independently. A candidate the episode does not confirm is dropped from the
+ * returned list entirely -- not weakened -- the same discard-on-mismatch discipline as
+ * [IntroOutroDetector].
+ */
+ fun check(
+ candidates: List<Candidate>,
+ episodeId: String,
+ fullClip: DoubleArray,
+ confirmThreshold: Int = CONFIRM_THRESHOLD,
+ ): Result {
+ val survivors = mutableListOf<Candidate>()
+ val detected = mutableListOf<Detected>()
+ val allMatches = mutableListOf<Detected>()
+
+ for (candidate in candidates) {
+ when (val outcome = evaluate(candidate, episodeId, fullClip)) {
+ is Outcome.Discarded -> Unit
+ is Outcome.Kept -> {
+ survivors += outcome.candidate
+ val match = outcome.match ?: continue
+ allMatches += match
+ if (outcome.candidate.confirmedCount >= confirmThreshold) detected += match
+ }
+ }
+ }
+
+ return Result(survivors, detected, allMatches)
+ }
+
+ private fun evaluate(candidate: Candidate, episodeId: String, fullClip: DoubleArray): Outcome {
+ if (candidate.lastConfirmedEpisodeId == episodeId) return Outcome.Kept(candidate, match = null)
+
+ val match = AudioCorrelation.bestMatch(candidate.referenceClip, fullClip)
+ if (match == null || match.score < MIN_SCORE) return Outcome.Discarded
+
+ val bumped = candidate.copy(confirmedCount = candidate.confirmedCount + 1, lastConfirmedEpisodeId = episodeId)
+ val detected = Detected(bumped.id, match.position, candidate.referenceClip.size)
+ return Outcome.Kept(bumped, detected)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt
new file mode 100644
index 0000000..d69870c
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt
@@ -0,0 +1,79 @@
+package org.terst.listen.fingerprint
+
+/**
+ * Finds every segment [a] and [b] share, at whatever position each recording happens to have it
+ * -- unlike [IntroOutroDetector], which only ever looks for one candidate anchored at the head or
+ * tail. A show's midroll ad breaks land at different timestamps in every episode, so discovering
+ * one means scanning the whole body of both episodes rather than a short fixed-position window.
+ *
+ * Single-resolution and pure: given whatever `a`/`b` it is handed, it does not know or care that
+ * the real caller runs it twice at different sample rates (see the coarse-to-fine composition in
+ * [MidrollUpdater]) -- that split exists purely so a hand-length scan of a full episode is fast
+ * enough to run on a phone, and has nothing to do with correctness.
+ */
+object MidrollScanner {
+
+ /** A segment found at [positionInA] in `a` and [positionInB] in `b`, both in samples. */
+ data class SharedSegment(
+ val positionInA: Int,
+ val positionInB: Int,
+ val lengthSamples: Int,
+ val score: Double,
+ )
+
+ /**
+ * Slides a [windowSamples]-long window across [a] every [stepSamples], keeping wherever it
+ * best matches somewhere in [b]. Hits are merged into one [SharedSegment] when they fall on
+ * the same diagonal (`positionInA - positionInB` within a step of each other) -- consecutive
+ * windows walking across the same real embedded segment, not two different ones.
+ */
+ fun findSharedSegments(
+ a: DoubleArray,
+ b: DoubleArray,
+ windowSamples: Int,
+ stepSamples: Int,
+ minScore: Double,
+ ): List<SharedSegment> {
+ if (windowSamples <= 0 || windowSamples > a.size || windowSamples > b.size) return emptyList()
+
+ val groups = mutableListOf<Group>()
+ var start = 0
+ while (start + windowSamples <= a.size) {
+ val window = a.copyOfRange(start, start + windowSamples)
+ val match = AudioCorrelation.bestMatch(window, b)
+ if (match != null && match.score >= minScore) {
+ place(groups, Hit(aStart = start, bStart = match.position, score = match.score), windowSamples, stepSamples)
+ }
+ start += stepSamples
+ }
+
+ return groups.map {
+ SharedSegment(
+ positionInA = it.aStart,
+ positionInB = it.bStart,
+ lengthSamples = it.aEnd - it.aStart,
+ score = it.bestScore,
+ )
+ }
+ }
+
+ private class Group(var aStart: Int, var aEnd: Int, var bStart: Int, var bEnd: Int, var bestScore: Double)
+
+ private data class Hit(val aStart: Int, val bStart: Int, val score: Double)
+
+ private fun place(groups: MutableList<Group>, hit: Hit, windowSamples: Int, tolerance: Int) {
+ val aEnd = hit.aStart + windowSamples
+ val bEnd = hit.bStart + windowSamples
+ val diagonal = hit.aStart - hit.bStart
+ val existing = groups.firstOrNull { kotlin.math.abs((it.aStart - it.bStart) - diagonal) <= tolerance }
+ if (existing == null) {
+ groups += Group(hit.aStart, aEnd, hit.bStart, bEnd, hit.score)
+ } else {
+ existing.aStart = minOf(existing.aStart, hit.aStart)
+ existing.aEnd = maxOf(existing.aEnd, aEnd)
+ existing.bStart = minOf(existing.bStart, hit.bStart)
+ existing.bEnd = maxOf(existing.bEnd, bEnd)
+ existing.bestScore = maxOf(existing.bestScore, hit.score)
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt
new file mode 100644
index 0000000..47f9184
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt
@@ -0,0 +1,226 @@
+package org.terst.listen.fingerprint
+
+import java.util.UUID
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+import org.terst.listen.data.MidrollDao
+import org.terst.listen.data.ShowMidrollEntity
+import org.terst.listen.data.SubscriptionDao
+
+/**
+ * Runs [MidrollDetector] for every downloaded episode a show has not yet been checked against,
+ * and discovers new candidates via [MidrollScanner] when there is room and something to compare
+ * against.
+ *
+ * The one thing [FingerprintUpdater] never has to do that this does: decide *when* to spend the
+ * cost of a full-body scan. Comparing two whole episodes is expensive enough that it only ever
+ * happens between the show's single rolling "pending" episode and whatever is newest -- see
+ * [org.terst.listen.data.SubscriptionEntity.midrollPendingEpisodeId] for why no audio is
+ * persisted for it between runs.
+ */
+class MidrollUpdater(
+ private val subscriptions: SubscriptionDao,
+ private val episodes: EpisodeDao,
+ private val midrolls: MidrollDao,
+ private val clips: AudioClipSource,
+ private val tuning: Tuning = Tuning(),
+) {
+
+ /** Every knob this class needs, grouped so the constructor stays a fixed, readable size no
+ * matter how many of them a test needs to override at once. */
+ data class Tuning(
+ /** How much to shrink audio before the coarse discovery pass. 1 disables it entirely. */
+ val coarseFactor: Int = DEFAULT_COARSE_FACTOR,
+ /** The coarse-pass window, in already-decimated samples -- see [MidrollScanner]. */
+ val discoverySamples: Int = DEFAULT_DISCOVERY_SAMPLES,
+ val maxCandidates: Int = DEFAULT_MAX_CANDIDATES,
+ val confirmThreshold: Int = DEFAULT_CONFIRM_THRESHOLD,
+ val newId: () -> String = { UUID.randomUUID().toString() },
+ val now: () -> Long = System::currentTimeMillis,
+ )
+
+ private val coarseFactor get() = tuning.coarseFactor
+ private val discoverySamples get() = tuning.discoverySamples
+ private val maxCandidates get() = tuning.maxCandidates
+ private val confirmThreshold get() = tuning.confirmThreshold
+ private val newId get() = tuning.newId
+ private val now get() = tuning.now
+
+ suspend fun processShow(subscriptionId: String) {
+ val show = subscriptions.getById(subscriptionId) ?: return
+ var pendingEpisodeId = show.midrollPendingEpisodeId
+
+ for (episode in midrolls.getUncheckedDownloaded(subscriptionId)) {
+ val decoded = clips.decodeFull(episode)
+ if (decoded == null) {
+ midrolls.setMidrollCheckedAtMs(episode.id, now())
+ continue
+ }
+
+ val stored = midrolls.getCandidates(subscriptionId)
+ val result = MidrollDetector.check(
+ stored.map(::toDetectorCandidate),
+ episode.id,
+ decoded.samples,
+ confirmThreshold,
+ )
+ persist(subscriptionId, stored, result, decoded.sampleRate)
+ for (detected in result.detected) applyDetected(episode, decoded, detected)
+
+ if (result.candidates.size < maxCandidates) {
+ pendingEpisodeId?.takeIf { it != episode.id }?.let { pendingId ->
+ discoverAgainstPending(subscriptionId, pendingId, episode, decoded, result.allMatches)
+ }
+ pendingEpisodeId = episode.id
+ }
+
+ midrolls.setMidrollCheckedAtMs(episode.id, now())
+ }
+
+ if (pendingEpisodeId != show.midrollPendingEpisodeId) {
+ subscriptions.update(show.copy(midrollPendingEpisodeId = pendingEpisodeId))
+ }
+ }
+
+ private suspend fun discoverAgainstPending(
+ subscriptionId: String,
+ pendingEpisodeId: String,
+ newEpisode: EpisodeEntity,
+ newClip: DecodedClip,
+ alreadyMatchedInNewEpisode: List<MidrollDetector.Detected>,
+ ) {
+ val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return
+ val pendingClip = clips.decodeFull(pendingEpisode) ?: return
+
+ val tolerance = discoverySamples.toLong() * coarseFactor
+ for (segment in discover(pendingClip.samples, newClip.samples)) {
+ // The same real segment surfaces from a fresh discovery pass every time the pending
+ // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked.
+ // Recognised here by *where* it landed in the new episode, not by re-correlating
+ // clip content: two coarse passes over the same segment do not reliably extract the
+ // same sub-range of it, so a content comparison between them can score low even
+ // though both come from the one real ad break -- position overlap is what is
+ // actually stable between runs.
+ val overlapsTracked = alreadyMatchedInNewEpisode.any { existing ->
+ rangesOverlap(
+ segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance,
+ existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples,
+ )
+ }
+ if (overlapsTracked) continue
+
+ midrolls.upsertCandidate(
+ ShowMidrollEntity(
+ subscriptionId = subscriptionId,
+ id = newId(),
+ referenceClipPcm = PcmCodec.encode(segment.referenceClip),
+ sampleRate = newClip.sampleRate,
+ confirmedCount = SEEDED_CONFIRM_COUNT,
+ lastConfirmedEpisodeId = newEpisode.id,
+ updatedAtMs = now(),
+ )
+ )
+ }
+ }
+
+ private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd
+
+ private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long)
+
+ /**
+ * Coarse-to-fine: [MidrollScanner] runs once on decimated copies to find candidate
+ * alignments cheaply, then each candidate is re-sliced from the *native*-resolution audio and
+ * re-checked with a single [AudioCorrelation.bestMatch] -- both for a trustworthy score and
+ * to reject anything the decimation made look like a match but is not. See the desktop
+ * prototype findings this composition is based on for why the coarse pass alone is not
+ * trusted: it is a performance shortcut, not where correctness comes from.
+ */
+ private fun discover(a: DoubleArray, b: DoubleArray): List<DiscoveredSegment> {
+ val coarseA = Decimate.byAveraging(a, coarseFactor)
+ val coarseB = Decimate.byAveraging(b, coarseFactor)
+ val step = (discoverySamples / 2).coerceAtLeast(1)
+ val hits = MidrollScanner.findSharedSegments(coarseA, coarseB, discoverySamples, step, COARSE_MIN_SCORE)
+ return hits.mapNotNull { extractSegment(it, a, b) }
+ }
+
+ /**
+ * Re-slices one coarse hit at native resolution and verifies it with a single
+ * [AudioCorrelation.bestMatch] -- both for a trustworthy score and to reject anything the
+ * decimation made look like a match but is not. Null covers every way that can fail: an empty
+ * slice at either end, or a score that does not hold up at full resolution.
+ */
+ private fun extractSegment(hit: MidrollScanner.SharedSegment, a: DoubleArray, b: DoubleArray): DiscoveredSegment? {
+ val aStart = (hit.positionInA.toLong() * coarseFactor).coerceIn(0, a.size.toLong()).toInt()
+ val aEnd = ((hit.positionInA.toLong() + hit.lengthSamples) * coarseFactor).coerceIn(0, a.size.toLong()).toInt()
+ if (aEnd - aStart <= 0) return null
+ val query = a.copyOfRange(aStart, aEnd).let {
+ if (it.size > MAX_REFERENCE_SAMPLES) it.copyOf(MAX_REFERENCE_SAMPLES) else it
+ }
+
+ val margin = discoverySamples.toLong() * coarseFactor
+ val bStart = ((hit.positionInB.toLong() * coarseFactor) - margin).coerceIn(0, b.size.toLong()).toInt()
+ val bEnd = ((hit.positionInB.toLong() + hit.lengthSamples) * coarseFactor + margin)
+ .coerceIn(0, b.size.toLong()).toInt()
+ if (bEnd - bStart < query.size) return null
+ val target = b.copyOfRange(bStart, bEnd)
+
+ val match = AudioCorrelation.bestMatch(query, target) ?: return null
+ if (match.score < FINE_MIN_SCORE) return null
+ return DiscoveredSegment(query, (bStart + match.position).toLong(), query.size.toLong())
+ }
+
+ private suspend fun persist(
+ subscriptionId: String,
+ stored: List<ShowMidrollEntity>,
+ result: MidrollDetector.Result,
+ sampleRate: Int,
+ ) {
+ val surviving = result.candidates.associateBy { it.id }
+ for (row in stored) {
+ if (row.id !in surviving) midrolls.deleteCandidate(subscriptionId, row.id)
+ }
+ for (candidate in result.candidates) {
+ midrolls.upsertCandidate(
+ ShowMidrollEntity(
+ subscriptionId = subscriptionId,
+ id = candidate.id,
+ referenceClipPcm = PcmCodec.encode(candidate.referenceClip),
+ sampleRate = sampleRate,
+ confirmedCount = candidate.confirmedCount,
+ lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId,
+ updatedAtMs = now(),
+ )
+ )
+ }
+ }
+
+ private suspend fun applyDetected(
+ episode: EpisodeEntity,
+ clip: DecodedClip,
+ detected: MidrollDetector.Detected,
+ ) {
+ val startMs = clip.startMs + detected.positionInClip.toLong() * MS_PER_SECOND / clip.sampleRate
+ val endMs = startMs + detected.lengthSamples.toLong() * MS_PER_SECOND / clip.sampleRate
+ midrolls.upsertRange(EpisodeMidrollEntity(episode.id, detected.candidateId, startMs, endMs))
+ }
+
+ private fun toDetectorCandidate(entity: ShowMidrollEntity) = MidrollDetector.Candidate(
+ id = entity.id,
+ referenceClip = PcmCodec.decode(entity.referenceClipPcm),
+ confirmedCount = entity.confirmedCount,
+ lastConfirmedEpisodeId = entity.lastConfirmedEpisodeId,
+ )
+
+ private companion object {
+ const val DEFAULT_COARSE_FACTOR = 8
+ const val DEFAULT_DISCOVERY_SAMPLES = 20_000 // 20s at the coarse (post-decimation) rate
+ const val DEFAULT_MAX_CANDIDATES = 4
+ const val DEFAULT_CONFIRM_THRESHOLD = 3
+ const val SEEDED_CONFIRM_COUNT = 2
+ const val COARSE_MIN_SCORE = 0.7
+ const val FINE_MIN_SCORE = 0.6
+ const val MAX_REFERENCE_SAMPLES = 180 * 8_000 // 3 minutes at 8kHz, a generous cap
+ const val MS_PER_SECOND = 1_000L
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt
new file mode 100644
index 0000000..fdaf7a3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt
@@ -0,0 +1,18 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.EpisodeMidrollEntity
+
+/**
+ * Where to jump to if the playhead has entered a midroll ad break Tier 3 confirmed for this
+ * episode, or null to carry on playing.
+ *
+ * Same shape as [AdChapters], and the same reason: [PlayheadPolicy] just wants "is there
+ * somewhere to jump to right now", regardless of which of the two sources decided that.
+ */
+object MidrollSkip {
+
+ fun skipTarget(ranges: List<EpisodeMidrollEntity>, positionMs: Long): Long? =
+ ranges.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
+ ?.endMs
+ ?.takeIf { it > positionMs }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
index 8aa0bf0..51bef62 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
@@ -2,6 +2,7 @@ package org.terst.listen.playback
import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.QueueRepository
import org.terst.listen.data.SubscriptionDao
import org.terst.listen.data.SubscriptionEntity
@@ -13,22 +14,28 @@ data class PlayingContext(
val show: SubscriptionEntity? = null,
val chapters: List<Chapter> = emptyList(),
val episode: EpisodeEntity? = null,
+ /** Tier 3 midroll breaks confirmed for this episode -- see [MidrollSkip]. */
+ val midrolls: List<EpisodeMidrollEntity> = emptyList(),
)
/**
- * The parts of starting an episode that reach the network: its chapter file, and yt-dlp.
- *
- * One seam rather than two lambdas on the constructor, so a test stands in for both at once and
- * the class keeps a readable signature.
+ * Everything about starting or scoring an episode that lives outside [PlaybackAdvancer]'s own
+ * state: its chapter file and yt-dlp reach the network, and its detected midroll ranges reach the
+ * database -- grouped behind one seam regardless, so a test stands in for all of it at once and
+ * the constructor stays a readable, fixed size no matter how many small lookups this ends up
+ * covering.
*/
interface PlaybackSources {
suspend fun chapters(episode: EpisodeEntity): List<Chapter>
+ suspend fun midrolls(episode: EpisodeEntity): List<EpisodeMidrollEntity>
+
suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind): Result<Unit>
- /** For the cases that never reach the network: no chapters, and nothing to extract with. */
+ /** For the cases that never reach outside: no chapters, no midrolls, nothing to extract with. */
object None : PlaybackSources {
override suspend fun chapters(episode: EpisodeEntity) = emptyList<Chapter>()
+ override suspend fun midrolls(episode: EpisodeEntity) = emptyList<EpisodeMidrollEntity>()
override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) =
Result.failure<Unit>(IllegalStateException("no audio source configured"))
}
@@ -83,24 +90,25 @@ class PlaybackAdvancer(
private val episodes: EpisodeDao,
private val subscriptions: SubscriptionDao,
private val queue: QueueRepository,
- /** Everything here that needs the network, behind one seam so tests can stand it in. */
+ /** Everything here that reaches outside this class's own state, behind one seam so tests can
+ * stand it all in at once. */
private val sources: PlaybackSources = PlaybackSources.None,
private val progress: PlaybackProgress = PlaybackProgress(),
private val now: () -> Long = System::currentTimeMillis,
) {
/**
- * The show and chapters behind an episode.
+ * The show, chapters and detected midroll breaks behind an episode.
*
* Read once when the playing item changes rather than on every tick: the ticker runs every
- * two seconds for hours at a stretch and neither answer changes mid-episode.
+ * two seconds for hours at a stretch and none of these answers change mid-episode.
*/
suspend fun contextFor(episodeId: String?): PlayingContext {
val episode = episodeId?.let { episodes.getById(it) } ?: return PlayingContext()
val show = subscriptions.getById(episode.subscriptionId)
// Only worth fetching for a show that asked for it; most publish no chapters at all.
val chapters = if (show?.skipAdChapters == true) sources.chapters(episode) else emptyList()
- return PlayingContext(show, chapters, episode)
+ return PlayingContext(show, chapters, episode, sources.midrolls(episode))
}
/**
diff --git a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
index 5d7f7f6..2eff5be 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt
@@ -31,6 +31,10 @@ object PlayheadPolicy {
return TickAction.Finish
}
+ // No per-show toggle, same as intro/outro: the confirmation threshold behind a detected
+ // midroll break is the safety mechanism, not an extra switch nobody would think to flip.
+ MidrollSkip.skipTarget(context.midrolls, positionMs)?.let { return TickAction.SeekTo(it) }
+
if (!show.skipAdChapters) return TickAction.Nothing
return AdChapters.skipTarget(context.chapters, positionMs, durationMs)
?.let(TickAction::SeekTo)
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index 44fd497..a085070 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -14,6 +14,7 @@ import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
import org.terst.listen.fingerprint.FingerprintUpdater
import org.terst.listen.fingerprint.MediaCodecClipSource
+import org.terst.listen.fingerprint.MidrollUpdater
import java.util.concurrent.TimeUnit
/**
@@ -93,15 +94,33 @@ class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineW
}
}
+/**
+ * Runs [MidrollUpdater] for every show, nightly rather than per-download like [FingerprintWorker]:
+ * a full-body scan is expensive enough (see the desktop prototype findings) that it is worth
+ * batching, and unlike a download it never touches the network, so there is nothing per-episode
+ * to react to promptly.
+ */
+class MidrollWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val graph = Graph.get(applicationContext)
+ val updater = MidrollUpdater(graph.subscriptions, graph.episodes, graph.midrolls, MediaCodecClipSource())
+ for (show in graph.subscriptions.getAll()) updater.processShow(show.id)
+ return Result.success()
+ }
+}
+
object Scheduling {
private const val REFRESH_WORK = "listen-refresh"
private const val DOWNLOAD_WORK = "listen-download"
private const val DOWNLOAD_NOW_WORK = "listen-download-now"
private const val RETENTION_WORK = "listen-retention"
+ private const val MIDROLL_WORK = "listen-midroll"
private const val REFRESH_HOURS = 6L
private const val DOWNLOAD_HOURS = 2L
private const val RETENTION_HOURS = 12L
+ private const val MIDROLL_HOURS = 24L
/**
* Fetches what is in the queue now, rather than at the next periodic sweep.
@@ -176,5 +195,19 @@ object Scheduling {
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.build(),
)
+
+ manager.enqueueUniquePeriodicWork(
+ MIDROLL_WORK,
+ ExistingPeriodicWorkPolicy.KEEP,
+ PeriodicWorkRequestBuilder<MidrollWorker>(MIDROLL_HOURS, TimeUnit.HOURS)
+ .setConstraints(
+ Constraints.Builder()
+ // Charging, not wifi: everything it touches is already on disk, so
+ // unlike DownloadWorker there is no data cost to gate on network for.
+ .setRequiresCharging(true)
+ .build()
+ )
+ .build(),
+ )
}
}
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 43f4067..16f46f4 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -272,6 +272,44 @@ class FakeShowFingerprintDao : ShowFingerprintDao {
}
}
+class FakeMidrollDao : MidrollDao {
+ val candidateRows = mutableListOf<ShowMidrollEntity>()
+ val rangeRows = mutableListOf<EpisodeMidrollEntity>()
+
+ /** Wired the same way as [FakeShowFingerprintDao.episodeRows]: backs the queries this DAO
+ * runs against the real `episodes` table. */
+ var episodeRows: MutableList<EpisodeEntity> = mutableListOf()
+
+ override suspend fun getCandidates(subscriptionId: String) =
+ candidateRows.filter { it.subscriptionId == subscriptionId }
+
+ override suspend fun upsertCandidate(candidate: ShowMidrollEntity) {
+ candidateRows.removeAll { it.subscriptionId == candidate.subscriptionId && it.id == candidate.id }
+ candidateRows += candidate
+ }
+
+ override suspend fun deleteCandidate(subscriptionId: String, id: String) {
+ candidateRows.removeAll { it.subscriptionId == subscriptionId && it.id == id }
+ }
+
+ override suspend fun getRanges(episodeId: String) = rangeRows.filter { it.episodeId == episodeId }
+
+ override suspend fun upsertRange(range: EpisodeMidrollEntity) {
+ rangeRows.removeAll { it.episodeId == range.episodeId && it.candidateId == range.candidateId }
+ rangeRows += range
+ }
+
+ override suspend fun getUncheckedDownloaded(subscriptionId: String, downloaded: DownloadState) =
+ episodeRows.filter {
+ it.subscriptionId == subscriptionId && it.downloadState == downloaded && it.midrollCheckedAtMs == null
+ }.sortedBy { it.downloadedAtMs ?: 0 }
+
+ override suspend fun setMidrollCheckedAtMs(id: String, ms: Long) {
+ val index = episodeRows.indexOfFirst { it.id == id }
+ if (index >= 0) episodeRows[index] = episodeRows[index].copy(midrollCheckedAtMs = ms)
+ }
+}
+
class FakeQueueDao : QueueDao {
val entries = mutableListOf<QueueEntity>()
var episodeSource: () -> List<EpisodeEntity> = { emptyList() }
diff --git a/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt b/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt
new file mode 100644
index 0000000..c23d5de
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt
@@ -0,0 +1,35 @@
+package org.terst.listen.fingerprint
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+class DecimateTest {
+
+ @Test
+ fun `averages fixed-size blocks`() {
+ val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0, 2.0)
+ val out = Decimate.byAveraging(input, factor = 2)
+ assertArrayEquals(doubleArrayOf(2.0, 4.0, 6.0), out, 1e-9)
+ }
+
+ @Test
+ fun `a trailing partial block is dropped, not padded`() {
+ val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0)
+ val out = Decimate.byAveraging(input, factor = 2)
+ assertArrayEquals(doubleArrayOf(2.0, 4.0), out, 1e-9)
+ }
+
+ @Test
+ fun `factor 1 is the identity`() {
+ val input = doubleArrayOf(1.0, 2.0, 3.0)
+ assertArrayEquals(input, Decimate.byAveraging(input, factor = 1), 1e-9)
+ }
+
+ @Test
+ fun `factor below 1 is rejected`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ Decimate.byAveraging(doubleArrayOf(1.0), factor = 0)
+ }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
index e9cf5c3..4881f88 100644
--- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
+++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt
@@ -125,5 +125,7 @@ class FingerprintUpdaterTest {
override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? =
tails[episode.id]?.let { DecodedClip(it, sampleRate = 8_000, startMs = tailStartMs) }
+
+ override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? = null
}
}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt
new file mode 100644
index 0000000..70ead6c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt
@@ -0,0 +1,87 @@
+package org.terst.listen.fingerprint
+
+import kotlin.random.Random
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MidrollDetectorTest {
+
+ private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } }
+
+ private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray {
+ val out = base.copyOf()
+ segment.forEachIndexed { i, v -> out[at + i] = v }
+ return out
+ }
+
+ private val ad = noise(seed = 7, n = 60)
+
+ @Test
+ fun `a candidate that matches reaches the threshold and is reported detected`() {
+ val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2)
+ val episode = embed(noise(1, 500), ad, at = 200)
+
+ val result = MidrollDetector.check(listOf(candidate), "e3", episode)
+
+ val survivor = result.candidates.single()
+ assertEquals(3, survivor.confirmedCount)
+ assertEquals("e3", survivor.lastConfirmedEpisodeId)
+ val detected = result.detected.single()
+ assertEquals("c1", detected.candidateId)
+ assertTrue("expected position near 200, was ${detected.positionInClip}", detected.positionInClip in 190..210)
+ assertEquals(ad.size, detected.lengthSamples)
+ }
+
+ @Test
+ fun `a candidate below threshold after matching is tentative, not detected`() {
+ val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 1)
+ val episode = embed(noise(1, 500), ad, at = 200)
+
+ val result = MidrollDetector.check(listOf(candidate), "e2", episode)
+
+ assertEquals(2, result.candidates.single().confirmedCount)
+ assertTrue(result.detected.isEmpty())
+ // Not confirmed, but MidrollUpdater still needs to know *where* it matched this episode
+ // -- to recognise a fresh discovery hit at the same spot as this same, still-tentative
+ // candidate rather than a genuinely different one.
+ val match = result.allMatches.single()
+ assertEquals("c1", match.candidateId)
+ assertTrue(match.positionInClip in 190..210)
+ }
+
+ @Test
+ fun `a candidate that does not hold up is discarded, not weakened`() {
+ val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2)
+ val unrelated = noise(99, 500) // the Darknet Diaries case, generalised to midroll
+
+ val result = MidrollDetector.check(listOf(candidate), "e3", unrelated)
+
+ assertTrue(result.candidates.isEmpty())
+ assertTrue(result.detected.isEmpty())
+ }
+
+ @Test
+ fun `several candidates are each judged independently in the same run`() {
+ val good = MidrollDetector.Candidate(id = "good", referenceClip = ad, confirmedCount = 2)
+ val badAd = noise(seed = 8, n = 60)
+ val bad = MidrollDetector.Candidate(id = "bad", referenceClip = badAd, confirmedCount = 2)
+ val episode = embed(noise(1, 500), ad, at = 300) // only `ad` actually appears
+
+ val result = MidrollDetector.check(listOf(good, bad), "e3", episode)
+
+ assertEquals(listOf("good"), result.candidates.map { it.id })
+ assertEquals(listOf("good"), result.detected.map { it.candidateId })
+ }
+
+ @Test
+ fun `reprocessing the same episode id is a no-op, not a double count`() {
+ val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 3, lastConfirmedEpisodeId = "e3")
+ val episode = embed(noise(1, 500), ad, at = 200)
+
+ val result = MidrollDetector.check(listOf(candidate), "e3", episode)
+
+ assertEquals(3, result.candidates.single().confirmedCount)
+ assertTrue("already-processed episodes are not re-detected", result.detected.isEmpty())
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt
new file mode 100644
index 0000000..9a0b091
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt
@@ -0,0 +1,68 @@
+package org.terst.listen.fingerprint
+
+import kotlin.random.Random
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Windows and recordings here are deliberately large (hundreds, not tens, of samples): a short
+ * window's null-distribution of "best correlation somewhere in a few hundred random offsets" has
+ * high enough variance to clear 0.6 by pure chance, which was measured empirically before sizing
+ * these fixtures. Real audio never has this problem -- these sizes exist only so the tests mean
+ * what they say.
+ */
+class MidrollScannerTest {
+
+ private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } }
+
+ private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray {
+ val out = base.copyOf()
+ segment.forEachIndexed { i, v -> out[at + i] = v }
+ return out
+ }
+
+ private val window = 150
+ private val step = 75
+ private val minScore = 0.6
+
+ @Test
+ fun `finds a shared segment at different positions in each recording`() {
+ val ad = noise(seed = 99, n = 220)
+ val a = embed(noise(1, 3000), ad, at = 500)
+ val b = embed(noise(2, 3000), ad, at = 1800)
+
+ val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore)
+
+ assertEquals(1, hits.size)
+ val hit = hits.single()
+ assertTrue("expected positionInA near 500, was ${hit.positionInA}", hit.positionInA in 350..650)
+ assertTrue("expected positionInB near 1800, was ${hit.positionInB}", hit.positionInB in 1650..1950)
+ assertTrue("expected a strong score, was ${hit.score}", hit.score > 0.8)
+ }
+
+ @Test
+ fun `two unrelated recordings share nothing`() {
+ val a = noise(1, 3000)
+ val b = noise(2, 3000)
+
+ val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore)
+
+ assertTrue(hits.isEmpty())
+ }
+
+ @Test
+ fun `two distinct shared segments are reported separately, not merged`() {
+ val first = noise(101, 220)
+ val second = noise(202, 220)
+ val a = embed(embed(noise(1, 3000), first, at = 200), second, at = 2000)
+ val b = embed(embed(noise(2, 3000), first, at = 1000), second, at = 2600)
+
+ val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore)
+
+ assertEquals(2, hits.size)
+ val diagonals = hits.map { it.positionInA - it.positionInB }.sorted()
+ // 200 - 1000 = -800, 2000 - 2600 = -600 -- distinct alignments, not one merged run.
+ assertTrue("diagonals should be well separated, was $diagonals", diagonals[1] - diagonals[0] > step)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt
new file mode 100644
index 0000000..16e4c0d
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt
@@ -0,0 +1,182 @@
+package org.terst.listen.fingerprint
+
+import kotlin.random.Random
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeMidrollDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * Windows here match [MidrollScannerTest]'s sizes (150/75/3000-sample recordings), for the same
+ * reason: a smaller scale has enough null-distribution variance that random noise clears the
+ * score threshold by chance, which was measured empirically before choosing these numbers.
+ */
+class MidrollUpdaterTest {
+
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var subscriptions: FakeSubscriptionDao
+ private lateinit var midrolls: FakeMidrollDao
+ private lateinit var clips: FakeFullClipSource
+ private lateinit var updater: MidrollUpdater
+
+ private val fixedNow = 555_000L
+
+ @Before
+ fun setUp() {
+ episodes = FakeEpisodeDao()
+ subscriptions = FakeSubscriptionDao()
+ midrolls = FakeMidrollDao().apply { episodeRows = episodes.rows }
+ clips = FakeFullClipSource()
+ updater = MidrollUpdater(
+ subscriptions,
+ episodes,
+ midrolls,
+ clips,
+ // No decimation and a MidrollScannerTest-scale window: the coarse-to-fine split is
+ // MidrollUpdater's own concern to compose correctly, not something this test needs
+ // to exercise at real (8kHz-vs-1kHz) scale on top of everything else.
+ MidrollUpdater.Tuning(coarseFactor = 1, discoverySamples = 150, now = { fixedNow }),
+ )
+ }
+
+ private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } }
+
+ private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray {
+ val out = base.copyOf()
+ segment.forEachIndexed { i, v -> out[at + i] = v }
+ return out
+ }
+
+ private fun show(id: String = "show1") {
+ subscriptions.rows += SubscriptionEntity(id = id, feedUrl = "https://feed/$id", title = id, addedAtMs = 0)
+ }
+
+ private fun downloadedEpisode(id: String, downloadedAtMs: Long, durationMs: Long = 600_000): EpisodeEntity {
+ val ep = EpisodeEntity(
+ id = id, subscriptionId = "show1", guid = id, title = "Episode $id",
+ mediaUrl = "https://cdn/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0,
+ localPath = "/fake/$id.mp3", durationMs = durationMs,
+ downloadState = DownloadState.DOWNLOADED, downloadedAtMs = downloadedAtMs,
+ )
+ episodes.rows += ep
+ return ep
+ }
+
+ private val ad = noise(seed = 7, n = 220)
+
+ @Test
+ fun `nothing to do when no episode is unchecked`() = runTest {
+ show()
+ updater.processShow("show1")
+ assertTrue(midrolls.candidateRows.isEmpty())
+ }
+
+ @Test
+ fun `the production tuning defaults construct a working updater`() = runTest {
+ show()
+ val defaultUpdater = MidrollUpdater(subscriptions, episodes, midrolls, clips)
+ defaultUpdater.processShow("show1")
+ assertTrue(midrolls.candidateRows.isEmpty())
+ }
+
+ @Test
+ fun `the first downloaded episode only becomes the pending basis`() = runTest {
+ show()
+ clips.full["e1"] = embed(noise(1, 3000), ad, at = 500)
+ downloadedEpisode("e1", downloadedAtMs = 1)
+
+ updater.processShow("show1")
+
+ assertTrue(midrolls.candidateRows.isEmpty())
+ assertEquals("e1", subscriptions.rows.single().midrollPendingEpisodeId)
+ assertEquals(fixedNow, episodes.rows.single { it.id == "e1" }.midrollCheckedAtMs)
+ }
+
+ @Test
+ fun `a second episode sharing a segment with the pending one seeds a tentative candidate`() = runTest {
+ show()
+ clips.full["e1"] = embed(noise(1, 3000), ad, at = 500)
+ clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800)
+ downloadedEpisode("e1", downloadedAtMs = 1)
+ downloadedEpisode("e2", downloadedAtMs = 2)
+
+ updater.processShow("show1")
+ updater.processShow("show1")
+
+ val candidate = midrolls.candidateRows.single()
+ assertEquals(2, candidate.confirmedCount)
+ assertEquals("e2", candidate.lastConfirmedEpisodeId)
+ }
+
+ @Test
+ fun `a third independent episode confirms the candidate and records a skip range`() = runTest {
+ show()
+ clips.full["e1"] = embed(noise(1, 3000), ad, at = 500)
+ clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800)
+ clips.full["e3"] = embed(noise(3, 3000), ad, at = 1000)
+ downloadedEpisode("e1", downloadedAtMs = 1)
+ downloadedEpisode("e2", downloadedAtMs = 2)
+ downloadedEpisode("e3", downloadedAtMs = 3)
+
+ updater.processShow("show1")
+ updater.processShow("show1")
+ updater.processShow("show1")
+
+ val candidate = midrolls.candidateRows.single()
+ assertEquals(3, candidate.confirmedCount)
+
+ val range = midrolls.rangeRows.single { it.episodeId == "e3" }
+ val expectedStartMs = 1000L * 1000 / clips.sampleRate
+ val toleranceMs = 300L * 1000 / clips.sampleRate
+ assertTrue(
+ "expected startMs near $expectedStartMs, was ${range.startMs}",
+ range.startMs in (expectedStartMs - toleranceMs)..(expectedStartMs + toleranceMs),
+ )
+ assertTrue(range.endMs > range.startMs)
+ }
+
+ @Test
+ fun `an episode lacking the confirmed segment discards it instead of weakening it`() = runTest {
+ show()
+ clips.full["e1"] = embed(noise(1, 3000), ad, at = 500)
+ clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800)
+ clips.full["e3"] = noise(99, 3000) // the Darknet Diaries case, generalised to midroll
+ downloadedEpisode("e1", downloadedAtMs = 1)
+ downloadedEpisode("e2", downloadedAtMs = 2)
+ downloadedEpisode("e3", downloadedAtMs = 3)
+
+ updater.processShow("show1")
+ updater.processShow("show1")
+ updater.processShow("show1")
+
+ assertTrue("the false candidate must be discarded", midrolls.candidateRows.isEmpty())
+ assertTrue(midrolls.rangeRows.isEmpty())
+ }
+
+ @Test
+ fun `an episode that fails to decode is marked checked so it is not retried forever`() = runTest {
+ show()
+ downloadedEpisode("e1", downloadedAtMs = 1) // no clip seeded -- FakeFullClipSource returns null
+
+ updater.processShow("show1")
+
+ assertEquals(fixedNow, episodes.rows.single().midrollCheckedAtMs)
+ }
+
+ private class FakeFullClipSource : AudioClipSource {
+ val full = mutableMapOf<String, DoubleArray>()
+ val sampleRate = 8_000
+
+ override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? = null
+ override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? = null
+ override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? =
+ full[episode.id]?.let { DecodedClip(it, sampleRate, startMs = 0) }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt b/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt
new file mode 100644
index 0000000..9e8aea3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt
@@ -0,0 +1,46 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.data.EpisodeMidrollEntity
+
+class MidrollSkipTest {
+
+ private fun range(startMs: Long, endMs: Long) = EpisodeMidrollEntity("e1", "c1", startMs, endMs)
+
+ @Test
+ fun `no ranges means nothing to skip`() {
+ assertNull(MidrollSkip.skipTarget(emptyList(), positionMs = 5_000))
+ }
+
+ @Test
+ fun `before a range does nothing`() {
+ val ranges = listOf(range(60_000, 90_000))
+ assertNull(MidrollSkip.skipTarget(ranges, positionMs = 10_000))
+ }
+
+ @Test
+ fun `inside a range jumps to its end`() {
+ val ranges = listOf(range(60_000, 90_000))
+ assertEquals(90_000L, MidrollSkip.skipTarget(ranges, positionMs = 75_000))
+ }
+
+ @Test
+ fun `exactly at the start of a range jumps to its end`() {
+ val ranges = listOf(range(60_000, 90_000))
+ assertEquals(90_000L, MidrollSkip.skipTarget(ranges, positionMs = 60_000))
+ }
+
+ @Test
+ fun `after a range does nothing`() {
+ val ranges = listOf(range(60_000, 90_000))
+ assertNull(MidrollSkip.skipTarget(ranges, positionMs = 90_000))
+ }
+
+ @Test
+ fun `the right one of several non-overlapping ranges is picked`() {
+ val ranges = listOf(range(60_000, 90_000), range(600_000, 630_000))
+ assertEquals(630_000L, MidrollSkip.skipTarget(ranges, positionMs = 610_000))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
index 171114d..a3b5b0d 100644
--- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
@@ -7,6 +7,7 @@ import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.FakeEpisodeDao
import org.terst.listen.data.FakeQueueDao
import org.terst.listen.data.FakeSubscriptionDao
@@ -103,6 +104,8 @@ class PlaybackAdvancerTest {
return chapters
}
+ override suspend fun midrolls(episode: EpisodeEntity) = emptyList<EpisodeMidrollEntity>()
+
override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) =
onFetch(episode)
}
diff --git a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
index 92ab8fe..a7ddf77 100644
--- a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt
@@ -2,6 +2,7 @@ package org.terst.listen.playback
import org.junit.Assert.assertEquals
import org.junit.Test
+import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.feed.Chapter
@@ -32,9 +33,10 @@ class PlayheadPolicyTest {
private fun decide(
show: SubscriptionEntity? = this.show,
chapters: List<Chapter> = emptyList(),
+ midrolls: List<EpisodeMidrollEntity> = emptyList(),
positionMs: Long,
durationMs: Long? = 30 * minute,
- ) = PlayheadPolicy.decide(PlayingContext(show, chapters), positionMs, durationMs, progress)
+ ) = PlayheadPolicy.decide(PlayingContext(show, chapters, midrolls = midrolls), positionMs, durationMs, progress)
@Test
fun `mid-episode, with nothing configured, there is nothing to do`() {
@@ -89,6 +91,26 @@ class PlayheadPolicyTest {
)
}
+ @Test
+ fun `inside a detected midroll break it seeks past it`() {
+ val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 12 * minute, 13 * minute))
+ assertEquals(
+ TickAction.SeekTo(13 * minute),
+ decide(midrolls = midrolls, positionMs = 12 * minute + 20_000),
+ )
+ }
+
+ /** Unlike ad-chapter skipping there is no per-show opt-out -- same as intro/outro, Tier 3's
+ * confirmation threshold is the safety mechanism, not an extra toggle nobody would flip. */
+ @Test
+ fun `a detected midroll break is skipped even though no toggle was set`() {
+ val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 12 * minute, 13 * minute))
+ assertEquals(
+ TickAction.SeekTo(13 * minute),
+ decide(show = show.copy(skipAdChapters = false), midrolls = midrolls, positionMs = 12 * minute),
+ )
+ }
+
/**
* Finishing beats skipping.
*
@@ -111,4 +133,18 @@ class PlayheadPolicyTest {
),
)
}
+
+ /** Same precedence as a marked break sitting on the finish line, for the same reason. */
+ @Test
+ fun `the outro wins when a detected midroll break sits on the finish line`() {
+ val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 18 * minute, 22 * minute))
+ assertEquals(
+ TickAction.Finish,
+ decide(
+ show = show.copy(skipOutroMs = 10 * minute),
+ midrolls = midrolls,
+ positionMs = 19 * minute + 40_000,
+ ),
+ )
+ }
}