diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-20 06:48:48 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-20 06:48:48 +0000 |
| commit | 9cc92e743dda2609a67595439c4694f6a4efae27 (patch) | |
| tree | 7f200ad16613a33d31e0e01e4123ddaa987d3f09 | |
| parent | f087b8d15f38bc5500aa2ee986865557460ec89c (diff) | |
Let the listener validate or invalidate a detected break from a popup
Keeps partners on episode_midrolls (schema 15), stores verdicts in their own
break_verdicts table so a server refresh cannot wipe them, and drops invalidated
breaks from skipping on the next tick. Verdicts are local only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
29 files changed, 2086 insertions, 16 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 94f785c..afce29c 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -94.15 +94.34 diff --git a/app/schemas/org.terst.listen.data.AppDatabase/15.json b/app/schemas/org.terst.listen.data.AppDatabase/15.json new file mode 100644 index 0000000..098455b --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/15.json @@ -0,0 +1,842 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "c5a87d01610eb177f97526990c26a4d2", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, `midrollPendingEpisodeId` TEXT, `bedtime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feedUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAtMs", + "columnName": "addedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFetchedAtMs", + "columnName": "lastFetchedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "playbackSpeed", + "columnName": "playbackSpeed", + "affinity": "REAL" + }, + { + "fieldPath": "skipIntroMs", + "columnName": "skipIntroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipOutroMs", + "columnName": "skipOutroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dripEnabled", + "columnName": "dripEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripIntervalMs", + "columnName": "dripIntervalMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "86400000" + }, + { + "fieldPath": "dripCount", + "columnName": "dripCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripOldestFirst", + "columnName": "dripOldestFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripLastReleaseAtMs", + "columnName": "dripLastReleaseAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "skipAdChapters", + "columnName": "skipAdChapters", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "midrollPendingEpisodeId", + "columnName": "midrollPendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "bedtime", + "columnName": "bedtime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `actualDurationMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `audioFromServer` INTEGER NOT NULL DEFAULT 0, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "actualDurationMs", + "columnName": "actualDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioFromServer", + "columnName": "audioFromServer", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "midrollCheckedAtMs", + "columnName": "midrollCheckedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "show_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPath` TEXT 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 UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPath", + "columnName": "referenceClipPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "id" + ] + }, + "indices": [ + { + "name": "index_show_midrolls_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "candidateId", + "columnName": "candidateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "candidateId" + ] + }, + "indices": [ + { + "name": "index_episode_midrolls_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "break_verdicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`, `endMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "verdict", + "columnName": "verdict", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decidedAtMs", + "columnName": "decidedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs", + "endMs" + ] + }, + "indices": [ + { + "name": "index_break_verdicts_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c5a87d01610eb177f97526990c26a4d2')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt b/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt new file mode 100644 index 0000000..6a98c32 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/data/BreakVerdictsDbTest.kt @@ -0,0 +1,57 @@ +package org.terst.listen.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.terst.listen.playback.BreakVerdicts +import org.terst.listen.playback.VerdictRecorder + +/** + * A verdict against the real tables: the server deleting and re-inserting an episode's ranges + * must not touch it, which is the whole reason it has a table of its own. + */ +@RunWith(AndroidJUnit4::class) +class BreakVerdictsDbTest { + + private lateinit var db: AppDatabase + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + } + + @After + fun tearDown() = db.close() + + @Test + fun aVerdictSurvivesTheRangesBeingReplacedAndStillMatches() = runBlocking { + db.subscriptions().insert(SubscriptionEntity(id = "s1", feedUrl = "https://x/f", title = "S", addedAtMs = 0)) + db.episodes().insertAll(listOf( + EpisodeEntity( + id = "e1", subscriptionId = "s1", guid = "g", title = "t", mediaUrl = "u", + mediaType = "audio/mpeg", discoveredAtMs = 0, + ) + )) + val midrolls = db.midrolls() + midrolls.upsertRange(EpisodeMidrollEntity("e1", "server-0", 60_000, 90_000, "ad", partners = 3)) + VerdictRecorder(db.breakVerdicts()) { 9L }.record(midrolls.getRanges("e1").single(), BreakVerdicts.INVALID) + + // What ServerSkips does on every refresh, with the edges moved a little. + midrolls.deleteRanges("e1") + midrolls.upsertRange(EpisodeMidrollEntity("e1", "server-0", 60_900, 89_500, "ad", partners = 4)) + + val verdicts = db.breakVerdicts().getForEpisode("e1") + assertEquals(1, verdicts.size) + assertEquals(BreakVerdicts.INVALID, BreakVerdicts.forRange(verdicts, midrolls.getRanges("e1").single())?.verdict) + assertEquals("the evidence at the time of the verdict is kept", 3, verdicts.single().partners) + } +} 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 5352db8..0124562 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -595,4 +595,62 @@ class MigrationTest { assertEquals("/audio/e1.mp3", it.getString(2)) } } + + /** + * Existing breaks keep their rows and gain a partner count of 0 -- unknown, which is the + * truth: the phone discarded the figure when it was written. The verdict table arrives empty + * and accepts a row, and the crown jewel beside it is untouched. + */ + @Test + fun migrate14To15KeepsBreaksDefaultsPartnersToUnknownAndAddsVerdicts() { + helper.createDatabase(name, 14).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_midrolls (episodeId, candidateId, startMs, endMs, kind) " + + "VALUES ('e1', 'server-0', 1000, 2000, 'cue')" + ) + close() + } + + val db = helper.runMigrationsAndValidate(name, 15, true, AppDatabase.MIGRATION_14_15) + + db.query("SELECT startMs, endMs, kind, partners FROM episode_midrolls WHERE episodeId = 'e1'").use { + assertEquals("the break was lost", 1, it.count) + it.moveToFirst() + assertEquals(1000L, it.getLong(0)) + assertEquals(2000L, it.getLong(1)) + assertEquals("cue", it.getString(2)) + assertEquals("an old break's partner count is unknown, not invented", 0, it.getInt(3)) + } + db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { + it.moveToFirst() + assertEquals("the crown jewel must survive", 161803L, it.getLong(0)) + } + db.query("SELECT COUNT(*) FROM break_verdicts").use { + it.moveToFirst() + assertEquals(0, it.getInt(0)) + } + db.execSQL( + "INSERT INTO break_verdicts (episodeId, startMs, endMs, verdict, decidedAtMs) " + + "VALUES ('e1', 1000, 2000, 'invalid', 5)" + ) + db.query("SELECT kind, partners FROM break_verdicts").use { + it.moveToFirst() + assertEquals("ad", it.getString(0)) + assertEquals(0, it.getInt(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 73fa38c..3d5d54e 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -29,8 +29,9 @@ class Converters { ShowFingerprintEntity::class, ShowMidrollEntity::class, EpisodeMidrollEntity::class, + BreakVerdictEntity::class, ], - version = 14, + version = 15, exportSchema = true, ) @TypeConverters(Converters::class) @@ -42,6 +43,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun rules(): RuleDao abstract fun fingerprints(): ShowFingerprintDao abstract fun midrolls(): MidrollDao + abstract fun breakVerdicts(): BreakVerdictDao companion object { @Volatile private var instance: AppDatabase? = null @@ -378,6 +380,29 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Keeps the evidence behind each break, and adds the table listener verdicts go in. + * + * `partners` defaults to 0, meaning unknown: an existing row was written before the + * phone kept it, and 0 is the answer that makes no claim. The verdict table is new and + * empty, and has no foreign key on purpose -- see [BreakVerdictEntity]. + */ + val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `episode_midrolls` ADD COLUMN `partners` INTEGER NOT NULL DEFAULT 0") + db.execSQL( + """CREATE TABLE IF NOT EXISTS `break_verdicts` ( + `episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, + `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, + `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`episodeId`, `startMs`, `endMs`))""" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `break_verdicts` (`episodeId`)" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -390,6 +415,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, 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, ) .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 2ab6041..bdbc28e 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -521,3 +521,16 @@ interface QueueDao { upsertAll(episodeIds.mapIndexed { index, id -> QueueEntity(id, index) }) } } + +/** Verdicts the listener has given detected breaks; see [BreakVerdictEntity]. */ +@Dao +interface BreakVerdictDao { + @Query("SELECT * FROM break_verdicts WHERE episodeId = :episodeId ORDER BY startMs") + suspend fun getForEpisode(episodeId: String): List<BreakVerdictEntity> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(verdict: BreakVerdictEntity) + + @Query("DELETE FROM break_verdicts WHERE episodeId = :episodeId AND startMs = :startMs AND endMs = :endMs") + suspend fun delete(episodeId: String, startMs: Long, endMs: Long) +} 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 8db5a8b..4ae9faa 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -470,6 +470,41 @@ data class EpisodeMidrollEntity( * default preserves exactly what those rows already did. */ @ColumnInfo(defaultValue = "ad") val kind: String = "ad", + /** + * How many other episodes carried this same audio -- the evidence behind the break, kept so + * the episode screen can say why it was detected. 0 means unknown, not "none": rows from + * before this column existed, and a server that did not say, both read as 0. + */ + @ColumnInfo(defaultValue = "0") val partners: Int = 0, +) + +/** + * A listener's verdict on one detected break: this is right, or this is wrong. + * + * Its own table rather than columns on [EpisodeMidrollEntity], because the server is the + * authority on an episode's ranges and `ServerSkips` deletes and re-inserts every one of them on + * each refresh. A verdict stored on a range would be wiped by the next refresh. This one is + * keyed by position instead, and [BreakVerdicts] re-matches it to whatever the ranges become. + * + * Deliberately no foreign key to the episode: a verdict is a label somebody gave, and the corpus + * should outlive the episode row that happened to be on the phone when it was given. [kind] and + * [partners] are what the detector claimed at that moment, so the label stands on its own after + * the range it was about has been replaced. + */ +@Entity( + tableName = "break_verdicts", + primaryKeys = ["episodeId", "startMs", "endMs"], + indices = [Index(value = ["episodeId"])], +) +data class BreakVerdictEntity( + val episodeId: String, + val startMs: Long, + val endMs: Long, + /** [BreakVerdicts.VALID] or [BreakVerdicts.INVALID]. */ + val verdict: String, + val decidedAtMs: Long, + @ColumnInfo(defaultValue = "ad") val kind: String = "ad", + @ColumnInfo(defaultValue = "0") val partners: Int = 0, ) /** 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 476a7e3..0cee350 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -21,6 +21,7 @@ import org.terst.listen.fingerprint.FileMidrollClipStore import org.terst.listen.fingerprint.MidrollClipStore import org.terst.listen.playback.PlaybackAdvancer import org.terst.listen.playback.PlaybackSources +import org.terst.listen.playback.VerdictRecorder import org.terst.listen.queue.DripReleaser import org.terst.listen.queue.QueueOrdering import org.terst.listen.queue.RetentionPolicy @@ -66,6 +67,7 @@ class AppGraph(private val context: Context) { val ruleDao: RuleDao get() = db.rules() val fingerprints: ShowFingerprintDao get() = db.fingerprints() val midrolls: MidrollDao get() = db.midrolls() + val breakVerdicts: BreakVerdictDao get() = db.breakVerdicts() val libraryDir: File by lazy { File(context.filesDir, "audio").also { it.mkdirs() } @@ -97,6 +99,7 @@ class AppGraph(private val context: Context) { 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 verdicts(episode: EpisodeEntity) = breakVerdicts.getForEpisode(episode.id) override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) = downloader.download(episode, kind).map { } // DataStore's flow can throw (CorruptionException, IOException) if the preferences @@ -144,6 +147,8 @@ class AppGraph(private val context: Context) { }) } + val verdictRecorder: VerdictRecorder by lazy { VerdictRecorder(breakVerdicts) } + val repository: SubscriptionRepository by lazy { SubscriptionRepository( subscriptions, diff --git a/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt b/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt new file mode 100644 index 0000000..617e631 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/BreakPopup.kt @@ -0,0 +1,87 @@ +package org.terst.listen.fingerprint + +import org.terst.listen.playback.BreakVerdicts + +/** + * The words in the popup a tap on a break opens. + * + * Here rather than in the composable for the reason [SkipSummary] is: the wording is the + * feature. The line he most needs -- whether this will actually be skipped -- was never said + * anywhere, and the "why" has to read as a reason, because a bare count says nothing about + * whether to trust it. + */ +object BreakPopup { + + private const val MS_PER_SECOND = 1000L + private const val SECONDS_PER_MINUTE = 60L + + /** Everything the popup prints, one string per line. */ + data class Content( + val title: String, + val timing: String, + val why: String, + val willSkip: String, + /** What the listener has already said about this break, or null if nothing. */ + val verdict: String?, + val validateLabel: String, + val invalidateLabel: String, + ) + + fun content(mark: SkipSummary.BreakMarker, skipFurniture: Boolean, verdict: String?): Content = + Content( + title = SkipSummary.describeKind(mark.kind), + timing = "Runs ${SkipSummary.clock(mark.startMs)} - ${SkipSummary.clock(mark.endMs)}, " + + "${length(mark.endMs - mark.startMs)} long.", + why = why(mark), + willSkip = willSkip(mark.kind, skipFurniture, verdict), + verdict = when (verdict) { + BreakVerdicts.VALID -> "You confirmed this one." + BreakVerdicts.INVALID -> "You marked this one wrong." + else -> null + }, + validateLabel = if (SkipSummary.isAdvertising(mark.kind)) { + "Yes, this is advertising" + } else { + "Yes, this is the show's own audio" + }, + invalidateLabel = "No, this is the programme", + ) + + /** + * Why the detector flagged it. Phrased as what that agreement looks like, because the same + * fact means different things: audio repeated across episodes is how inserted advertising + * looks, and equally how a recurring intro does. + * + * 0 is "the server did not say", never "no other episode" -- it is also what every break + * stored before the phone kept the count reads as, so claiming otherwise would be a lie. + */ + private fun why(mark: SkipSummary.BreakMarker): String { + if (mark.partners <= 0) return "The server did not say how many other episodes carried this audio." + val episodes = if (mark.partners == 1) "1 other episode" else "${mark.partners} other episodes" + val looksLike = when (mark.kind) { + "intro" -> "a recurring intro looks" + "cue" -> "a recurring break cue looks" + "outro" -> "recurring credits look" + else -> "inserted advertising looks" + } + return "The same audio is in $episodes of this show, which is how $looksLike." + } + + private fun willSkip(kind: String, skipFurniture: Boolean, verdict: String?): String = when { + verdict == BreakVerdicts.INVALID -> "Will not be skipped. You marked it as the programme." + SkipSummary.isAdvertising(kind) -> "Will be skipped. Advertising always is." + skipFurniture -> "Will be skipped, because skipping a show's own intros, cues and credits is on." + else -> "Will not be skipped. It is the show's own audio, and skipping that is off." + } + + private fun length(ms: Long): String { + val seconds = ms / MS_PER_SECOND + val minutes = seconds / SECONDS_PER_MINUTE + val rest = seconds % SECONDS_PER_MINUTE + return when { + minutes == 0L -> "$rest s" + rest == 0L -> "$minutes min" + else -> "$minutes min $rest s" + } + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt index d42d2a8..3f5d04b 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt @@ -60,7 +60,16 @@ object SkipSummary { private const val MINUTES_PER_HOUR = 60 /** Where one detected break sits in an episode, as fractions of its length, and what it is. */ - data class BreakMarker(val start: Float, val end: Float, val kind: String) + data class BreakMarker( + val start: Float, + val end: Float, + val kind: String, + /** The range in milliseconds as stored, unclamped: what a verdict is matched on. */ + val startMs: Long = 0, + val endMs: Long = 0, + /** How many other episodes carried this audio; 0 is unknown. */ + val partners: Int = 0, + ) /** * The detected breaks as positions on a 0..1 timeline, for the episode screen's scrubber. @@ -78,6 +87,9 @@ object SkipSummary { start = (it.startMs.toFloat() / durationMs).coerceIn(0f, 1f), end = (it.endMs.toFloat() / durationMs).coerceIn(0f, 1f), kind = it.kind, + startMs = it.startMs, + endMs = it.endMs, + partners = it.partners, ) } } diff --git a/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt b/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt new file mode 100644 index 0000000..bb33f1c --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/BreakVerdicts.kt @@ -0,0 +1,33 @@ +package org.terst.listen.playback + +import org.terst.listen.data.BreakVerdictEntity +import org.terst.listen.data.EpisodeMidrollEntity +import kotlin.math.abs + +/** + * Matching a listener's verdict back to the break it was about, and acting on it. + */ +object BreakVerdicts { + const val VALID = "valid" + const val INVALID = "invalid" + + /** How far each edge of a break may have moved between passes and still be the same break. */ + const val TOLERANCE_MS = 2_000L + + /** The verdict recorded for the break at [startMs]..[endMs], or null. */ + fun matching(verdicts: List<BreakVerdictEntity>, startMs: Long, endMs: Long): BreakVerdictEntity? = + verdicts + .filter { abs(it.startMs - startMs) <= TOLERANCE_MS && abs(it.endMs - endMs) <= TOLERANCE_MS } + .minByOrNull { abs(it.startMs - startMs) + abs(it.endMs - endMs) } + + /** The verdict for [range], or null when the listener has not judged it. */ + fun forRange(verdicts: List<BreakVerdictEntity>, range: EpisodeMidrollEntity): BreakVerdictEntity? = + matching(verdicts, range.startMs, range.endMs) + + /** [ranges] without the ones the listener marked wrong. */ + fun withoutInvalidated( + ranges: List<EpisodeMidrollEntity>, + verdicts: List<BreakVerdictEntity>, + ): List<EpisodeMidrollEntity> = + if (verdicts.isEmpty()) ranges else ranges.filterNot { forRange(verdicts, it)?.verdict == INVALID } +} diff --git a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt index 32b2144..d5bbca3 100644 --- a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt +++ b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt @@ -1,5 +1,6 @@ package org.terst.listen.playback +import org.terst.listen.data.BreakVerdictEntity import org.terst.listen.data.EpisodeMidrollEntity /** @@ -22,6 +23,10 @@ object MidrollSkip { /** * Where to jump to if the playhead has entered a break that should be skipped. * + * A break the listener marked wrong in [verdicts] is not skipped, whatever its kind: that is + * the correction channel, and it has to take effect on the next tick. Marking one right + * changes nothing -- it records agreement. + * * Advertising is skipped whatever [skipFurniture] says. The show's own intro, break cue and * credits are skipped only when asked for. */ @@ -29,7 +34,8 @@ object MidrollSkip { ranges: List<EpisodeMidrollEntity>, positionMs: Long, skipFurniture: Boolean, - ): Long? = activeBreak(ranges, positionMs, skipFurniture)?.endMs?.takeIf { it > positionMs } + verdicts: List<BreakVerdictEntity> = emptyList(), + ): Long? = activeBreak(ranges, positionMs, skipFurniture, verdicts)?.endMs?.takeIf { it > positionMs } /** * Whether the playhead is inside a break that should be acted on right now, without working @@ -43,14 +49,16 @@ object MidrollSkip { ranges: List<EpisodeMidrollEntity>, positionMs: Long, skipFurniture: Boolean, - ): Boolean = activeBreak(ranges, positionMs, skipFurniture) != null + verdicts: List<BreakVerdictEntity> = emptyList(), + ): Boolean = activeBreak(ranges, positionMs, skipFurniture, verdicts) != null private fun activeBreak( ranges: List<EpisodeMidrollEntity>, positionMs: Long, skipFurniture: Boolean, + verdicts: List<BreakVerdictEntity>, ): EpisodeMidrollEntity? = - ranges.asSequence() + BreakVerdicts.withoutInvalidated(ranges, verdicts).asSequence() .filter { skipFurniture || !isShowsOwnAudio(it.kind) } .firstOrNull { positionMs >= it.startMs && positionMs < it.endMs } 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 50f172c..9007c51 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt @@ -1,5 +1,6 @@ package org.terst.listen.playback +import org.terst.listen.data.BreakVerdictEntity import org.terst.listen.data.EpisodeDao import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.EpisodeMidrollEntity @@ -21,6 +22,8 @@ data class PlayingContext( val skipFurniture: Boolean = true, /** Whether a break that is due to be acted on is played fast instead of being skipped. */ val fastPlayBreaks: Boolean = false, + /** What the listener has said about this episode's breaks -- see [BreakVerdicts]. */ + val verdicts: List<BreakVerdictEntity> = emptyList(), ) { /** The episode's normal speed: a rule's choice, else the show's, else the global default. */ val speed: Float get() = PlaybackSpeed.forEpisode(episode?.speedOverride, show?.playbackSpeed) @@ -38,6 +41,9 @@ interface PlaybackSources { suspend fun midrolls(episode: EpisodeEntity): List<EpisodeMidrollEntity> + /** The listener's verdicts on this episode's breaks, re-read as they are given. */ + suspend fun verdicts(episode: EpisodeEntity): List<BreakVerdictEntity> = emptyList() + suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind): Result<Unit> /** Whether a show's own intro, cue and credits are skipped as well as the advertising. */ @@ -131,10 +137,19 @@ class PlaybackAdvancer( sources.midrolls(episode), sources.skipFurniture(), sources.fastPlayBreaks(), + sources.verdicts(episode), ) } /** + * The same context with the listener's current verdicts, for the ticker to call before each + * decision. Verdicts are the one input here that changes while an episode plays -- marking a + * break wrong has to stop it being skipped from the next tick, not the next episode. + */ + suspend fun withFreshVerdicts(context: PlayingContext): PlayingContext = + context.episode?.let { context.copy(verdicts = sources.verdicts(it)) } ?: context + + /** * Records an episode as finished and reports what plays next, or null if nothing does. * * Marking played happens before advancing: advance rewrites the queue, and the other order diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt index ef4be31..603ec5b 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt @@ -195,8 +195,19 @@ class PlaybackService : MediaLibraryService() { */ private suspend fun applyPlayheadPolicy() { val exo = player ?: return + // Re-read every tick, unlike the rest of the context: a break marked wrong has to stop + // being skipped from the next tick, and this is a tiny indexed read of a handful of rows. + // Used for this decision only, never stored back over `context`, which a fast episode + // switch may have replaced while the read was in flight. + val ticking = try { + advancer?.withFreshVerdicts(context) ?: context + } catch (e: android.database.sqlite.SQLiteException) { + // A failed read must not stop the ticker; the last known verdicts still apply. + android.util.Log.w(TAG, "verdicts unreadable this tick", e) + context + } val action = PlayheadPolicy.decide( - context = context, + context = ticking, positionMs = exo.currentPosition, durationMs = exo.duration.takeIf { it > 0 }, progress = progress, 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 f814034..0fac437 100644 --- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt +++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt @@ -73,9 +73,10 @@ object PlayheadPolicy { private fun breakAction(context: PlayingContext, positionMs: Long): TickAction? = if (context.fastPlayBreaks) { TickAction.SetSpeed(FAST_BREAK_SPEED) - .takeIf { MidrollSkip.isActedOnBreak(context.midrolls, positionMs, context.skipFurniture) } + .takeIf { MidrollSkip.isActedOnBreak(context.midrolls, positionMs, context.skipFurniture, context.verdicts) } } else { - MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture)?.let(TickAction::SeekTo) + MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture, context.verdicts) + ?.let(TickAction::SeekTo) } private fun chapterAction( diff --git a/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt b/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt new file mode 100644 index 0000000..d89762f --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/VerdictRecorder.kt @@ -0,0 +1,33 @@ +package org.terst.listen.playback + +import org.terst.listen.data.BreakVerdictDao +import org.terst.listen.data.BreakVerdictEntity +import org.terst.listen.data.EpisodeMidrollEntity + +/** + * Writes down a verdict. + * + * Replaces whatever verdict already matches the break rather than adding a second: boundaries + * drift between passes, so changing your mind about a break whose edges have since moved would + * otherwise leave two rows disagreeing about one stretch of audio. + */ +class VerdictRecorder( + private val dao: BreakVerdictDao, + private val now: () -> Long = System::currentTimeMillis, +) { + suspend fun record(range: EpisodeMidrollEntity, verdict: String) { + BreakVerdicts.forRange(dao.getForEpisode(range.episodeId), range) + ?.let { dao.delete(it.episodeId, it.startMs, it.endMs) } + dao.upsert( + BreakVerdictEntity( + episodeId = range.episodeId, + startMs = range.startMs, + endMs = range.endMs, + verdict = verdict, + decidedAtMs = now(), + kind = range.kind, + partners = range.partners, + ) + ) + } +} 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 608521a..bb8d2e1 100644 --- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt +++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt @@ -87,6 +87,7 @@ class ServerSkips( startMs = range.startMs, endMs = range.endMs, kind = range.kind, + partners = range.partners, ) ) } 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 990b758..4872ab9 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -59,6 +59,8 @@ data class ServerBreak( @SerialName("startMs") val startMs: Long = 0, @SerialName("endMs") val endMs: Long = 0, val kind: String = "ad", + /** How many other episodes carried this audio; 0 when the server does not say. */ + val partners: Int = 0, ) /** What the server knows about one episode. */ diff --git a/app/src/main/java/org/terst/listen/ui/BreakJudging.kt b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt new file mode 100644 index 0000000..bc9c802 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/BreakJudging.kt @@ -0,0 +1,115 @@ +package org.terst.listen.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.terst.listen.data.BreakVerdictEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.AppGraph +import org.terst.listen.fingerprint.BreakPopup +import org.terst.listen.fingerprint.SkipSummary +import org.terst.listen.playback.BreakVerdicts + +/** + * What the break popup needs beyond the break itself: what has already been said about this + * episode's breaks, whether the show's own audio is being skipped, and how to say something. + */ +class BreakJudging( + val verdicts: List<BreakVerdictEntity>, + val skipFurniture: Boolean, + val judge: (SkipSummary.BreakMarker, String) -> Unit, +) { + companion object { + val NONE = BreakJudging(emptyList(), true) { _, _ -> } + } +} + +/** + * What the break popup reads and writes, apart from [ListenViewModel] so that class does not grow + * a function per feature. + */ +class BreakJudgingSource(private val graph: AppGraph) { + /** What the listener has said about this episode's detected breaks. */ + suspend fun verdicts(episodeId: String) = graph.breakVerdicts.getForEpisode(episodeId) + + /** Records "that break is right" or "that break is wrong"; see [BreakVerdicts]. */ + suspend fun record(range: EpisodeMidrollEntity, verdict: String) = graph.verdictRecorder.record(range, verdict) + + /** Whether the show's own intros, cues and credits are skipped, which the popup reports. */ + val skipShowFurniture = graph.preferences.skipShowFurniture +} + +/** + * Loads an episode's verdicts and records new ones. The recording is a plain database write the + * playback service re-reads every tick, so nothing here has to tell the player anything. + */ +@Composable +internal fun rememberBreakJudging( + vm: ListenViewModel, + episodeId: String, + ranges: List<EpisodeMidrollEntity>, +): BreakJudging { + var verdicts by remember { mutableStateOf(emptyList<BreakVerdictEntity>()) } + val skipFurniture by vm.breakJudging.skipShowFurniture.collectAsState(initial = true) + val scope = rememberCoroutineScope() + LaunchedEffect(episodeId) { verdicts = vm.breakJudging.verdicts(episodeId) } + return BreakJudging(verdicts, skipFurniture) { mark, verdict -> + // The stored range this mark was drawn from, not a rebuilt one: the verdict carries the + // detector's own kind and partner count as evidence. + val range = ranges.firstOrNull { it.startMs == mark.startMs && it.endMs == mark.endMs } + if (range != null) { + scope.launch { + vm.breakJudging.record(range, verdict) + verdicts = vm.breakJudging.verdicts(episodeId) + } + } + } +} + +/** The popup a tap on a break opens. Every word in it comes from [BreakPopup]. */ +@Composable +internal fun BreakDialog( + mark: SkipSummary.BreakMarker, + judging: BreakJudging, + onSeek: () -> Unit, + onDismiss: () -> Unit, +) { + val verdict = BreakVerdicts.matching(judging.verdicts, mark.startMs, mark.endMs)?.verdict + val content = BreakPopup.content(mark, judging.skipFurniture, verdict) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(content.title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(content.timing, style = MaterialTheme.typography.bodyMedium) + Text(content.why, style = MaterialTheme.typography.bodyMedium) + Text(content.willSkip, style = MaterialTheme.typography.bodyMedium) + content.verdict?.let { Text(it, style = MaterialTheme.typography.titleSmall) } + DialogAction("Seek here") { onSeek(); onDismiss() } + DialogAction(content.validateLabel) { judging.judge(mark, BreakVerdicts.VALID) } + DialogAction(content.invalidateLabel) { judging.judge(mark, BreakVerdicts.INVALID) } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +@Composable +private fun DialogAction(label: String, onClick: () -> Unit) { + TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) { Text(label) } +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index ac0b99f..e9c0db8 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -73,6 +73,7 @@ fun EpisodeScreen( } val scope = rememberCoroutineScope() val rules by vm.rules.collectAsState() + val judging = rememberBreakJudging(vm, episodeId, ranges) val nowPlaying by player.nowPlaying.collectAsState() val showTitle = vm.subscriptions.collectAsState().value @@ -113,7 +114,7 @@ fun EpisodeScreen( ranges = ranges, nowPlaying = nowPlaying, speed = speed, - ), + ).withJudging(judging), modifier = Modifier.padding(padding), actions = episodeActions(vm, player, current, nowPlaying, hooks), ) @@ -281,6 +282,10 @@ private fun episodeDetailState( ) } +/** The same state with the break popup's verdicts and actions filled in. */ +private fun EpisodeDetailState.withJudging(judging: BreakJudging) = + copy(playback = playback.copy(judging = judging)) + /** What the episode screen's transport controls act on. */ data class EpisodePlayback( val positionMs: Long, @@ -288,6 +293,8 @@ data class EpisodePlayback( val isPlaying: Boolean, /** Detected ad breaks as fractions of the episode -- see [SkipSummary.breakMarkers]. */ val breaks: List<SkipSummary.BreakMarker>, + /** What the break popup shows and does -- see [BreakJudging]. */ + val judging: BreakJudging = BreakJudging.NONE, ) /** diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt index 87ccfac..cd8c4b6 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt @@ -59,7 +59,7 @@ fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) { enabled = duration > 0, ) } - BreakMarks(playback.breaks, duration) { actions.seekTo(it) } + BreakMarks(playback.breaks, duration, playback.judging) { actions.seekTo(it) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall) Text(formatClock(duration), style = MaterialTheme.typography.bodySmall) @@ -75,14 +75,21 @@ fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) { * not silently invert which colour means advertising. A kind this version does not recognise * draws as advertising too, matching what playback does with it. * - * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than - * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a - * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels. + * Tapping a band names it and opens [BreakDialog], whose "seek here" is the old tap behaviour. + * The strip is its own, taller target rather than a hairline behind the slider, because a break + * is a few pixels wide and a finger is not; what a tap hits is decided by [BreakTrack.markAt], + * so the composable only converts pixels. */ @Composable -private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) { +private fun BreakMarks( + breaks: List<SkipSummary.BreakMarker>, + durationMs: Long, + judging: BreakJudging, + onSeek: (Long) -> Unit, +) { if (breaks.isEmpty()) return var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) } + var open by remember { mutableStateOf(false) } val adColour = MaterialTheme.colorScheme.tertiary val furnitureColour = MaterialTheme.colorScheme.secondary val trackColour = MaterialTheme.colorScheme.surfaceVariant @@ -101,7 +108,7 @@ private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, slop = TAP_SLOP_PX / usable, ) picked = hit - hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) } + open = hit != null } }, ) { @@ -122,6 +129,9 @@ private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, } picked?.let { Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall) + if (open) { + BreakDialog(it, judging, { onSeek(BreakTrack.markStartMs(it, durationMs)) }) { open = false } + } } } diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt index 4365729..3115c32 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -86,6 +86,9 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { /** The ad breaks detection has confirmed in one episode, for the episode screen's readout. */ suspend fun midrollRanges(episodeId: String) = graph.midrolls.getRanges(episodeId) + /** The break popup's reads and writes, kept off this class -- see [BreakJudgingSource]. */ + val breakJudging = BreakJudgingSource(graph) + /** * Moves where an episode will start from, for the episode screen's scrubber. * 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 9515c3d..8f28795 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -405,3 +405,21 @@ class FakeQueueDao : QueueDao { override suspend fun clear() = entries.clear() override suspend fun size() = entries.size } + +class FakeBreakVerdictDao : BreakVerdictDao { + val rows = mutableListOf<BreakVerdictEntity>() + + override suspend fun getForEpisode(episodeId: String) = + rows.filter { it.episodeId == episodeId }.sortedBy { it.startMs } + + override suspend fun upsert(verdict: BreakVerdictEntity) { + rows.removeAll { + it.episodeId == verdict.episodeId && it.startMs == verdict.startMs && it.endMs == verdict.endMs + } + rows += verdict + } + + override suspend fun delete(episodeId: String, startMs: Long, endMs: Long) { + rows.removeAll { it.episodeId == episodeId && it.startMs == startMs && it.endMs == endMs } + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt b/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt new file mode 100644 index 0000000..a845d27 --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/BreakPopupTest.kt @@ -0,0 +1,138 @@ +package org.terst.listen.fingerprint + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.terst.listen.playback.BreakVerdicts + +/** + * The wording of the popup a tap on a break opens. Pinned like [SkipSummary]'s, because the one + * line he most needs -- will this actually be skipped -- is the one the app never used to say. + */ +class BreakPopupTest { + + private fun mark(kind: String, partners: Int = 0, startMs: Long = 720_000, endMs: Long = 860_000) = + SkipSummary.BreakMarker(0.4f, 0.5f, kind, startMs, endMs, partners) + + private fun content( + kind: String, + partners: Int = 0, + skipFurniture: Boolean = true, + verdict: String? = null, + ) = BreakPopup.content(mark(kind, partners), skipFurniture, verdict) + + @Test + fun `the title is the pinned wording for the kind`() { + assertEquals("Ad break", content("ad").title) + assertEquals("Show intro", content("intro").title) + assertEquals("Break cue", content("cue").title) + assertEquals("Credits", content("outro").title) + assertEquals("Ad break", content("something-new").title) + } + + @Test + fun `it says when the break runs and for how long`() { + assertEquals("Runs 12:00 - 14:20, 2 min 20 s long.", content("ad").timing) + assertEquals( + "Runs 1:00 - 1:45, 45 s long.", + BreakPopup.content(mark("ad", startMs = 60_000, endMs = 105_000), true, null).timing, + ) + assertEquals( + "Runs 10:00 - 12:00, 2 min long.", + BreakPopup.content(mark("ad", startMs = 600_000, endMs = 720_000), true, null).timing, + ) + } + + @Test + fun `an advert is explained by the other episodes that carried it`() { + assertEquals( + "The same audio is in 3 other episodes of this show, which is how inserted advertising looks.", + content("ad", partners = 3).why, + ) + assertEquals( + "The same audio is in 1 other episode of this show, which is how inserted advertising looks.", + content("ad", partners = 1).why, + ) + } + + @Test + fun `the shows own audio is explained as recurring rather than as advertising`() { + assertEquals( + "The same audio is in 4 other episodes of this show, which is how a recurring intro looks.", + content("intro", partners = 4).why, + ) + assertEquals( + "The same audio is in 4 other episodes of this show, which is how a recurring break cue looks.", + content("cue", partners = 4).why, + ) + assertEquals( + "The same audio is in 4 other episodes of this show, which is how recurring credits look.", + content("outro", partners = 4).why, + ) + } + + /** 0 means the phone was never told, not that nothing else carried it -- it must not claim so. */ + @Test + fun `an unknown count admits it rather than claiming there is no evidence`() { + val why = content("ad", partners = 0).why + + assertEquals("The server did not say how many other episodes carried this audio.", why) + } + + @Test + fun `advertising is always skipped`() { + val expected = "Will be skipped. Advertising always is." + + assertEquals(expected, content("ad", skipFurniture = false).willSkip) + assertEquals(expected, content("ad", skipFurniture = true).willSkip) + assertEquals(expected, content("never-seen-kind", skipFurniture = false).willSkip) + } + + @Test + fun `the shows own audio is skipped only when that is turned on`() { + assertEquals( + "Will be skipped, because skipping a show's own intros, cues and credits is on.", + content("intro", skipFurniture = true).willSkip, + ) + assertEquals( + "Will not be skipped. It is the show's own audio, and skipping that is off.", + content("cue", skipFurniture = false).willSkip, + ) + } + + @Test + fun `a break marked wrong is not skipped whatever it is`() { + val expected = "Will not be skipped. You marked it as the programme." + + assertEquals(expected, content("ad", verdict = BreakVerdicts.INVALID).willSkip) + assertEquals(expected, content("intro", skipFurniture = true, verdict = BreakVerdicts.INVALID).willSkip) + assertEquals(expected, content("intro", skipFurniture = false, verdict = BreakVerdicts.INVALID).willSkip) + } + + @Test + fun `a break marked right is skipped exactly as before`() { + assertEquals( + content("ad").willSkip, + content("ad", verdict = BreakVerdicts.VALID).willSkip, + ) + assertEquals( + content("intro", skipFurniture = false).willSkip, + content("intro", skipFurniture = false, verdict = BreakVerdicts.VALID).willSkip, + ) + } + + @Test + fun `the verdict line reports what was recorded`() { + assertNull(content("ad").verdict) + assertEquals("You confirmed this one.", content("ad", verdict = BreakVerdicts.VALID).verdict) + assertEquals("You marked this one wrong.", content("ad", verdict = BreakVerdicts.INVALID).verdict) + } + + @Test + fun `the buttons say what each answer means for this kind of break`() { + assertEquals("Yes, this is advertising", content("ad").validateLabel) + assertEquals("Yes, this is the show's own audio", content("intro").validateLabel) + assertEquals("No, this is the programme", content("ad").invalidateLabel) + assertEquals("No, this is the programme", content("outro").invalidateLabel) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt new file mode 100644 index 0000000..8fd964b --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/BreakVerdictsTest.kt @@ -0,0 +1,210 @@ +package org.terst.listen.playback + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.terst.listen.data.BreakVerdictEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.FakeBreakVerdictDao +import org.terst.listen.data.FakeMidrollDao +import org.terst.listen.server.ServerBreak +import org.terst.listen.server.ServerEpisode +import org.terst.listen.server.ServerSkips +import org.terst.listen.server.SkipServer + +/** + * A verdict is the most trustworthy label in the system -- a person confirmed it -- so the two + * ways it can be lost are pinned here: the server replacing the ranges, and the boundaries + * moving a little between passes. + */ +class BreakVerdictsTest { + + private fun verdict(startMs: Long, endMs: Long, v: String = BreakVerdicts.INVALID, episode: String = "e1") = + BreakVerdictEntity(episode, startMs, endMs, v, decidedAtMs = 1L) + + private fun range(startMs: Long, endMs: Long, kind: String = "ad") = + EpisodeMidrollEntity("e1", "server-0", startMs, endMs, kind) + + @Test + fun `a verdict matches the same break exactly`() { + val v = verdict(60_000, 90_000) + + assertEquals(v, BreakVerdicts.matching(listOf(v), 60_000, 90_000)) + } + + @Test + fun `a verdict still matches when both edges drift a little between passes`() { + val v = verdict(60_000, 90_000) + + assertEquals(v, BreakVerdicts.matching(listOf(v), 61_500, 88_700)) + assertEquals(v, BreakVerdicts.matching(listOf(v), 58_000, 92_000)) + } + + @Test + fun `a verdict does not match a genuinely different break`() { + val v = verdict(60_000, 90_000) + + assertNull(BreakVerdicts.matching(listOf(v), 600_000, 630_000)) + // Overlapping, but a different stretch: starts 10s later. + assertNull(BreakVerdicts.matching(listOf(v), 70_000, 90_000)) + // Same start, runs far longer. + assertNull(BreakVerdicts.matching(listOf(v), 60_000, 150_000)) + } + + @Test + fun `just outside the tolerance is a different break`() { + val v = verdict(60_000, 90_000) + + assertNull(BreakVerdicts.matching(listOf(v), 60_000 + BreakVerdicts.TOLERANCE_MS + 1, 90_000)) + } + + @Test + fun `where two verdicts could match the nearer one wins`() { + val near = verdict(60_500, 90_000) + val far = verdict(61_900, 90_000) + + assertEquals(near, BreakVerdicts.matching(listOf(far, near), 60_000, 90_000)) + } + + @Test + fun `an invalidated range is dropped and a validated or unjudged one is kept`() { + val wrong = range(60_000, 90_000) + val right = range(300_000, 330_000) + val unjudged = range(600_000, 630_000) + val verdicts = listOf( + verdict(60_000, 90_000, BreakVerdicts.INVALID), + verdict(300_000, 330_000, BreakVerdicts.VALID), + ) + + assertEquals(listOf(right, unjudged), BreakVerdicts.withoutInvalidated(listOf(wrong, right, unjudged), verdicts)) + } + + @Test + fun `an invalidated range whose edges have shifted is still dropped`() { + val shifted = range(61_000, 89_000) + + assertEquals( + emptyList<EpisodeMidrollEntity>(), + BreakVerdicts.withoutInvalidated(listOf(shifted), listOf(verdict(60_000, 90_000))), + ) + } + + // --- acting on it at playback ------------------------------------------------------------- + + @Test + fun `an invalidated advert is no longer skipped`() { + val ranges = listOf(range(60_000, 90_000)) + val wrong = listOf(verdict(60_000, 90_000, BreakVerdicts.INVALID)) + + assertNull(MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = true, verdicts = wrong)) + assertEquals(false, MidrollSkip.isActedOnBreak(ranges, 70_000, skipFurniture = true, verdicts = wrong)) + } + + @Test + fun `a validated advert is still skipped, exactly as an unjudged one`() { + val ranges = listOf(range(60_000, 90_000)) + val right = listOf(verdict(60_000, 90_000, BreakVerdicts.VALID)) + + assertEquals(90_000L, MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = false, verdicts = right)) + assertEquals(90_000L, MidrollSkip.skipTarget(ranges, 70_000, skipFurniture = false)) + } + + @Test + fun `a verdict on one break leaves the others skipped`() { + val ranges = listOf(range(60_000, 90_000), range(300_000, 330_000)) + val wrong = listOf(verdict(60_000, 90_000, BreakVerdicts.INVALID)) + + assertEquals(330_000L, MidrollSkip.skipTarget(ranges, 310_000, skipFurniture = false, verdicts = wrong)) + } + + @Test + fun `invalidating the shows own audio with furniture skipping off changes nothing`() { + val intro = listOf(range(0, 98_000, "intro")) + val wrong = listOf(verdict(0, 98_000, BreakVerdicts.INVALID)) + + assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = false)) + assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = false, verdicts = wrong)) + } + + @Test + fun `invalidating the shows own audio with furniture skipping on stops the skip`() { + val intro = listOf(range(0, 98_000, "intro")) + val wrong = listOf(verdict(0, 98_000, BreakVerdicts.INVALID)) + + assertEquals(98_000L, MidrollSkip.skipTarget(intro, 10_000, skipFurniture = true)) + assertNull(MidrollSkip.skipTarget(intro, 10_000, skipFurniture = true, verdicts = wrong)) + } + + // --- recording ---------------------------------------------------------------------------- + + @Test + fun `recording stores the verdict with the position, the time and what the detector claimed`() = runTest { + val dao = FakeBreakVerdictDao() + + VerdictRecorder(dao) { 777L }.record( + EpisodeMidrollEntity("e1", "server-0", 60_000, 90_000, "cue", partners = 4), + BreakVerdicts.INVALID, + ) + + assertEquals( + listOf(BreakVerdictEntity("e1", 60_000, 90_000, BreakVerdicts.INVALID, 777L, "cue", 4)), + dao.rows, + ) + } + + @Test + fun `changing your mind replaces the verdict rather than keeping both`() = runTest { + val dao = FakeBreakVerdictDao() + val recorder = VerdictRecorder(dao) { 5L } + + recorder.record(range(60_000, 90_000), BreakVerdicts.INVALID) + // The server has since moved the edges a little; it is still the same break. + recorder.record(range(61_000, 91_000), BreakVerdicts.VALID) + + assertEquals(1, dao.rows.size) + assertEquals(BreakVerdicts.VALID, dao.rows.single().verdict) + } + + // --- surviving a refresh ------------------------------------------------------------------ + + @Test + fun `a verdict survives the server replacing the episodes ranges`() = runTest { + val verdicts = FakeBreakVerdictDao() + val midrolls = FakeMidrollDao() + val skips = ServerSkips( + SkipServer( + { null }, + object : org.terst.listen.server.SkipTransport { + override suspend fun get(url: String, token: String) = org.terst.listen.server.SkipReply.NoAnswer + override suspend fun post(url: String, token: String, body: String) = + org.terst.listen.server.SkipReply.NoAnswer + }, + ), + org.terst.listen.data.FakeSubscriptionDao(), + org.terst.listen.data.FakeShowFingerprintDao(), + org.terst.listen.data.FakePlaybackStateDao(mutableListOf()), + midrolls, + ) + val episode = org.terst.listen.data.EpisodeEntity( + id = "e1", subscriptionId = "s1", guid = "g", title = "t", mediaUrl = "u", + mediaType = "audio/mpeg", discoveredAtMs = 0, + ) + fun answer(start: Long, end: Long) = ServerEpisode( + ready = true, audioUrl = "x", durationMs = null, introMs = null, outroMs = null, + detected = true, breaks = listOf(ServerBreak(start, end, "ad", partners = 3)), + ) + + skips.record(episode, answer(60_000, 90_000)) + VerdictRecorder(verdicts) { 1L }.record(midrolls.rangeRows.single(), BreakVerdicts.INVALID) + // The next refresh: every range deleted and re-inserted, edges slightly moved. + skips.record(episode, answer(60_800, 89_400)) + + val kept = BreakVerdicts.matching(verdicts.getForEpisode("e1"), 60_800, 89_400) + assertEquals(BreakVerdicts.INVALID, kept?.verdict) + assertEquals( + emptyList<EpisodeMidrollEntity>(), + BreakVerdicts.withoutInvalidated(midrolls.getRanges("e1"), verdicts.getForEpisode("e1")), + ) + } +} 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 4e3215e..eba2629 100644 --- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt +++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt @@ -6,6 +6,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test +import org.terst.listen.data.BreakVerdictEntity import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.EpisodeMidrollEntity import org.terst.listen.data.FakeEpisodeDao @@ -98,7 +99,10 @@ class PlaybackAdvancerTest { val onFetch: suspend (EpisodeEntity) -> Result<Unit> = { Result.success(Unit) }, val chapters: List<Chapter> = emptyList(), val onChapters: () -> Unit = {}, + val verdictRows: () -> List<BreakVerdictEntity> = { emptyList() }, ) : PlaybackSources { + override suspend fun verdicts(episode: EpisodeEntity) = verdictRows() + override suspend fun chapters(episode: EpisodeEntity): List<Chapter> { onChapters() return chapters @@ -117,6 +121,34 @@ class PlaybackAdvancerTest { /** Unwraps the common case so the assertions stay about the thing under test. */ private fun StartOutcome.ready() = (this as? StartOutcome.Ready)?.next + // --- verdicts --------------------------------------------------------------------------- + + /** + * Marking a break wrong has to stop it being skipped from the next tick, and the context is + * read once per episode -- so the ticker asks for the verdicts afresh each time. + */ + @Test + fun `fresh verdicts are read each time rather than once per episode`() = runTest { + show() + val ep = episode("a") + var rows = emptyList<BreakVerdictEntity>() + val live = PlaybackAdvancer(episodes, subscriptions, queue, FakeSources(verdictRows = { rows })) { now } + val context = live.contextFor("a") + assertEquals(emptyList<BreakVerdictEntity>(), context.verdicts) + + rows = listOf(BreakVerdictEntity(ep.id, 60_000, 90_000, BreakVerdicts.INVALID, 1L)) + + assertEquals(rows, live.withFreshVerdicts(context).verdicts) + assertEquals("everything else is left alone", context.episode, live.withFreshVerdicts(context).episode) + } + + @Test + fun `with nothing playing there are no verdicts to fetch`() = runTest { + val live = PlaybackAdvancer(episodes, subscriptions, queue, FakeSources(verdictRows = { error("asked") })) { now } + + assertEquals(PlayingContext(), live.withFreshVerdicts(PlayingContext())) + } + // --- finishing -------------------------------------------------------------------------- @Test 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 56c0bf0..aa651af 100644 --- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt +++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt @@ -185,6 +185,28 @@ class ServerSkipsTest { } /** + * The evidence behind a break is what makes it worth opening: "the same audio turned up in + * three other episodes" is a reason, and a bare range is not. + */ + @Test + fun `keeps how many other episodes carried each break`() = 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, 60_000, partners = 5), ServerBreak(900_000, 960_000)), + ), + ) + + val ranges = midrolls.rangeRows.sortedBy { it.startMs } + assertEquals(5, ranges[0].partners) + assertEquals("a server that does not say reads as unknown", 0, ranges[1].partners) + } + + /** * 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. 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 c54ed35..aa0fd06 100644 --- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt +++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt @@ -298,6 +298,19 @@ class SkipServerTest { } @Test + fun `reads how many other episodes carried a break`() = runTest { + val transport = FakeTransport().apply { + response = """{"status":"ready","audioUrl":"/audio/abc","breaks":[ + {"startMs":0,"endMs":60000,"kind":"ad","partners":7},{"startMs":90000,"endMs":99000}]}""" + } + + val got = server(transport).episode("https://f", "g1", "", "") + + assertEquals(7, got?.breaks?.get(0)?.partners) + assertEquals(0, got?.breaks?.get(1)?.partners) + } + + @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"}""" } diff --git a/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md new file mode 100644 index 0000000..6dea531 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-corrections-and-signature-library.md @@ -0,0 +1,263 @@ +# Human corrections, then a signature library — plan and handover + +Written 2026-10-20, immediately before a context compaction. Assume the next session knows +nothing. `CLAUDE.md` holds the standing rules; this holds where the work is and why. + +**Spec this argues from:** `docs/superpowers/specs/2026-10-08-furniture-and-cues-design.md` +(furniture/ad distinction, partly built) and the design published at +https://claude.ai/artifact/BJmBFVrohrJo1hB6vYSz2Y (signature library, nothing built). + +--- + +## Where things stand + +**Branch `main`, clean at `f087b8d`.** 771 unit tests. Room schema **14**. APK published at +https://static.terst.org/files/listen-debug.apk built from `f087b8d`. + +**Server healthy**, `listend.service` on tungsten, 43 shows, 335 mirrored episodes, 501 breaks. +Up since 2026-10-17, zero restarts. + +### Shipped since the break-kinds plan + +| commit | what | +|---|---| +| `6c4d40e` | Refresh skips on feed refresh; `audioFromServer` provenance so server timestamps never reach publisher audio | +| `203e8a6` | That sweep bounded to 6 concurrent instead of several hundred sequential | +| `bd2a8ba` | Downloads **wait** for the server's copy instead of silently taking the publisher's; `WAITING_FOR_SERVER`; explicit publisher action | +| `999759f` | A rejected token (401/403) is permanent, not a reason to wait; notice in Settings | +| `d96c876` | Skip readout becomes three numbers plus tappable marks; the 37-row timestamp list deleted | +| `2329c92` | **Episode screen becomes the player**; `PlayerScreen.kt` deleted; six buttons to one plus overflow | +| `f087b8d` | Show name in the top bar, episode in the body; chapters fetched lazily | + +### In flight when this was written + +One subagent building the **tap-a-mark popup** (see next section). It will land a commit and a +schema 14 → 15 migration adding `partners`, plus a verdict table. If the tree is dirty or an +unexpected commit is present, that is it. + +--- + +## The sequence, and why it is in this order + +Peter's instruction, verbatim: *"let me tap the highlighted skips - give me a popup that has meta +data and a way to explicitly validate or invalidate it"* — offered as the answer to the risk that +a wrong signature-library entry would cut the same audio from every episode forever, silently. + +**That reordered the plan.** Corrections come before the library, not after it, because the labels +are the most trustworthy signal in the system — a human confirmed them — and they are the only +source of **negative** evidence. Nothing else can say "this is not an advertisement". + +### 1. The popup — IN FLIGHT + +Tapping a break mark opens metadata and a verdict. Detail in the dispatch; the parts that matter: + +- **Persist `partners`.** The server sends it on every break and the phone discards it. It is the + "why" the popup exists to show. Schema 14 → 15. +- **Verdicts live in their own table**, keyed by `(episodeId, startMs, endMs)` with a tolerance. + They must survive `ServerSkips.recordBreaks`, which **deletes and re-inserts every range for an + episode** on each refresh — a verdict stored on `episode_midrolls` would be wiped. +- **Invalidate takes effect on the next tick**, no refresh, no round trip. +- **Verdicts stay local for now.** No endpoint, no consumer. Storing them from today means a + corpus exists by the time the library does. + +### 1b. "This is an ad" in the notification — NOT STARTED + +Peter, 2026-10-20: *"The notification currently has duplicate skip buttons (our custom ones plus +forward/back) - there's UI space for explicit 'this is an ad' there."* + +**He is right about the duplication.** `ListenSessionCallback.onConnect` leaves +`COMMAND_SEEK_BACK` and `COMMAND_SEEK_FORWARD` in the player commands (they are in +`DEFAULT_PLAYER_COMMANDS`), which Media3's notification provider renders itself, and then +`NotificationControls.customLayout()` publishes a second seek-back-15 / forward-25 pair on top. +Reclaiming a slot means dropping one pair. + +**Do this on a device, carefully.** `ListenSessionCallback` carries a comment recording that +removing commands to free notification slots once silently killed the headphone skip button. +Whichever pair goes, verify: the notification, the lock screen, Bluetooth/headphone transport, +and Android Auto. Note also that that comment currently contradicts its own code — it says "Only +previous-media-item goes" while the code removes both previous and next. Worth resolving while +in there. + +**Why this button matters more than the popup.** They catch opposite errors: + +| surface | catches | evidence | +|---|---|---| +| tap a mark → popup | **false positives** — a mark that should not be there | negative label | +| notification button | **false negatives** — an advertisement nothing detected | positive label | + +False negatives are by far the bigger problem: the scan finds about **38%** of the advertising the +feeds imply, so roughly three fifths of what he hears is undetected. The popup can only ever +correct what was already found. And the notification is available at the one moment he actually +notices — phone in pocket, advertisement playing, without unlocking anything. + +**What one press captures, and what to do with it.** A press is a *timestamp*, not a range, and a +signature needs a range. Do not ask him to press twice. Record the position and let the server +work out the extent: take a window back from the press (he presses partway in, and spots run +roughly 15–60s, so something like `[t-45s, t+15s]`) and feed it to the existing +`detect.MatchedRuns` machinery as a *seeded* search rather than a blind one. That is the same +correlation already in use, pointed at a known-positive location instead of groping. + +**The press also skips, and what happens next is more signal.** Peter, 2026-10-20: *"Also skip - +if I hit back immediately that might invalidate the signal or indicate I jumped past the end."* + +That turns one button into a complete labelling gesture. The rule, which needs no extra UI: + +| gesture | means | what it fixes | +|---|---|---| +| press | advertising starts at or before here | **start** of the range | +| press again, still in the jump | still advertising | **extends** the range | +| back, landing inside the jump | overshot | **end** of the range, at the landing point | +| back, landing before the first press | that was not advertising | **invalidates** the whole thing | + +The two back cases are told apart by *where he lands*, not by guessing: inside the jumped span is +a correction to the end, before the original press is a rejection. Both are unambiguous from the +playhead alone. + +**How far to jump is an ergonomics question, and probably not a constant.** + +An earlier draft of this plan argued the jump must be a fixed size and deliberately unequal to the +25s forward-seek, so that a back-press would be "unambiguous". Peter challenged it and it does not +hold, in either half: + +- The system knows **which button was pressed**. A following back-press is read as a correction + because the last action was the ad button, not because the distance was distinctive. Equal + distances would create no ambiguity anywhere in the logic. +- The listener is not computing anything either. He presses back until the programme resumes, and + the correction is read from **where he lands** against a span the system recorded exactly. That + works for any jump size; he never needs to know the number. + +What actually sets the size is the trade between **too small** (several presses to clear one pod) +and **too large** (routinely landing in content, so he corrects constantly). It should sit near the +typical *remaining* advertisement duration from a mid-advertisement press. + +And it need not be fixed: if a detected break is nearby, jump to its end — that position is already +known. If the episode's declared-versus-served gap says minutes remain, that is informative too. +The press means "get me past this advertisement", so the best jump is the best estimate available +at that moment. Correction semantics are unaffected, because "landed inside the jumped span" holds +for any span. + +The one real reason to keep the two buttons distinct is that *he* must not confuse them in his +pocket, which is a matter of icon and label, not distance. + +**"Immediately" needs a window.** A back-press within roughly 15–20 seconds of wall clock counts +as correction; later than that it is ordinary navigation and must not be read as a label. Store +the window with the label so a future change to it does not retroactively reinterpret old data. + +This composes with his earlier manual-skip-logging idea: *"a series of skips with small gaps +between them, sometimes followed by one or more skips back, is a pretty strong signal of an ad +block."* A press and a skip-burst are two observations of the same event, and a skip *back* after +over-shooting brackets the end precisely. Treat them as one input stream. + +### 2. Push verdicts to the server — NOT STARTED + +Phone to server. Needs an endpoint and server-side storage. Design note: a verdict is about a +*range of a specific episode's audio*, so it is only meaningful for episodes whose bytes the +server itself served — gate on `audioFromServer`, the same invariant the refresh sweep uses. + +### 3. The index spike — NOT STARTED, and it is the gate + +**Do this before anything else in the library, because it decides whether the library is +possible.** A linear scan of N signatures against each episode does not survive: at ~0.33s per +correlation, 500 entries × 8 episodes is over twenty minutes per show against a pass that +currently takes seven. That is the same cliff the original intro search went over (199s per end, +killed by WorkManager before persisting anything). + +Needs landmark hashing — spectral peak pairs, hashed with their time offset, inverted index. A +true match produces many hashes agreeing on one offset; noise scatters. + +**The spike has a ready-made pass/fail test**, from the probe run 2026-10-20: + +- It must find the Up First repeat that scored **r = 0.877** (template: episode + `32b5b954c1e705c185f51829.mp3` at 847s for 60s; found in `99e85f679bcf64db8739cfda.mp3` at + 836s, and at r = 0.678 in `73ae08f072189cc954ea6b71.mp3`). +- It must reject the non-matches that scored **0.06–0.13** (Tangle `7ad21fd746fe0d22440c9f6a.mp3`, + Serial `94f4ce3a94f5af2bf1b488aa.mp3`, Heavyweight `c7801ebf5c52825fbab680e8.mp3`). + +If it cannot do both, fast, the library stops there and nothing else is wasted. + +### 4. Store and age signatures — NOT STARTED + +Two kinds, which behave nothing alike: + +- **Cue** — a show's own furniture (break stinger, opening, credits). One show, permanent while + subscribed. Search Engine's is 8.0s and recurs 3–4 times an episode; Jon Stewart's ~5s at four + positions. +- **Copy** — one advertisement. Any show, aged out after months unseen. + +Confidence is a count of sightings across **separate fetch occasions**, which is also the +independence test the withheld intro/outro offsets have been waiting for. Never promote on one +sighting. + +### 5. Shadow run — NOT STARTED + +Match against each batch, log what it *would* have cut, publish nothing. A week or two answers +whether it finds advertising or accumulates junk, before one extra second is skipped. + +### 6. Publish matches, and unlock intro/outro — NOT STARTED + +Only once the shadow run has earned it. + +--- + +## Measurements that must not be re-derived + +All in `docs/detection-findings.md` with full numbers. Re-running them costs hours. + +| finding | number | +|---|---| +| Ads found vs the declared length gap, current 12s floor | **~38%** median on shows with verified insertion | +| Lowering the floor to 8s | LeVar 10:30 → 19:43, **Sleepy History 14:11 → 174:44** on a show with 0:48 of ads. **Do not lower it.** | +| Filtering breaks by partner count or run length | Agreement drops 67% → 43%. **Disproven.** | +| `MinRunSamples` and `MergeGapMs` | **Interact.** Low floor + 60s bridging welds noise into enormous spans. Never tune one alone. | +| Ad repeats within a show | r = **0.877** | +| Ad repeats across unrelated shows | r = 0.06–0.13 (two probes, both negative; one was a host-read spot that cannot repeat) | +| Host-read ads (Darknet Diaries) | Never appear elsewhere. The library will not help such shows across shows, only across their own episodes over time. | +| Episodes with no inserted ads at all | 138 of 322, and the app used to report "Ads inserted" on 111 of them. **Now 0.** | + +Peter's own domain knowledge, which has twice beaten my analysis: **ads come in ~15-second units, +clustered in a few pods, with long stretches of content between.** That is what produced the 12s +floor. And he reports ads repeat across podcasts within a network (NPR, Pushkin) — consistent with +everything measured, though not directly caught in a probe. + +--- + +## Traps that have already cost time + +- **Never run anything as root against `/site/listen.terst.org/data`.** An experiment did, left + `state.json` root-owned, and the service crash-looped **4,645 times over 13 hours** while + `systemctl is-active` reported "active" (it was the restart loop). Probe against a **copy** with + the audio dir symlinked, and check `/api/health`, not `is-active`. +- **`gradle-test` reports a false FAIL for `connectedDebugAndroidTest`.** Line 70 scans + `*/test-results/*`; connected tests write to `app/build/outputs/androidTest-results/connected/`. + Parse that XML directly and check its mtime. **Unfixed in `/workspace/devtools`.** +- **The emulator AVD is `doot_test_api36`.** There is no `feedreader_test` AVD. `adb` is at + `/opt/android-sdk/platform-tools/adb`, not on PATH. +- **The pre-commit hook takes several minutes** (full build, tests, lint, detekt, coverage). Two + subagents have died mid-commit; one finished its work and never committed it. Always check + `git status` for orphaned work before assuming a failed agent did nothing. +- **Post-hoc filtering cannot model a detector change.** Predicting the floor raise by filtering + already-detected breaks gave "31% → 34%"; the real answer was 30% → 23%. Measure by running the + detector, not by filtering its old output. + +--- + +## Open, and genuinely Peter's + +- **Chapters** — settled by evidence, not asked: exactly one of his 61 shows publishes any, so + they live in the overflow. +- **The queue screen** — asked, answered: *"Leave it."* It keeps its own screen. +- **Still open: nothing from the UI proposal.** Both remaining questions were resolved — queue + stays its own screen ("Leave it"), chapters went to the overflow on evidence. +- **Answered 2026-10-20:** the press skips as well as records, and the gesture that follows it + carries the rest of the label. See 1b. + +## Known gaps, deliberate + +- **Compose has no automated coverage** — no Robolectric. Everything visual is verified by eye on + the emulator only. +- **The rejected-token notice is in-memory**, so after a process restart it reappears on the next + refresh rather than at launch. +- **Library and Settings redesign (step 4 of the UI plan) not started.** Smallest wins; nothing + depends on it. Mockups in https://claude.ai/artifact/AnywWE7j7e96QUpXvF9yaW +- **Intro/outro offsets still withheld.** They need sightings spanning more than one fetch + occasion — which step 4 of the library delivers. |
