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 /app | |
| 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
Diffstat (limited to 'app')
27 files changed, 1822 insertions, 15 deletions
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"}""" } |
