diff options
Diffstat (limited to 'app')
33 files changed, 2857 insertions, 199 deletions
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/10.json b/app/schemas/org.terst.listen.data.AppDatabase/10.json new file mode 100644 index 0000000..aacc2e7 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/10.json @@ -0,0 +1,747 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "6b086b6c5300aace4b1950bd882c2de6", + "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, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `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": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "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, `referenceClipPcm` BLOB NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB", + "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, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '6b086b6c5300aace4b1950bd882c2de6')" + ] + } +}
\ No newline at end of file diff --git a/app/schemas/org.terst.listen.data.AppDatabase/9.json b/app/schemas/org.terst.listen.data.AppDatabase/9.json new file mode 100644 index 0000000..849e972 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/9.json @@ -0,0 +1,752 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "2d882e6af98676065a505e077b27f730", + "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, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `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": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "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, `pendingClipPcm` 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": "pendingClipPcm", + "columnName": "pendingClipPcm", + "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, `referenceClipPcm` BLOB NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB", + "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, 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 + } + ], + "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" + ] + } + ] + } + ], + "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, '2d882e6af98676065a505e077b27f730')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt index 4117b79..8f2f7b6 100644 --- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt @@ -44,11 +44,17 @@ class LibraryOrderTest { @After fun tearDown() = db.close() - private suspend fun show(id: String) = subscriptions.insert( - SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0) + private suspend fun show(id: String, bedtime: Boolean = false) = subscriptions.insert( + SubscriptionEntity( + id = id, + feedUrl = "https://example.com/$id", + title = id, + addedAtMs = 0, + bedtime = bedtime, + ) ) - private suspend fun episode(id: String, showId: String, publishedAtMs: Long) = + private suspend fun episode(id: String, showId: String, publishedAtMs: Long?) = episodes.insertAll( listOf( EpisodeEntity( @@ -64,7 +70,14 @@ class LibraryOrderTest { ) ) - private suspend fun order() = subscriptions.observeAll().first().map { it.id } + private suspend fun listing(bedtime: Boolean = false) = + subscriptions.observeAll(bedtime).first() + + private suspend fun order(bedtime: Boolean = false) = + listing(bedtime).map { it.subscription.id } + + private suspend fun waiting(id: String) = + listing().first { it.subscription.id == id }.hasWaiting @Test fun ranksByTheNewestEpisodeStillWaiting() = runBlocking { @@ -151,4 +164,45 @@ class LibraryOrderTest { assertEquals(listOf("active", "empty"), order()) } + + /** Bedtime shows are a separate list, not a re-ordering of the same one. */ + @Test + fun bedtimeShowsAreOutOfTheOrdinaryLibrary() = runBlocking { + show("daytime") + show("sleepy", bedtime = true) + episode("e1", "daytime", 1 * day) + episode("e2", "sleepy", 5 * day) + + assertEquals("the newer show is a bedtime one and must not appear", listOf("daytime"), order()) + assertEquals(listOf("sleepy"), order(bedtime = true)) + } + + /** Which is what greys a show out: nothing unplayed and unqueued left. */ + @Test + fun hasWaitingGoesFalseOncePlayedAndQueuedAccountForEverything() = runBlocking { + show("a") + episode("played", "a", 1 * day) + episode("queued", "a", 2 * day) + assertEquals(true, waiting("a")) + + episodes.markPlayed("played", 1) + queue.replaceAll(listOf("queued")) + + assertEquals(false, waiting("a")) + } + + /** + * The trap in computing this from the sort key instead of a count. + * + * Ranking uses MAX(COALESCE(publishedAtMs, 0)), so a show whose only waiting episode has no + * publish date ranks at 0 -- correct for ordering, but reading "has something waiting" off + * that same 0 would grey out a show that does. + */ + @Test + fun aWaitingEpisodeWithNoPublishDateStillCounts() = runBlocking { + show("undated") + episode("e1", "undated", null) + + assertEquals(true, waiting("undated")) + } } 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 09b669f..091d8e6 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -333,4 +333,87 @@ class MigrationTest { assertEquals(90000L, cursor.getLong(1)) } } + + /** + * Bedtime shows. Every existing subscription must come through as *not* bedtime: the flag + * hides a show from the library, so a default that went the other way would empty it. + */ + @Test + fun migrates8To9DefaultingEveryExistingShowToNotBedtime() { + helper.createDatabase(name, 8).use { db -> + db.execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters) + VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0, + 0, 86400000, 1, 1, 1)""" + ) + db.execSQL( + """INSERT INTO episodes + (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs, + positionMs, downloadState) + VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000, + 515151, 'DOWNLOADED')""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 9, true, AppDatabase.MIGRATION_8_9) + + db.query("SELECT bedtime, title FROM subscriptions WHERE id = 's1'").use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("an existing show must stay in the library", 0, cursor.getInt(0)) + assertEquals("A Show", cursor.getString(1)) + } + + db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { cursor -> + cursor.moveToFirst() + assertEquals("the crown jewel must survive", 515151L, cursor.getLong(0)) + } + } + + /** + * Drops `pendingClipPcm`, which could hold a whole 240-second decoded clip -- 3.84MB, well + * past the 2MB a CursorWindow will carry. Rows written with one could be inserted and then + * never read back: reproduced on an emulator as a hard crash the moment a show's second + * episode was fingerprinted. + * + * The surviving columns must come through, because the confirmed reference clip is the part + * worth keeping -- losing it would restart detection for every show. + */ + @Test + fun migrates9To10DroppingTheOversizedPendingClipAndKeepingTheRest() { + helper.createDatabase(name, 9).use { db -> + db.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)""" + ) + db.execSQL( + """INSERT INTO show_fingerprints + (subscriptionId, kind, referenceClipPcm, pendingClipPcm, pendingEpisodeId, + sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs) + VALUES ('s1', 'INTRO', X'0A0B', X'0C0D', 'e9', 8000, 2, 'e8', 4242)""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 10, true, AppDatabase.MIGRATION_9_10) + + db.query( + """SELECT referenceClipPcm, pendingEpisodeId, confirmedCount, lastConfirmedEpisodeId, + sampleRate, updatedAtMs + FROM show_fingerprints WHERE subscriptionId = 's1' AND kind = 'INTRO'""" + ).use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("a confirmed reference clip is worth keeping", 2, cursor.getBlob(0).size) + assertEquals("e9", cursor.getString(1)) + assertEquals(2, cursor.getInt(2)) + assertEquals("e8", cursor.getString(3)) + assertEquals(8000, cursor.getInt(4)) + assertEquals(4242L, cursor.getLong(5)) + } + } } 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 e8769d3..d1b7ed2 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -30,7 +30,7 @@ class Converters { ShowMidrollEntity::class, EpisodeMidrollEntity::class, ], - version = 8, + version = 10, exportSchema = true, ) @TypeConverters(Converters::class) @@ -230,6 +230,62 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Bedtime shows: the ones Peter only listens to falling asleep. + * + * Defaulted off, and it has to be. The flag takes a show out of the library entirely, + * so a default of 1 here would hand an upgrader an empty list and look like data loss. + */ + val MIGRATION_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `bedtime` INTEGER NOT NULL DEFAULT 0") + } + } + + /** + * Drops `show_fingerprints.pendingClipPcm`, which could not be read back. + * + * It held a whole decoded head or tail window -- 240 seconds at 8kHz, 3.84MB as 16-bit + * PCM -- and Android reads every Room row through a CursorWindow, which refuses a row + * over 2MB. The insert succeeded and the next read threw SQLiteBlobTooBigException, so + * the intro/outro half of Tier 3 crashed the moment a show's second episode was + * fingerprinted. Found on an emulator; it could not have shown up off-device. + * + * The pending episode's *id* stays, and its audio is re-decoded on demand -- exactly + * what [SubscriptionEntity.midrollPendingEpisodeId] already does, for the same reason. + * Recreated rather than altered because SQLite cannot drop a column in place on the + * versions this app supports; every other column is copied across, since a confirmed + * reference clip is worth keeping. + */ + val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `show_fingerprints_new` ( + `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 DELETE CASCADE)""" + ) + db.execSQL( + """INSERT INTO `show_fingerprints_new` + (subscriptionId, kind, referenceClipPcm, pendingEpisodeId, sampleRate, + confirmedCount, lastConfirmedEpisodeId, updatedAtMs) + SELECT subscriptionId, kind, referenceClipPcm, pendingEpisodeId, + sampleRate, confirmedCount, lastConfirmedEpisodeId, updatedAtMs + FROM `show_fingerprints`""" + ) + db.execSQL("DROP TABLE `show_fingerprints`") + db.execSQL("ALTER TABLE `show_fingerprints_new` RENAME TO `show_fingerprints`") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` " + + "ON `show_fingerprints` (`subscriptionId`)" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -240,7 +296,7 @@ abstract class AppDatabase : RoomDatabase() { // deliberate addition, and the list is the record of them. .addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, - MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, ) .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 541d6ef..4fb6342 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -1,6 +1,7 @@ package org.terst.listen.data import androidx.room.Dao +import androidx.room.Embedded import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -8,6 +9,18 @@ import androidx.room.Transaction import androidx.room.Update import kotlinx.coroutines.flow.Flow +/** + * A show as the library lists it: the row, plus whether anything of it is still waiting. + * + * [hasWaiting] is what greys a show out. It is computed in the same query as the ordering + * because both answer the same question about the same subquery, and asking twice would let + * the list and the dimming disagree. + */ +data class SubscriptionListing( + @Embedded val subscription: SubscriptionEntity, + val hasWaiting: Boolean, +) + @Dao interface SubscriptionDao { /** @@ -20,17 +33,36 @@ interface SubscriptionDao { * * Queued episodes are excluded for the same reason played ones are: they have been dealt * with. Peter's words -- once they're on the queue they're noise in the list. + * + * [bedtime] picks which of the two lists this is. They are disjoint, not a filter over one + * list: a bedtime show is absent from the ordinary library entirely, which is the point. + * + * [SubscriptionListing.hasWaiting] comes from a COUNT rather than from `latest`, even + * though the sort key is right there. An episode with no publish date ranks at 0, which is + * fine for ordering and wrong for "has something waiting" -- reading the flag off the sort + * key would grey out a show that has an undated episode sitting unheard. */ @Query( - """SELECT s.* FROM subscriptions s - LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest + """SELECT s.*, COALESCE(e.waiting, 0) > 0 AS hasWaiting FROM subscriptions s + LEFT JOIN (SELECT subscriptionId, COUNT(*) AS waiting, + MAX(COALESCE(publishedAtMs, 0)) AS latest FROM episodes WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue) GROUP BY subscriptionId) e ON e.subscriptionId = s.id + WHERE s.bedtime = :bedtime ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE""" ) - fun observeAll(): Flow<List<SubscriptionEntity>> + fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>> + + /** + * Every show, bedtime ones included. + * + * Deliberately not the library query: a rule may well target a bedtime show, and a screen + * that has navigated into one must keep rendering it after bedtime mode is switched off. + */ + @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE") + fun observeEvery(): Flow<List<SubscriptionEntity>> @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE") suspend fun getAll(): List<SubscriptionEntity> @@ -67,6 +99,10 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET skipAdChapters = :enabled WHERE id = :id") suspend fun setSkipAdChapters(id: String, enabled: Boolean) + /** Moves a show between the ordinary library and the bedtime one. */ + @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id") + suspend fun setBedtime(id: String, enabled: Boolean) + /** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */ @Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id") suspend fun updateSkips(id: String, introMs: Long, outroMs: 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 4f5242d..83ee56a 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -102,15 +102,25 @@ data class SubscriptionEntity( * The one episode set aside to compare the *next* newly-downloaded episode against, when * looking for a midroll segment neither has been matched to yet. * - * Unlike [ShowFingerprintEntity.pendingClipPcm] this stores only an id, never audio: a - * pending clip for intro/outro is a few seconds, cheap to persist between runs, but a - * pending clip here is a *whole episode* -- tens of megabytes of PCM doubles for an hour of - * audio, uselessly large to keep in a database row. [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] - * re-decodes this episode's file on demand instead, and if retention has already reclaimed - * it, simply moves the pending slot on to whatever is newest -- the same graceful "nothing to - * compare against yet" path as never having had a pending episode at all. + * An id, never audio -- the same rule [ShowFingerprintEntity.pendingEpisodeId] follows, and + * for the same reason: a pending clip here is a *whole episode*, tens of megabytes of PCM + * for an hour of audio, far past what a database row can carry back out. + * [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] re-decodes this episode's + * file on demand instead, and if retention has already reclaimed it, simply moves the + * pending slot on to whatever is newest -- the same graceful "nothing to compare against + * yet" path as never having had a pending episode at all. */ val midrollPendingEpisodeId: String? = null, + + /** + * A show only listened to falling asleep, kept out of the ordinary library. + * + * Not a category system: the one grouping that earns its keep here is the one with a + * behaviour attached, which is that entering bedtime mode also starts the sleep timer. + * A show marked this way is invisible until that mode is on, so the default must be false + * -- see [AppDatabase.MIGRATION_8_9]. + */ + @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false, ) /** One a day, the cadence a drip subscription is for. */ @@ -279,13 +289,16 @@ data class ShowFingerprintEntity( */ val referenceClipPcm: ByteArray?, /** - * The most recently processed episode's *whole* head/tail clip, kept only while there is no - * [referenceClipPcm] yet -- the next episode needs something to compare against to find a - * first candidate. Null again as soon as one is found; unlike the reference clip this is - * genuinely temporary and, unlike it, sized like a whole decoded clip rather than a short - * matched segment. + * The episode set aside to compare the *next* one against, while there is no + * [referenceClipPcm] yet -- finding a first candidate needs two episodes. + * + * An id, never the audio. The clip itself is a whole decoded head or tail window: 240 + * seconds at 8kHz is 3.84MB as 16-bit PCM, and Android reads every Room row through a + * CursorWindow that refuses anything over 2MB. Storing it wrote a row that could never be + * read back. It is re-decoded from this episode's file on demand instead, exactly as + * [SubscriptionEntity.midrollPendingEpisodeId] already does; if retention has reclaimed + * the file, detection simply starts again from whatever is newest. */ - val pendingClipPcm: ByteArray?, val pendingEpisodeId: String?, val sampleRate: Int, /** @@ -308,7 +321,6 @@ data class ShowFingerprintEntity( return subscriptionId == other.subscriptionId && kind == other.kind && referenceClipPcm.contentEqualsOrBothNull(other.referenceClipPcm) && - pendingClipPcm.contentEqualsOrBothNull(other.pendingClipPcm) && pendingEpisodeId == other.pendingEpisodeId && sampleRate == other.sampleRate && confirmedCount == other.confirmedCount && @@ -320,7 +332,6 @@ data class ShowFingerprintEntity( var result = subscriptionId.hashCode() result = 31 * result + kind.hashCode() result = 31 * result + (referenceClipPcm?.contentHashCode() ?: 0) - result = 31 * result + (pendingClipPcm?.contentHashCode() ?: 0) result = 31 * result + (pendingEpisodeId?.hashCode() ?: 0) result = 31 * result + sampleRate result = 31 * result + confirmedCount diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt index dc627fa..7018103 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt @@ -1,5 +1,6 @@ package org.terst.listen.fingerprint +import org.terst.listen.data.EpisodeDao import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.FingerprintKind import org.terst.listen.data.ShowFingerprintDao @@ -40,6 +41,7 @@ interface AudioClipSource { */ class FingerprintUpdater( private val fingerprints: ShowFingerprintDao, + private val episodes: EpisodeDao, private val clips: AudioClipSource, private val referenceLengthSamples: Int = DEFAULT_REFERENCE_SECONDS * DEFAULT_SAMPLE_RATE, private val now: () -> Long = System::currentTimeMillis, @@ -53,7 +55,7 @@ class FingerprintUpdater( private suspend fun processEnd(episode: EpisodeEntity, kind: FingerprintKind, clip: DecodedClip) { val stored = fingerprints.get(episode.subscriptionId, kind) - val state = stateOf(stored) + val state = stateOf(stored, kind) val update = IntroOutroDetector.check(state, episode.id, clip.samples, referenceLengthSamples) fingerprints.upsert(entityOf(update.state, episode.subscriptionId, kind, clip.sampleRate)) @@ -84,17 +86,40 @@ class FingerprintUpdater( } } - private fun stateOf(stored: ShowFingerprintEntity?): FingerprintState { + private suspend fun stateOf(stored: ShowFingerprintEntity?, kind: FingerprintKind): FingerprintState { if (stored == null) return FingerprintState() return FingerprintState( referenceClip = stored.referenceClipPcm?.let(PcmCodec::decode), - pendingClip = stored.pendingClipPcm?.let(PcmCodec::decode), + pendingClip = pendingClipOf(stored, kind), pendingEpisodeId = stored.pendingEpisodeId, confirmedCount = stored.confirmedCount, lastConfirmedEpisodeId = stored.lastConfirmedEpisodeId, ) } + /** + * Re-decodes the pending episode rather than reading a clip back out of the database. + * + * The clip is a whole head or tail window and does not fit in a row -- see + * [org.terst.listen.data.ShowFingerprintEntity.pendingEpisodeId]. Decoding again costs one + * pass over a file already on disk, and only until a first candidate exists. + * + * Null whenever that episode or its file has gone: the detector then treats the episode in + * hand as the new pending one, which is the same place it starts from anyway. + */ + private suspend fun pendingClipOf(stored: ShowFingerprintEntity, kind: FingerprintKind): DoubleArray? { + if (stored.referenceClipPcm != null) return null + val pendingId = stored.pendingEpisodeId ?: return null + val pending = episodes.getById(pendingId) ?: return null + if (pending.localPath == null) return null + + val clip = when (kind) { + FingerprintKind.INTRO -> clips.decodeHead(pending) + FingerprintKind.OUTRO -> clips.decodeTail(pending) + } + return clip?.samples + } + private fun entityOf( state: FingerprintState, subscriptionId: String, @@ -104,7 +129,6 @@ class FingerprintUpdater( subscriptionId = subscriptionId, kind = kind, referenceClipPcm = state.referenceClip?.let(PcmCodec::encode), - pendingClipPcm = state.pendingClip?.let(PcmCodec::encode), pendingEpisodeId = state.pendingEpisodeId, sampleRate = sampleRate, confirmedCount = state.confirmedCount, @@ -112,9 +136,10 @@ class FingerprintUpdater( updatedAtMs = now(), ) - private companion object { + companion object { + /** Public so [org.terst.listen.fingerprint.StoredClip]'s guard can measure them. */ const val DEFAULT_REFERENCE_SECONDS = 8 const val DEFAULT_SAMPLE_RATE = 8_000 - const val MS_PER_SECOND = 1_000L + private const val MS_PER_SECOND = 1_000L } } diff --git a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt index 3a446d7..9050466 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/IntroOutroDetector.kt @@ -55,7 +55,8 @@ sealed interface FingerprintUpdate { */ object IntroOutroDetector { - private const val CONFIRM_THRESHOLD = 3 + /** Public so the UI can say "2 of 3" rather than hardcoding the same number again. */ + const val CONFIRM_THRESHOLD = 3 private const val MIN_SCORE = 0.6 private const val CANDIDATE_STEP_DIVISOR = 4 diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt index 133d391..20f4417 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt @@ -188,7 +188,12 @@ class MediaCodecClipSource : AudioClipSource { } } - private companion object { + companion object { + /** + * The head/tail window decoded for matching. Public because [StoredClip]'s size guard + * measures it: this window is far too big to persist, and a test says so rather than a + * comment nobody reads. + */ const val CLIP_SECONDS = 240 // durationMs is the feed's own claim, occasionally a little short of the real file. const val FULL_DECODE_MARGIN_SECONDS = 30 diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt index b33f577..8cbb441 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt @@ -15,7 +15,8 @@ package org.terst.listen.fingerprint */ object MidrollDetector { - private const val CONFIRM_THRESHOLD = 3 + /** Public for the same reason as [IntroOutroDetector.CONFIRM_THRESHOLD]. */ + const val CONFIRM_THRESHOLD = 3 private const val MIN_SCORE = 0.6 data class Candidate( diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt index 47f9184..5849d1d 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt @@ -93,28 +93,30 @@ class MidrollUpdater( val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return val pendingClip = clips.decodeFull(pendingEpisode) ?: return + // The same real segment surfaces from a fresh discovery pass every time the pending + // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. + // Recognised by *where* it landed in the new episode, not by re-correlating clip + // content: two coarse passes over the same segment do not reliably extract the same + // sub-range of it, so a content comparison between them can score low even though both + // come from the one real ad break -- position overlap is what is actually stable + // between runs. val tolerance = discoverySamples.toLong() * coarseFactor - for (segment in discover(pendingClip.samples, newClip.samples)) { - // The same real segment surfaces from a fresh discovery pass every time the pending - // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. - // Recognised here by *where* it landed in the new episode, not by re-correlating - // clip content: two coarse passes over the same segment do not reliably extract the - // same sub-range of it, so a content comparison between them can score low even - // though both come from the one real ad break -- position overlap is what is - // actually stable between runs. - val overlapsTracked = alreadyMatchedInNewEpisode.any { existing -> + val undiscovered = discover(pendingClip.samples, newClip.samples).filterNot { segment -> + alreadyMatchedInNewEpisode.any { existing -> rangesOverlap( segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance, existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples, ) } - if (overlapsTracked) continue + } + for (segment in undiscovered) { + val pcm = storablePcm(segment.referenceClip, newClip.sampleRate) ?: continue midrolls.upsertCandidate( ShowMidrollEntity( subscriptionId = subscriptionId, id = newId(), - referenceClipPcm = PcmCodec.encode(segment.referenceClip), + referenceClipPcm = pcm, sampleRate = newClip.sampleRate, confirmedCount = SEEDED_CONFIRM_COUNT, lastConfirmedEpisodeId = newEpisode.id, @@ -124,6 +126,29 @@ class MidrollUpdater( } } + /** + * A discovered segment as bytes a database row can carry back out, or null if even the + * trimmed clip would not fit. + * + * A shared segment has no length limit of its own -- a long sponsor block could run for + * minutes, and at 8kHz 16-bit PCM two minutes is already past the 2MB a CursorWindow will + * return. See [StoredClip]; the fingerprint table shipped with exactly that bug, and it + * crashed the app on read rather than failing on write. + * + * Trimming shortens the range that gets skipped, so a very long break plays its tail rather + * than being cut entirely. That is the safe direction to be wrong in: over-skipping eats + * real content, and not trimming at all makes a row that cannot be read. + * + * The [StoredClip.fits] check afterwards is a backstop, unreachable while the cap and the + * limit agree. It returns null rather than throwing because an untracked ad break is a + * missing feature and an oversized row is a crash. + */ + private fun storablePcm(clip: DoubleArray, sampleRate: Int): ByteArray? { + val maxSamples = StoredClip.MAX_CANDIDATE_SECONDS * sampleRate + val trimmed = if (clip.size <= maxSamples) clip else clip.copyOf(maxSamples) + return PcmCodec.encode(trimmed).takeIf { StoredClip.fits(it.size) } + } + private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long) @@ -181,11 +206,12 @@ class MidrollUpdater( if (row.id !in surviving) midrolls.deleteCandidate(subscriptionId, row.id) } for (candidate in result.candidates) { + val pcm = storablePcm(candidate.referenceClip, sampleRate) ?: continue midrolls.upsertCandidate( ShowMidrollEntity( subscriptionId = subscriptionId, id = candidate.id, - referenceClipPcm = PcmCodec.encode(candidate.referenceClip), + referenceClipPcm = pcm, sampleRate = sampleRate, confirmedCount = candidate.confirmedCount, lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId, diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt new file mode 100644 index 0000000..3718f58 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt @@ -0,0 +1,112 @@ +package org.terst.listen.fingerprint + +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.ShowFingerprintEntity +import org.terst.listen.data.ShowMidrollEntity +import org.terst.listen.data.SubscriptionEntity + +/** The three lines the episode screen shows about skipping. [breaks] is never empty. */ +data class EpisodeSkips(val intro: String, val outro: String, val breaks: List<String>) + +/** + * Says in words what the skip detection has worked out, so it stops being invisible. + * + * Tier 3 decides on its own, in a background worker, whether to cut audio out of an episode. + * Until this existed there was no way to tell a show it had confirmed an intro for from one it + * had never looked at -- both simply played. The distinction the readout has to carry is + * confirmed-and-acting versus still-gathering, because only the first one skips anything. + * + * Pure, and the wording is pinned by tests: this is the only explanation of the feature the app + * ever gives. + */ +object SkipSummary { + + private const val MS_PER_SECOND = 1000L + private const val SECONDS_PER_MINUTE = 60 + private const val MINUTES_PER_HOUR = 60 + + /** One end of a show's episodes -- its intro or its outro. */ + fun candidateState(state: ShowFingerprintEntity?, threshold: Int): String { + // No reference clip means no candidate: either nothing has been processed, or a single + // episode's clip is set aside with nothing yet to compare it against. Both are the same + // thing to a reader -- detection has not found anything. + if (state?.referenceClipPcm == null) return "Nothing found yet" + + return if (state.confirmedCount >= threshold) { + "Confirmed by ${state.confirmedCount} episodes" + } else { + "Found, ${state.confirmedCount} of $threshold episodes agree" + } + } + + /** A show's midroll candidates, which unlike intro/outro can be several at once. */ + fun midrollState(candidates: List<ShowMidrollEntity>, threshold: Int): String { + if (candidates.isEmpty()) return "None found yet" + + val confirmed = candidates.count { it.confirmedCount >= threshold } + val gathering = candidates.size - confirmed + + return listOfNotNull( + "$confirmed confirmed".takeIf { confirmed > 0 }, + "$gathering still gathering".takeIf { gathering > 0 }, + ).joinToString(", ") + } + + /** + * The offset actually in force for one episode, and where it came from. + * + * Mirrors [org.terst.listen.playback.effectiveIntroMs]: a detected value wins over the + * manual one. A detected *zero* is a real finding -- this show turned out to have no intro + * -- and must not read as "nothing detected", or the readout would disagree with what + * playback is doing. + */ + private fun offset(detectedMs: Long?, manualMs: Long): String = when { + detectedMs != null -> "${describe(detectedMs)}, detected" + manualMs > 0 -> "${describe(manualMs)}, set by hand" + else -> "None" + } + + /** Detected ad breaks in one episode, earliest first, as clock times you can scrub to. */ + private fun ranges(ranges: List<EpisodeMidrollEntity>): List<String> = + ranges.sortedBy { it.startMs }.map { "${clock(it.startMs)} - ${clock(it.endMs)}" } + + /** + * Everything the episode screen says about skipping, decided here rather than in the + * composable so the "not checked yet" case is actually covered by a test. + */ + fun episode( + episode: EpisodeEntity, + show: SubscriptionEntity?, + ranges: List<EpisodeMidrollEntity>, + ): EpisodeSkips { + val breaks = ranges(ranges) + return EpisodeSkips( + intro = offset(episode.detectedIntroMs, show?.skipIntroMs ?: 0), + outro = offset(episode.detectedOutroMs, show?.skipOutroMs ?: 0), + breaks = when { + breaks.isNotEmpty() -> breaks + // Distinct from "none found": scanning runs nightly while charging, so a + // freshly downloaded episode has genuinely not been looked at yet. + episode.midrollCheckedAtMs == null -> listOf("Not checked yet") + else -> listOf("None found") + }, + ) + } + + private fun describe(ms: Long): String = + if (ms <= 0) "None" else "${ms / MS_PER_SECOND}s" + + private fun clock(ms: Long): String { + val totalSeconds = ms / MS_PER_SECOND + val seconds = totalSeconds % SECONDS_PER_MINUTE + val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR + val hours = totalSeconds / SECONDS_PER_MINUTE / MINUTES_PER_HOUR + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt new file mode 100644 index 0000000..4d425df --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/StoredClip.kt @@ -0,0 +1,32 @@ +package org.terst.listen.fingerprint + +/** + * How much decoded audio may be put in a database row. + * + * Android hands every Room read through a CursorWindow, which refuses a row larger than 2MB -- + * on the way *out*. An oversized blob inserts cleanly and then throws + * `SQLiteBlobTooBigException` the next time anything reads it, which is how the intro/outro + * half of Tier 3 shipped unable to get past a show's second episode. + * + * Every persisted clip goes through [fits], so the limit is one shared fact rather than + * something each caller has to remember. + */ +object StoredClip { + + /** Android's own cap on a row read through a CursorWindow. */ + const val CURSOR_WINDOW_LIMIT_BYTES = 2 * 1024 * 1024 + + /** Half of it, so the columns alongside the blob can never tip an accepted clip over. */ + const val SAFE_BYTES = CURSOR_WINDOW_LIMIT_BYTES / 2 + + /** + * The longest midroll segment worth keeping, at [MediaCodecClipSource.TARGET_SAMPLE_RATE]. + * + * Sixty seconds is about 960KB, comfortably inside [SAFE_BYTES], and long enough to + * identify an ad break by correlation several times over -- matching is about whether the + * segment recurs, not about hearing all of it. + */ + const val MAX_CANDIDATE_SECONDS = 60 + + fun fits(bytes: Int): Boolean = bytes <= SAFE_BYTES +} diff --git a/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt new file mode 100644 index 0000000..de52742 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/BedtimePolicy.kt @@ -0,0 +1,31 @@ +package org.terst.listen.playback + +/** What switching bedtime mode should do to the sleep timer. */ +sealed interface BedtimeAction { + data class ArmSleepTimer(val minutes: Long) : BedtimeAction + data object CancelSleepTimer : BedtimeAction + data object Nothing : BedtimeAction +} + +/** + * Bedtime mode's one side effect: it arms the sleep timer, and takes it away again. + * + * Pure, because both directions have a failure that is invisible until it bites. Arming over a + * timer the listener set by hand silently changes a duration they chose; cancelling on the way + * out when bedtime never armed anything silently kills that same timer. Neither shows up as a + * crash -- they show up as audio still playing at 3am, or stopping mid-sentence. + */ +object BedtimePolicy { + + /** + * Long rather than short. [SleepTimer]'s own reasoning applies: stopping late costs + * nothing, stopping early wakes you up to restart it. + */ + const val DEFAULT_MINUTES = 45L + + fun entering(timerAlreadyArmed: Boolean): BedtimeAction = + if (timerAlreadyArmed) BedtimeAction.Nothing else BedtimeAction.ArmSleepTimer(DEFAULT_MINUTES) + + fun leaving(bedtimeArmedIt: Boolean): BedtimeAction = + if (bedtimeArmedIt) BedtimeAction.CancelSleepTimer else BedtimeAction.Nothing +} diff --git a/app/src/main/java/org/terst/listen/ui/DripSettings.kt b/app/src/main/java/org/terst/listen/ui/DripSettings.kt index 9b8188a..97ba063 100644 --- a/app/src/main/java/org/terst/listen/ui/DripSettings.kt +++ b/app/src/main/java/org/terst/listen/ui/DripSettings.kt @@ -3,19 +3,13 @@ package org.terst.listen.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.terst.listen.data.SubscriptionEntity import java.util.concurrent.TimeUnit @@ -41,43 +35,32 @@ fun DripSettings( onFetchArchive: () -> Unit, ) { Column( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), + Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - // Named, because a bare switch has nothing for a screen reader to announce -- - // and nothing for the UI checker to tap by. - modifier = Modifier.semantics { contentDescription = "Turn slow drip on or off" }, - checked = subscription.dripEnabled, - onCheckedChange = { wanted -> - if (wanted) { - onEnable( - subscription.dripIntervalMs, - subscription.dripCount, - subscription.dripOldestFirst, - ) - } else { - onDisable() - } - }, - ) - Column(Modifier.padding(start = 12.dp)) { - Text("Slow drip", style = MaterialTheme.typography.titleSmall) - Text(dripSummary(subscription), style = MaterialTheme.typography.bodySmall) - } - } + SwitchRow( + label = "Slow drip", + description = "Turn slow drip on or off", + checked = subscription.dripEnabled, + detail = dripSummary(subscription), + onCheckedChange = { wanted -> + if (wanted) { + onEnable( + subscription.dripIntervalMs, + subscription.dripCount, + subscription.dripOldestFirst, + ) + } else { + onDisable() + } + }, + ) if (subscription.dripEnabled) { DripCadence(subscription, onEnable) } OutlinedButton(onClick = onFetchArchive, enabled = !busy) { Text("Fetch archive") } - Text( - "Pulls older episodes by following the feed's archive pages. Many publishers do not " + - "offer them, in which case a drip can only work through what the feed lists today.", - style = MaterialTheme.typography.bodySmall, - ) } } @@ -127,11 +110,9 @@ private fun DripChip(label: String, selected: Boolean, onClick: () -> Unit) { } private fun dripSummary(subscription: SubscriptionEntity): String { - if (!subscription.dripEnabled) { - return "Off. The whole catalogue is listed." - } + if (!subscription.dripEnabled) return "Whole catalogue listed" val days = TimeUnit.MILLISECONDS.toDays(subscription.dripIntervalMs) val every = if (days == 1L) "day" else "$days days" val order = if (subscription.dripOldestFirst) "oldest" else "newest" - return "${subscription.dripCount} $order first, every $every. Only released episodes are listed." + return "${subscription.dripCount} $order first, every $every" } 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 0e161e3..09356f1 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -36,7 +36,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.SubscriptionEntity import org.terst.listen.feed.WatchLink +import org.terst.listen.fingerprint.EpisodeSkips +import org.terst.listen.fingerprint.SkipSummary +import org.terst.listen.playback.effectiveIntroMs +import org.terst.listen.playback.effectiveOutroMs /** * One episode in full. @@ -54,8 +60,13 @@ fun EpisodeScreen( nav: SubscriptionNav, ) { var episode by remember { mutableStateOf<EpisodeEntity?>(null) } - LaunchedEffect(episodeId) { episode = vm.episode(episodeId) } + var ranges by remember { mutableStateOf<List<EpisodeMidrollEntity>>(emptyList()) } + LaunchedEffect(episodeId) { + episode = vm.episode(episodeId) + ranges = vm.midrollRanges(episodeId) + } val rules by vm.rules.collectAsState() + val shows by vm.subscriptions.collectAsState() val current = episode Scaffold( @@ -78,6 +89,11 @@ fun EpisodeScreen( EpisodeDetail( episode = current, ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name, + skips = SkipSummary.episode( + current, + shows.firstOrNull { it.id == current.subscriptionId }, + ranges, + ), modifier = Modifier.padding(padding), actions = EpisodeDetailActions( play = { player.play(current); nav.openPlayer() }, @@ -97,6 +113,7 @@ fun EpisodeScreen( private fun EpisodeDetail( episode: EpisodeEntity, ruleName: String?, + skips: EpisodeSkips, modifier: Modifier, actions: EpisodeDetailActions, ) { @@ -140,6 +157,8 @@ private fun EpisodeDetail( RuleAttribution(episode, ruleName) + SkipReadout(skips) + episode.description?.let { Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium) } @@ -147,6 +166,29 @@ private fun EpisodeDetail( } /** + * What is actually being skipped in this episode, and where the numbers came from. + * + * The one place the detection is visible per episode. It deliberately reports what playback + * will really do -- [effectiveIntroMs] and [effectiveOutroMs] prefer a detected value over the + * manual one, so reading the manual field here would quietly contradict the player. + * + * "Not checked yet" is a distinct state from "nothing found". Midroll scanning runs nightly + * while charging, so a freshly downloaded episode legitimately has nothing to say yet, and + * reporting that as "no ad breaks" would look like the feature failing. + */ +@Composable +private fun SkipReadout(skips: EpisodeSkips) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Skips", style = MaterialTheme.typography.titleSmall) + ReadoutRow("Intro", skips.intro) + ReadoutRow("Outro", skips.outro) + skips.breaks.forEachIndexed { index, line -> + ReadoutRow(if (index == 0) "Ad breaks" else "", line) + } + } +} + +/** * Show notes are HTML in practice, whatever the spec says. * * Rendered raw they appear as a wall of tags. This is not a parser — it strips tags, decodes the diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt index 16301aa..a3a1149 100644 --- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button @@ -47,15 +49,17 @@ import coil3.compose.AsyncImage import org.terst.listen.data.DownloadState import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.SubscriptionEntity +import org.terst.listen.data.SubscriptionListing import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) { - val subscriptions by vm.subscriptions.collectAsState() + val subscriptions by vm.library.collectAsState() val queue by vm.queue.collectAsState() val refreshing by vm.refreshing.collectAsState() val banner by vm.banner.collectAsState() + val bedtime by vm.bedtime.collectAsState() val nowPlaying by player.nowPlaying.collectAsState() val snackbar = remember { SnackbarHostState() } var adding by remember { mutableStateOf(false) } @@ -69,19 +73,22 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) Scaffold( topBar = { - TopAppBar( - title = { Text("Listen") }, - actions = { - IconButton(onClick = { adding = !adding }) { - Icon(Icons.Default.Add, contentDescription = "Add a podcast") - } - IconButton(onClick = vm::refreshAll, enabled = !refreshing) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") - } - IconButton(onClick = nav.openSettings) { - Icon(Icons.Default.Settings, contentDescription = "Settings") - } - }, + LibraryTopBar( + bedtime = bedtime, + refreshing = refreshing, + actions = LibraryTopBarActions( + toggleAdd = { adding = !adding }, + refreshAll = vm::refreshAll, + // Both halves of the switch happen here rather than inside a view model: + // the show list is library state and the sleep timer is playback state, + // and they live in different view models by design. + toggleBedtime = { + val entering = !bedtime + vm.setBedtime(entering) + player.onBedtimeChanged(entering) + }, + openSettings = nav.openSettings, + ), ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, @@ -106,15 +113,53 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) AddSubscription(onSubmit = { vm.subscribe(it); adding = false }) } - LibraryList(subscriptions, queue, nav.openSubscription, nav.openQueue) + LibraryList(subscriptions, queue, bedtime, nav.openSubscription, nav.openQueue) } } } +/** The top bar's four callbacks, bundled so the signature stays readable. */ +private data class LibraryTopBarActions( + val toggleAdd: () -> Unit, + val refreshAll: () -> Unit, + val toggleBedtime: () -> Unit, + val openSettings: () -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LibraryTopBar( + bedtime: Boolean, + refreshing: Boolean, + actions: LibraryTopBarActions, +) { + TopAppBar( + title = { Text(if (bedtime) "Bedtime" else "Listen") }, + actions = { + IconButton(onClick = actions.toggleAdd) { + Icon(Icons.Default.Add, contentDescription = "Add a podcast") + } + IconButton(onClick = actions.refreshAll, enabled = !refreshing) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") + } + IconButton(onClick = actions.toggleBedtime) { + Icon( + if (bedtime) Icons.Default.LightMode else Icons.Default.Bedtime, + contentDescription = if (bedtime) "Leave bedtime mode" else "Enter bedtime mode", + ) + } + IconButton(onClick = actions.openSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + }, + ) +} + @Composable private fun LibraryList( - subscriptions: List<SubscriptionEntity>, + subscriptions: List<SubscriptionListing>, queue: List<EpisodeEntity>, + bedtime: Boolean, onOpenSubscription: (String) -> Unit, onOpenQueue: () -> Unit, ) { @@ -123,10 +168,10 @@ private fun LibraryList( item { UpNextCard(queue, onOpenQueue) } } if (subscriptions.isEmpty()) { - item { EmptyLibrary() } + item { if (bedtime) EmptyBedtime() else EmptyLibrary() } } - items(subscriptions, key = { it.id }) { subscription -> - SubscriptionRow(subscription) { onOpenSubscription(subscription.id) } + items(subscriptions, key = { it.subscription.id }) { listing -> + SubscriptionRow(listing) { onOpenSubscription(listing.subscription.id) } HorizontalDivider() } } @@ -147,6 +192,20 @@ private fun UpNextCard(queue: List<EpisodeEntity>, onOpenQueue: () -> Unit) { } } +/** Dimmed rather than hidden: a show you are caught up on is still one you subscribe to. */ +private const val CAUGHT_UP_ALPHA = 0.38f + +@Composable +private fun EmptyBedtime() { + Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("No bedtime podcasts", style = MaterialTheme.typography.titleMedium) + Text( + "Mark a show as Bedtime in its menu and it moves here, out of the main list.", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + @Composable private fun EmptyLibrary() { Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -177,21 +236,43 @@ private fun AddSubscription(onSubmit: (String) -> Unit) { } } +/** + * One show. Dimmed when it has nothing waiting, so a scan down the list separates the shows + * with something to hear from the ones you are caught up on. + * + * The dimming stops short of [SubscriptionEntity.lastError], which keeps its error colour: a + * feed that is failing to refresh looks exactly like a feed with nothing new, and that is the + * one case where "nothing waiting" is a lie worth shouting about. + */ @Composable -private fun SubscriptionRow(subscription: SubscriptionEntity, onClick: () -> Unit) { +private fun SubscriptionRow(listing: SubscriptionListing, onClick: () -> Unit) { + val subscription = listing.subscription + val alpha = if (listing.hasWaiting) 1f else CAUGHT_UP_ALPHA + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) { AsyncImage( model = subscription.imageUrl, contentDescription = null, + alpha = alpha, modifier = Modifier .size(48.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(Modifier.weight(1f).padding(start = 12.dp)) { - Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) + Text( + subscription.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + maxLines = 2, + ) subscription.author?.let { - Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + maxLines = 1, + ) } // A feed that has quietly stopped working is otherwise indistinguishable from one // with nothing new, which is the whole reason the error is stored rather than logged. 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 2b520ec..831ec8f 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -3,10 +3,12 @@ package org.terst.listen.ui import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity @@ -16,6 +18,7 @@ import org.terst.listen.data.RefreshOutcome import org.terst.listen.data.RuleEntity import org.terst.listen.data.SubscriptionEntity import org.terst.listen.data.SubscriptionKind +import org.terst.listen.data.SubscriptionListing import org.terst.listen.feed.FeedResolver import org.terst.listen.feed.SubscriptionResolver import org.terst.listen.feed.YouTubeUrls @@ -27,10 +30,36 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { private val graph = Graph.get(app) + /** + * Whether the library is showing bedtime shows instead of the ordinary ones. + * + * Deliberately not persisted. It describes the next half hour, not a setting: waking up to + * an app still in bedtime mode, with the whole library apparently gone, is the failure this + * avoids. A restart is morning. + */ + private val _bedtime = MutableStateFlow(false) + val bedtime: StateFlow<Boolean> = _bedtime.asStateFlow() + + /** What the library screen lists, which of the two lists depending on [bedtime]. */ + @OptIn(ExperimentalCoroutinesApi::class) + val library: StateFlow<List<SubscriptionListing>> = + _bedtime.flatMapLatest { graph.subscriptions.observeAll(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + + /** + * Every show, for the screens that look one up by id. + * + * Separate from [library] on purpose: a show screen opened from bedtime mode must survive + * bedtime mode being switched off, and it would blank if it read the filtered list. + */ val subscriptions: StateFlow<List<SubscriptionEntity>> = - graph.subscriptions.observeAll() + graph.subscriptions.observeEvery() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) + fun setBedtime(enabled: Boolean) { + _bedtime.value = enabled + } + val queue: StateFlow<List<EpisodeEntity>> = graph.queue.observe() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) @@ -52,6 +81,9 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id) + /** The ad breaks detection has confirmed in one episode, for the episode screen's readout. */ + suspend fun midrollRanges(episodeId: String) = graph.midrolls.getRanges(episodeId) + /** * A show's episodes as it displays them. * diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt index cc28fe9..fed4960 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -18,6 +18,8 @@ import org.terst.listen.feed.WatchLink import org.terst.listen.playback.NowPlaying import org.terst.listen.playback.PlayerConnection import org.terst.listen.playback.StartOutcome +import org.terst.listen.playback.BedtimeAction +import org.terst.listen.playback.BedtimePolicy import org.terst.listen.playback.SleepTimer import org.terst.listen.playback.toMediaItem @@ -203,6 +205,31 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { fun cancelSleepTimer() { sleepTimer.cancel() _sleepRemainingMs.value = null + bedtimeArmedTimer = false + } + + /** + * Tracked so leaving bedtime mode only cancels a timer bedtime mode started. + * + * Without it, flicking the moon icon off would silently kill a timer set by hand. + */ + private var bedtimeArmedTimer = false + + /** Bedtime mode's effect on playback: see [BedtimePolicy] for why each case is what it is. */ + fun onBedtimeChanged(entering: Boolean) { + val action = if (entering) { + BedtimePolicy.entering(timerAlreadyArmed = sleepTimer.isArmed) + } else { + BedtimePolicy.leaving(bedtimeArmedIt = bedtimeArmedTimer) + } + when (action) { + is BedtimeAction.ArmSleepTimer -> { + armSleepTimer(action.minutes) + bedtimeArmedTimer = true + } + BedtimeAction.CancelSleepTimer -> cancelSleepTimer() + BedtimeAction.Nothing -> Unit + } } /** @@ -219,6 +246,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { connection.pause() sleepTimer.cancel() _sleepRemainingMs.value = null + bedtimeArmedTimer = false _banner.value = Banner("Sleep timer finished") break } diff --git a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt index 5080011..98ae063 100644 --- a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt @@ -24,7 +24,7 @@ class RulesViewModel(app: Application) : AndroidViewModel(app) { /** For the "only this show" condition, and for naming a scoped rule in the list. */ val subscriptions: StateFlow<List<SubscriptionEntity>> = - graph.subscriptions.observeAll() + graph.subscriptions.observeEvery() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList()) /** diff --git a/app/src/main/java/org/terst/listen/ui/SettingRows.kt b/app/src/main/java/org/terst/listen/ui/SettingRows.kt new file mode 100644 index 0000000..a1a6db9 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SettingRows.kt @@ -0,0 +1,59 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** + * A labelled switch, with the accessibility name every switch in this app has to carry. + * + * A bare `Switch` announces nothing to TalkBack and cannot be found by `android-ui tap`, and + * [description] has to differ from [label] -- two identical strings beside each other make the + * control ambiguous to pick, for a screen reader and the UI checker alike. + */ +@Composable +fun SwitchRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + detail: String? = null, +) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + modifier = Modifier.semantics { contentDescription = description }, + checked = checked, + onCheckedChange = onCheckedChange, + ) + Column(Modifier.padding(start = 12.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge) + detail?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } + } +} + +/** A label and its current value on one line -- the shape every read-only readout here uses. */ +@Composable +fun ReadoutRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt new file mode 100644 index 0000000..b387a2c --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -0,0 +1,109 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.terst.listen.data.SubscriptionEntity + +/** + * Everything that is a setting for one show, in a sheet rather than stacked above its episodes. + * + * These used to sit inline at the top of the show screen, which meant scrolling past drip + * cadence and two paragraphs of explanation to reach the episodes -- the thing the screen is + * for. Peter's call, and the prose went with it: this is a single-user app and he wrote the + * requirements. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShowSettingsSheet( + show: SubscriptionEntity, + settings: ShowSettingsViewModel, + onDismiss: () -> Unit, +) { + val busy by settings.busy.collectAsState() + val detection by settings.detection.collectAsState() + + // Read once per opening. Nothing can move it while the sheet is up: detection only advances + // in a background worker. + LaunchedEffect(show.id) { settings.loadDetection(show.id) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(show.title, style = MaterialTheme.typography.titleMedium) + + SwitchRow( + label = "Bedtime", + description = "Move this show in or out of bedtime", + checked = show.bedtime, + detail = if (show.bedtime) "Only listed in bedtime mode" else "In the main library", + onCheckedChange = { settings.setBedtime(show.id, it) }, + ) + + HorizontalDivider() + + DripSettings( + subscription = show, + busy = busy, + onEnable = { intervalMs, count, oldestFirst -> + settings.enableDrip(show.id, intervalMs, count, oldestFirst) + }, + onDisable = { settings.disableDrip(show.id) }, + onFetchArchive = { settings.fetchArchive(show.id) }, + ) + + HorizontalDivider() + + SkipSettings( + subscription = show, + onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) }, + onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) }, + ) + + HorizontalDivider() + + DetectionReadout(detection) + } + } +} + +/** + * What the automatic detection has worked out for this show. + * + * Read-only, and the point of it: until this existed, a show whose intro had been confirmed and + * one that had never been looked at were indistinguishable -- both simply played. + */ +@Composable +private fun DetectionReadout(detection: ShowDetection?) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Detected skips", style = MaterialTheme.typography.titleSmall) + if (detection == null) { + Text("Reading…", style = MaterialTheme.typography.bodySmall) + return@Column + } + ReadoutRow("Intro", detection.intro) + ReadoutRow("Outro", detection.outro) + ReadoutRow("Ad breaks", detection.midrolls) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt index 1e972f9..a84fa40 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.terst.listen.data.ArchiveOutcome +import org.terst.listen.data.FingerprintKind import org.terst.listen.data.Graph +import org.terst.listen.fingerprint.IntroOutroDetector +import org.terst.listen.fingerprint.MidrollDetector +import org.terst.listen.fingerprint.SkipSummary /** * An archive walk, reported in full. @@ -109,4 +113,50 @@ class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) { fun setSkipAdChapters(subscriptionId: String, enabled: Boolean) { viewModelScope.launch { graph.subscriptions.setSkipAdChapters(subscriptionId, enabled) } } + + /** Moves a show in or out of the bedtime list. */ + fun setBedtime(subscriptionId: String, enabled: Boolean) { + viewModelScope.launch { + graph.subscriptions.setBedtime(subscriptionId, enabled) + _banner.value = if (enabled) { + Banner("Bedtime — hidden until you switch to bedtime mode") + } else { + Banner("Back in the main library") + } + } + } + + private val _detection = MutableStateFlow<ShowDetection?>(null) + + /** What Tier 3 has worked out for the show currently on screen. */ + val detection: StateFlow<ShowDetection?> = _detection.asStateFlow() + + /** + * Read on demand rather than observed. + * + * Detection only ever moves when a background worker runs, which is per download for + * intro/outro and nightly for midrolls -- never while this sheet is open. A Flow would buy + * nothing and would mean three more queries running for every show screen. + */ + fun loadDetection(subscriptionId: String) { + viewModelScope.launch { + _detection.value = ShowDetection( + intro = SkipSummary.candidateState( + graph.fingerprints.get(subscriptionId, FingerprintKind.INTRO), + IntroOutroDetector.CONFIRM_THRESHOLD, + ), + outro = SkipSummary.candidateState( + graph.fingerprints.get(subscriptionId, FingerprintKind.OUTRO), + IntroOutroDetector.CONFIRM_THRESHOLD, + ), + midrolls = SkipSummary.midrollState( + graph.midrolls.getCandidates(subscriptionId), + MidrollDetector.CONFIRM_THRESHOLD, + ), + ) + } + } } + +/** The three lines of Tier 3 state a show has, already worded. */ +data class ShowDetection(val intro: String, val outro: String, val midrolls: String) diff --git a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt index 106e3fd..fdee5d0 100644 --- a/app/src/main/java/org/terst/listen/ui/SkipSettings.kt +++ b/app/src/main/java/org/terst/listen/ui/SkipSettings.kt @@ -4,17 +4,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import org.terst.listen.data.SubscriptionEntity @@ -22,11 +17,11 @@ import org.terst.listen.data.SubscriptionEntity private const val MS_PER_SECOND = 1000L /** - * Per-show intro and outro offsets. + * Per-show intro and outro offsets, and the chapter-break opt-in. * * The cheap tier of the skip feature, and the one that always works: a show whose theme runs * forty-seven seconds gets forty-seven seconds skipped, with no detection involved. It is also - * the override for when detection eventually arrives and gets a show wrong. + * the override for when Tier 3 gets a show wrong. */ @Composable fun SkipSettings( @@ -35,7 +30,7 @@ fun SkipSettings( onSkipAdChapters: (Boolean) -> Unit, ) { Column( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), + Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text("Skip", style = MaterialTheme.typography.titleSmall) @@ -51,25 +46,11 @@ fun SkipSettings( modifier = Modifier.weight(1f), ) { onChange(subscription.skipIntroMs, it) } } - Text( - "Starts each new episode past the intro and treats it as finished before the outro. " + - "Resuming something part-heard still lands where you left it.", - style = MaterialTheme.typography.bodySmall, - ) - - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - modifier = Modifier.semantics { contentDescription = "Skip marked ad chapters" }, - checked = subscription.skipAdChapters, - onCheckedChange = onSkipAdChapters, - ) - Text("Skip marked breaks", Modifier.padding(start = 12.dp)) - } - Text( - "On by default. Only acts where the show publishes chapters and marks a section " + - "as not part of the episode -- nothing is guessed from chapter titles, so a " + - "show discussing its own sponsors keeps playing.", - style = MaterialTheme.typography.bodySmall, + SwitchRow( + label = "Skip marked breaks", + description = "Skip marked ad chapters", + checked = subscription.skipAdChapters, + onCheckedChange = onSkipAdChapters, ) } } diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt index 6018e56..c93f353 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -15,11 +15,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -66,6 +69,7 @@ fun SubscriptionScreen( nav: SubscriptionNav, ) { var confirming by remember { mutableStateOf(false) } + var showingSettings by remember { mutableStateOf(false) } val refreshing by vm.refreshing.collectAsState() val nowPlaying by player.nowPlaying.collectAsState() val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId } @@ -83,9 +87,12 @@ fun SubscriptionScreen( SubscriptionTopBar( title = subscription?.title, refreshing = refreshing, - onRefresh = { vm.refresh(subscriptionId) }, - onUnsubscribe = { confirming = true }, - onBack = nav.back, + actions = SubscriptionTopBarActions( + refresh = { vm.refresh(subscriptionId) }, + openSettings = { showingSettings = true }, + unsubscribe = { confirming = true }, + back = nav.back, + ), ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, @@ -111,10 +118,10 @@ fun SubscriptionScreen( modifier = Modifier.padding(padding), ) { PlayedToggle(showPlayed, vm::setShowPlayed) - subscription?.let { show -> - HorizontalDivider(Modifier.padding(vertical = 12.dp)) - ShowSettings(show, settings) - } + } + + subscription?.takeIf { showingSettings }?.let { show -> + ShowSettingsSheet(show, settings) { showingSettings = false } } } } @@ -192,50 +199,16 @@ private fun SubscriptionBody( } } -/** Slow drip and the skip offsets, which are settings for this show rather than for the app. */ -@Composable -private fun ShowSettings(show: SubscriptionEntity, settings: ShowSettingsViewModel) { - val busy by settings.busy.collectAsState() - - DripSettings( - subscription = show, - busy = busy, - onEnable = { intervalMs, count, oldestFirst -> - settings.enableDrip(show.id, intervalMs, count, oldestFirst) - }, - onDisable = { settings.disableDrip(show.id) }, - onFetchArchive = { settings.fetchArchive(show.id) }, - ) - HorizontalDivider(Modifier.padding(vertical = 12.dp)) - SkipSettings( - subscription = show, - onChange = { introMs, outroMs -> settings.setSkips(show.id, introMs, outroMs) }, - onSkipAdChapters = { settings.setSkipAdChapters(show.id, it) }, - ) - HorizontalDivider(Modifier.padding(top = 12.dp)) -} - /** A view preference rather than a show setting, so it says so and applies everywhere. */ @Composable private fun PlayedToggle(showPlayed: Boolean, onShowPlayed: (Boolean) -> Unit) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Switch( - modifier = Modifier.semantics { // Distinct from the visible label: identical strings make the switch ambiguous to - // pick, for a screen reader and for the UI checker alike. - contentDescription = "Show or hide played episodes" }, + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + SwitchRow( + label = "Show played episodes", + description = "Show or hide played episodes", checked = showPlayed, onCheckedChange = onShowPlayed, ) - Column(Modifier.padding(start = 12.dp)) { - Text("Show played episodes", style = MaterialTheme.typography.titleSmall) - Text( - if (showPlayed) "Listed in every show." else "Hidden in every show.", - style = MaterialTheme.typography.bodySmall, - ) - } } } @@ -295,28 +268,52 @@ data class SubscriptionNav( val back: () -> Unit, ) +/** The top bar's four callbacks, bundled so the signature stays readable. */ +private data class SubscriptionTopBarActions( + val refresh: () -> Unit, + val openSettings: () -> Unit, + val unsubscribe: () -> Unit, + val back: () -> Unit, +) + +/** + * Refresh stays an icon; everything else is behind the overflow. + * + * Unsubscribe in particular: it was one tap from a bin icon next to Refresh, which is a hard + * action to undo sitting next to one you reach for constantly. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SubscriptionTopBar( title: String?, refreshing: Boolean, - onRefresh: () -> Unit, - onUnsubscribe: () -> Unit, - onBack: () -> Unit, + actions: SubscriptionTopBarActions, ) { + var expanded by remember { mutableStateOf(false) } + TopAppBar( title = { Text(title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = actions.back) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { - IconButton(onClick = onRefresh, enabled = !refreshing) { + IconButton(onClick = actions.refresh, enabled = !refreshing) { Icon(Icons.Default.Refresh, contentDescription = "Refresh this podcast") } - IconButton(onClick = onUnsubscribe) { - Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "Podcast menu") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Settings") }, + onClick = { expanded = false; actions.openSettings() }, + ) + DropdownMenuItem( + text = { Text("Unsubscribe") }, + onClick = { expanded = false; actions.unsubscribe() }, + ) } }, ) diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt index a085070..6ce26c6 100644 --- a/app/src/main/java/org/terst/listen/work/Workers.kt +++ b/app/src/main/java/org/terst/listen/work/Workers.kt @@ -84,7 +84,7 @@ class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineW val graph = Graph.get(applicationContext) val episode = graph.episodes.getById(episodeId) ?: return Result.success() - FingerprintUpdater(graph.fingerprints, MediaCodecClipSource()).process(episode) + FingerprintUpdater(graph.fingerprints, graph.episodes, MediaCodecClipSource()).process(episode) return Result.success() } 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 16f46f4..863f265 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -12,7 +12,13 @@ import kotlinx.coroutines.flow.flowOf class FakeSubscriptionDao : SubscriptionDao { val rows = mutableListOf<SubscriptionEntity>() - override fun observeAll(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList()) + // Ordering and hasWaiting are both SQL, so neither is reproduced here -- LibraryOrderTest + // pins them against a real database instead. This only honours the bedtime split, which + // callers do branch on. + override fun observeAll(bedtime: Boolean): Flow<List<SubscriptionListing>> = + flowOf(rows.filter { it.bedtime == bedtime }.map { SubscriptionListing(it, hasWaiting = true) }) + + override fun observeEvery(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList()) override suspend fun getAll() = rows.toList() override suspend fun getById(id: String) = rows.firstOrNull { it.id == id } override suspend fun getByFeedUrl(feedUrl: String) = rows.firstOrNull { it.feedUrl == feedUrl } @@ -48,6 +54,10 @@ class FakeSubscriptionDao : SubscriptionDao { replace(id) { it.copy(skipAdChapters = enabled) } } + override suspend fun setBedtime(id: String, enabled: Boolean) { + replace(id) { it.copy(bedtime = enabled) } + } + override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) { replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) } } diff --git a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt index 0cc9d7f..2139215 100644 --- a/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt +++ b/app/src/test/java/org/terst/listen/data/ShowFingerprintEntityTest.kt @@ -12,12 +12,11 @@ import org.junit.Test */ class ShowFingerprintEntityTest { - private fun entity(pcm: ByteArray?, pending: ByteArray? = null) = ShowFingerprintEntity( + private fun entity(pcm: ByteArray?, pendingEpisodeId: String? = null) = ShowFingerprintEntity( subscriptionId = "s1", kind = FingerprintKind.INTRO, referenceClipPcm = pcm, - pendingClipPcm = pending, - pendingEpisodeId = null, + pendingEpisodeId = pendingEpisodeId, sampleRate = 8_000, confirmedCount = 3, lastConfirmedEpisodeId = "e1", @@ -43,15 +42,15 @@ class ShowFingerprintEntityTest { } @Test - fun `two rows with both clips null are equal`() { - assertEquals(entity(null, null), entity(null, null)) + fun `two rows with no clip at all are equal`() { + assertEquals(entity(null), entity(null)) } @Test - fun `differing pending clips are not equal even when the reference clip matches`() { + fun `differing pending episodes are not equal even when the reference clip matches`() { assertNotEquals( - entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(9)), - entity(byteArrayOf(1, 2, 3), pending = byteArrayOf(8)), + entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e9"), + entity(byteArrayOf(1, 2, 3), pendingEpisodeId = "e8"), ) } } diff --git a/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt new file mode 100644 index 0000000..7992098 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/StoredClipSizeTest.kt @@ -0,0 +1,60 @@ +package org.terst.listen.data + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.fingerprint.FingerprintUpdater +import org.terst.listen.fingerprint.MediaCodecClipSource +import org.terst.listen.fingerprint.StoredClip + +/** + * Guards the bug class that took Tier 3 down: a blob big enough to write but too big to read. + * + * Android reads every Room row through a CursorWindow, and a row over 2MB throws + * `SQLiteBlobTooBigException` on the way *out*. Nothing complains on insert, nothing complains + * at build time, and the unit tests were perfectly happy -- it only surfaced on a device, as a + * hard crash the moment a show's second episode was fingerprinted. + * + * Decoded audio is the only thing in this schema anywhere near that size, so these pin the two + * clips that get persisted against the limit rather than trusting anyone to remember it. + */ +class StoredClipSizeTest { + + private fun encodedBytes(seconds: Int, sampleRate: Int) = seconds * sampleRate * 2 + + /** The reference clip is short by design, and is the one fingerprint blob that is kept. */ + @Test + fun aStoredFingerprintReferenceClipFitsInARow() { + val bytes = encodedBytes(FingerprintUpdater.DEFAULT_REFERENCE_SECONDS, FingerprintUpdater.DEFAULT_SAMPLE_RATE) + + assertTrue("reference clip is $bytes bytes", StoredClip.fits(bytes)) + } + + /** + * The whole head/tail window emphatically does not fit, which is why only the pending + * *episode id* is stored and the audio re-decoded. If this ever starts passing, the window + * shrank and the comment explaining that indirection needs revisiting -- it does not mean + * the clip may now be stored. + */ + @Test + fun awholeHeadWindowDoesNotFitWhichIsWhyItIsNeverStored() { + val bytes = encodedBytes(MediaCodecClipSource.CLIP_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE) + + assertFalse("head window is $bytes bytes", StoredClip.fits(bytes)) + } + + /** A midroll candidate is a matched segment of unbounded length, so it has to be capped. */ + @Test + fun aMidrollCandidateIsCappedBelowTheLimit() { + val cap = encodedBytes(StoredClip.MAX_CANDIDATE_SECONDS, MediaCodecClipSource.TARGET_SAMPLE_RATE) + + assertTrue("cap is $cap bytes", StoredClip.fits(cap)) + } + + /** Half the hard limit, so the rest of the row can never tip an accepted clip over it. */ + @Test + fun theSafeSizeLeavesHeadroomUnderTheHardLimit() { + assertTrue(StoredClip.SAFE_BYTES * 2 <= StoredClip.CURSOR_WINDOW_LIMIT_BYTES) + assertFalse(StoredClip.fits(StoredClip.SAFE_BYTES + 1)) + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt index 4881f88..7dd7d1d 100644 --- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt +++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt @@ -25,7 +25,7 @@ class FingerprintUpdaterTest { episodes = FakeEpisodeDao() fingerprints = FakeShowFingerprintDao().apply { episodeRows = episodes.rows } clips = FakeClipSource() - updater = FingerprintUpdater(fingerprints, clips, referenceLengthSamples = 40) + updater = FingerprintUpdater(fingerprints, episodes, clips, referenceLengthSamples = 40) } private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt new file mode 100644 index 0000000..bf351ae --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt @@ -0,0 +1,209 @@ +package org.terst.listen.fingerprint + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.FingerprintKind +import org.terst.listen.data.ShowFingerprintEntity +import org.terst.listen.data.ShowMidrollEntity +import org.terst.listen.data.SubscriptionEntity + +/** + * The words the app uses to say what the skip detection has worked out. + * + * Worth pinning rather than writing inline in a composable: the whole point of this readout is + * that Peter can tell "confirmed and acting" apart from "still gathering evidence", and a + * summary that blurred the two would be worse than no readout at all. + */ +class SkipSummaryTest { + + private fun fingerprint( + confirmedCount: Int, + hasReference: Boolean = true, + pendingEpisodeId: String? = null, + ) = ShowFingerprintEntity( + subscriptionId = "s1", + kind = FingerprintKind.INTRO, + referenceClipPcm = if (hasReference) byteArrayOf(1, 2) else null, + pendingEpisodeId = pendingEpisodeId, + sampleRate = 8000, + confirmedCount = confirmedCount, + lastConfirmedEpisodeId = null, + updatedAtMs = 0, + ) + + private fun episodeRow( + detectedIntroMs: Long? = null, + detectedOutroMs: Long? = null, + midrollCheckedAtMs: Long? = null, + ) = EpisodeEntity( + id = "e1", + subscriptionId = "s1", + guid = "g1", + title = "One", + mediaUrl = "https://cdn/1.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + detectedIntroMs = detectedIntroMs, + detectedOutroMs = detectedOutroMs, + midrollCheckedAtMs = midrollCheckedAtMs, + ) + + private fun showRow(skipIntroMs: Long = 0, skipOutroMs: Long = 0) = SubscriptionEntity( + id = "s1", + feedUrl = "https://example.com/feed.xml", + title = "A Show", + addedAtMs = 0, + skipIntroMs = skipIntroMs, + skipOutroMs = skipOutroMs, + ) + + private fun candidate(id: String, confirmedCount: Int) = ShowMidrollEntity( + subscriptionId = "s1", + id = id, + referenceClipPcm = byteArrayOf(1, 2), + sampleRate = 8000, + confirmedCount = confirmedCount, + lastConfirmedEpisodeId = null, + updatedAtMs = 0, + ) + + @Test + fun nothingSeenYet() { + assertEquals("Nothing found yet", SkipSummary.candidateState(null, threshold = 3)) + } + + /** One episode in, there is a clip set aside but nothing to compare it against. */ + @Test + fun waitingForASecondEpisode() { + assertEquals( + "Nothing found yet", + SkipSummary.candidateState(fingerprint(0, hasReference = false, pendingEpisodeId = "e1"), 3), + ) + } + + @Test + fun gatheringNamesHowFarOffItIs() { + assertEquals("Found, 2 of 3 episodes agree", SkipSummary.candidateState(fingerprint(2), 3)) + } + + /** The distinction that matters: below the threshold nothing is being skipped. */ + @Test + fun confirmedSaysSo() { + assertEquals("Confirmed by 3 episodes", SkipSummary.candidateState(fingerprint(3), 3)) + } + + @Test + fun confirmedKeepsCountingPastTheThreshold() { + assertEquals("Confirmed by 7 episodes", SkipSummary.candidateState(fingerprint(7), 3)) + } + + @Test + fun noMidrollCandidates() { + assertEquals("None found yet", SkipSummary.midrollState(emptyList(), threshold = 3)) + } + + @Test + fun midrollsCountBothStates() { + val candidates = listOf(candidate("a", 4), candidate("b", 3), candidate("c", 2)) + assertEquals("2 confirmed, 1 still gathering", SkipSummary.midrollState(candidates, 3)) + } + + @Test + fun midrollsGatheringOnly() { + assertEquals("1 still gathering", SkipSummary.midrollState(listOf(candidate("a", 2)), 3)) + } + + @Test + fun midrollsConfirmedOnly() { + assertEquals("1 confirmed", SkipSummary.midrollState(listOf(candidate("a", 3)), 3)) + } + + /** Detection wins over the manual offset, and the readout has to say which is in force. */ + @Test + fun offsetNamesWhereTheNumberCameFrom() { + val detected = SkipSummary.episode(episodeRow(detectedIntroMs = 47_000), showRow(skipIntroMs = 30_000), emptyList()) + assertEquals("47s, detected", detected.intro) + + val manual = SkipSummary.episode(episodeRow(), showRow(skipIntroMs = 30_000), emptyList()) + assertEquals("30s, set by hand", manual.intro) + + val neither = SkipSummary.episode(episodeRow(), showRow(), emptyList()) + assertEquals("None", neither.intro) + } + + /** A detected zero is a real answer -- this show has no intro -- not a missing one. */ + @Test + fun aDetectedZeroIsNotTheSameAsNothingDetected() { + val skips = SkipSummary.episode(episodeRow(detectedIntroMs = 0), showRow(skipIntroMs = 30_000), emptyList()) + + assertEquals("None, detected", skips.intro) + } + + /** Earliest first, whatever order the rows come back in. */ + @Test + fun rangesReadAsClockTimes() { + val ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 750_000, endMs = 825_000), + EpisodeMidrollEntity(episodeId = "e1", candidateId = "b", startMs = 61_000, endMs = 91_500), + ) + + assertEquals( + listOf("1:01 - 1:31", "12:30 - 13:45"), + SkipSummary.episode(episodeRow(), showRow(), ranges).breaks, + ) + } + + /** + * "Not checked yet" and "none found" are different answers. + * + * Midroll scanning runs nightly while charging, so a freshly downloaded episode has + * legitimately not been looked at -- reporting that as "no ad breaks" would read as the + * detection having run and found nothing, which is the opposite of true. + */ + @Test + fun anUncheckedEpisodeSaysSoRatherThanClaimingNoBreaks() { + val skips = SkipSummary.episode(episodeRow(), showRow(), ranges = emptyList()) + + assertEquals(listOf("Not checked yet"), skips.breaks) + } + + @Test + fun aCheckedEpisodeWithNothingInItSaysNoneFound() { + val skips = SkipSummary.episode( + episodeRow(midrollCheckedAtMs = 1_000), + showRow(), + ranges = emptyList(), + ) + + assertEquals(listOf("None found"), skips.breaks) + } + + @Test + fun episodeReportsTheOffsetsActuallyInForce() { + val skips = SkipSummary.episode( + episodeRow(detectedIntroMs = 47_000, midrollCheckedAtMs = 1_000), + showRow(skipIntroMs = 30_000, skipOutroMs = 20_000), + ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 61_000, endMs = 91_500) + ), + ) + + assertEquals("47s, detected", skips.intro) + assertEquals("20s, set by hand", skips.outro) + assertEquals(listOf("1:01 - 1:31"), skips.breaks) + } + + @Test + fun rangesPastAnHourKeepTheHour() { + val ranges = listOf( + EpisodeMidrollEntity(episodeId = "e1", candidateId = "a", startMs = 3_723_000, endMs = 3_780_000) + ) + + assertEquals( + listOf("1:02:03 - 1:03:00"), + SkipSummary.episode(episodeRow(), showRow(), ranges).breaks, + ) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt new file mode 100644 index 0000000..e8bc0a3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/BedtimePolicyTest.kt @@ -0,0 +1,48 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * What entering and leaving bedtime mode does to the sleep timer. + * + * Four cases, and two of them are the reason this is a class rather than an `if` in the view + * model: stomping a timer the listener set by hand, and leaving one armed after they have gone + * back to ordinary listening. + */ +class BedtimePolicyTest { + + @Test + fun enteringArmsTheTimer() { + assertEquals( + BedtimeAction.ArmSleepTimer(BedtimePolicy.DEFAULT_MINUTES), + BedtimePolicy.entering(timerAlreadyArmed = false), + ) + } + + /** A timer set by hand was set deliberately; bedtime mode must not overwrite the duration. */ + @Test + fun enteringLeavesATimerAlreadyRunningAlone() { + assertEquals(BedtimeAction.Nothing, BedtimePolicy.entering(timerAlreadyArmed = true)) + } + + @Test + fun leavingCancelsTheTimerBedtimeArmed() { + assertEquals(BedtimeAction.CancelSleepTimer, BedtimePolicy.leaving(bedtimeArmedIt = true)) + } + + /** + * The inverse of the case above. Leaving bedtime mode must not cancel a timer it never + * set -- otherwise flicking the moon icon on and off silently kills a manual sleep timer. + */ + @Test + fun leavingLeavesAManualTimerRunning() { + assertEquals(BedtimeAction.Nothing, BedtimePolicy.leaving(bedtimeArmedIt = false)) + } + + /** Long rather than short, deliberately: a timer that stops early is the worse failure. */ + @Test + fun theDefaultOutlastsATypicalSleepStory() { + assertEquals(45L, BedtimePolicy.DEFAULT_MINUTES) + } +} |
