diff options
Diffstat (limited to 'app')
25 files changed, 1511 insertions, 542 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2769b3..e06aa69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -324,7 +324,7 @@ val coverageExclusions = listOf( "**/ui/SkipSettingsKt*", "**/ui/SubscriptionRowKt*", "**/ui/SubscriptionScreenKt*", - "**/ui/TierSettingKt*", + "**/ui/CategorySettingKt*", "**/ui/TranscriptScreenKt*", // A screen's own parameter and state objects: data classes of callbacks and already-decided diff --git a/app/schemas/org.terst.listen.data.AppDatabase/21.json b/app/schemas/org.terst.listen.data.AppDatabase/21.json new file mode 100644 index 0000000..2e514fb --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/21.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "3d5e18421f875c9344915a9981b9f99f", + "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, `category` TEXT NOT NULL DEFAULT 'truestory', 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" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'truestory'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `actualDurationMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `audioFromServer` INTEGER NOT NULL DEFAULT 0, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "actualDurationMs", + "columnName": "actualDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioFromServer", + "columnName": "audioFromServer", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "midrollCheckedAtMs", + "columnName": "midrollCheckedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "show_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPath` TEXT NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPath", + "columnName": "referenceClipPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "id" + ] + }, + "indices": [ + { + "name": "index_show_midrolls_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, `origin` TEXT NOT NULL DEFAULT 'audio', PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "candidateId", + "columnName": "candidateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'audio'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "candidateId" + ] + }, + "indices": [ + { + "name": "index_episode_midrolls_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "break_verdicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`, `endMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "verdict", + "columnName": "verdict", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decidedAtMs", + "columnName": "decidedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs", + "endMs" + ] + }, + "indices": [ + { + "name": "index_break_verdicts_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "ad_reports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `reportedAtMs` INTEGER NOT NULL, `correctionWindowMs` INTEGER NOT NULL, `endCorrected` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportedAtMs", + "columnName": "reportedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "correctionWindowMs", + "columnName": "correctionWindowMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endCorrected", + "columnName": "endCorrected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_ad_reports_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ad_reports_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "episode_transcripts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `source` TEXT NOT NULL, `cues` INTEGER NOT NULL, `note` TEXT NOT NULL, `fetchedAtMs` 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": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cues", + "columnName": "cues", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAtMs", + "columnName": "fetchedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "transcript_cues", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `text` TEXT NOT NULL, `advertising` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "advertising", + "columnName": "advertising", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_transcript_cues_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_cues_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, '3d5e18421f875c9344915a9981b9f99f')" + ] + } +}
\ No newline at end of file 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 5ef4379..05102d1 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -4,6 +4,8 @@ import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -950,4 +952,58 @@ class MigrationTest { assertEquals("the crown jewel must survive", 271828L, it.getLong(0)) } } + + /** + * 20 to 21: `tier` becomes `category`. + * + * The rename is the easy half. What matters is that a real library survives it: a show the + * listener had placed keeps its row, its title and its drip settings, and only the one + * column's meaning changes. Old values deliberately do not carry over -- "ordinary" said + * nothing about what a show is -- so everything lands on the default and the known feeds + * are placed from the list baked into the migration. + */ + @Test + fun migrate20To21RenamesTierAndRefilesTheKnownShows() { + val name = "migration-20-21" + helper.createDatabase(name, 20).apply { + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime, tier) + VALUES ('s1', 'https://feeds.npr.org/510318/podcast.xml', 'PODCAST', + 'Up First from NPR', 1000, 0, 0, 1, 86400000, 2, 1, 1, 0, 'topical')""" + ) + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime, tier) + VALUES ('s2', 'https://example.com/unknown.xml', 'PODCAST', 'Someone Else', + 2000, 0, 0, 0, 86400000, 1, 1, 1, 0, 'archive')""" + ) + close() + } + + val db = helper.runMigrationsAndValidate(name, 21, true, AppDatabase.MIGRATION_20_21) + + // A feed in the baked-in list is placed by it. + db.query("SELECT category, title, dripCount FROM subscriptions WHERE id = 's1'").use { + it.moveToFirst() + assertEquals("news", it.getString(0)) + assertEquals("the rest of the row must survive a column rename", "Up First from NPR", it.getString(1)) + assertEquals(2, it.getInt(2)) + } + // One that is not keeps the default rather than its old tier. + db.query("SELECT category FROM subscriptions WHERE id = 's2'").use { + it.moveToFirst() + assertEquals("truestory", it.getString(0)) + } + // And the old column is gone, not merely ignored. + db.query("PRAGMA table_info(subscriptions)").use { info -> + val columns = buildList { + while (info.moveToNext()) add(info.getString(info.getColumnIndexOrThrow("name"))) + } + assertTrue("category missing: $columns", "category" in columns) + assertFalse("tier should be gone: $columns", "tier" in columns) + } + } } 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 95a984b..0ba2e82 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -34,7 +34,7 @@ class Converters { EpisodeTranscriptEntity::class, TranscriptCueEntity::class, ], - version = 20, + version = 21, exportSchema = true, ) @TypeConverters(Converters::class) @@ -514,6 +514,183 @@ abstract class AppDatabase : RoomDatabase() { } } + + /** + * The library as it stood on 2026-11-11, placed by hand. + * + * Data, not logic, and it lives in the migration because that is the one place it runs + * once and is then history. The previous attempt derived a show's placement from how + * often it publishes, and that cannot work: Creepy is daily horror fiction and Science + * Friday is daily science, and a cadence reads both as news. Nothing about a feed says + * what sort of listening it is, so somebody has to say, and saying it once here beats + * forty-two taps. + * + * Matched on feed URL because titles get rewritten by publishers. A show whose URL has + * moved since, or one subscribed later, keeps the default and is set in its own + * settings -- which is the ordinary way to do this and always was. + */ + /** Five of the feeds below share this, and spelling it out five times overran the line. */ + private const val OMNY = + "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/" + + private val PLACEMENTS = listOf( + "https://feeds.npr.org/510318/podcast.xml" to + "news", + "https://feeds.acast.com/public/shows/66510864c0852400122bc7be" to + "news", + "https://feeds.megaphone.fm/TBIEA2761282490" to + "news", + OMNY + + "654d6eb2-3c1a-4a7c-9933-aeac01321781/a4484dce-90df-4bdb-b4e8-aeac0132178a/podcast.rss" to + "news", + OMNY + + "b275c45e-40fc-4358-b7d7-aeac0132174e/15955f66-3fc4-4561-b974-aeac01321760/podcast.rss" to + "news", + "https://feeds.simplecast.com/kEKXbjuJ" to + "analysis", + "https://feeds.megaphone.fm/BVLLC2163264914" to + "analysis", + "https://rss.libsyn.com/shows/59621/destinations/228606.xml" to + "analysis", + "https://rss.libsyn.com/shows/561260/destinations/4849865.xml" to + "analysis", + "https://www.spreaker.com/show/6019906/episodes/feed" to + "analysis", + "https://feeds.npr.org/g-s1-84651/podcast.xml" to + "analysis", + "https://feeds.simplecast.com/h18ZIZD_" to + "curiosity", + "https://feeds.simplecast.com/EmVW7VGp" to + "curiosity", + "https://feeds.simplecast.com/fS_LWlfx" to + "curiosity", + "https://rss.libsyn.com/shows/51362/destinations/182214.xml" to + "curiosity", + "https://feeds.simplecast.com/6HKOhNgS" to + "curiosity", + "https://feeds.acast.com/public/shows/60aaa81306baac0012e485c8" to + "sailing", + "https://rss.libsyn.com/shows/283658/destinations/2179424.xml" to + "sailing", + "https://rss.buzzsprout.com/2270245.rss" to + "sailing", + "https://feeds.simplecast.com/xl36XBC2" to + "truestory", + OMNY + + "afbd76b8-eff2-442a-b938-b28e0126edad/d08826cd-f888-4cd3-b700-b28e0126edbb/podcast.rss" to + "truestory", + "https://feeds.megaphone.fm/replyall" to + "truestory", + "https://podcast.darknetdiaries.com" to + "truestory", + OMNY + + "86877fb0-9178-405d-92b1-b347010e3649/77ead75a-0897-48be-86c9-b347010e3c42/podcast.rss" to + "truestory", + "https://feeds.simplecast.com/6HzeyO6b" to + "truestory", + OMNY + + "56c1736b-308b-48e9-803a-b3cb0112d291/48090b89-a05d-49e6-9a6c-b3cb0112d6d4/podcast.rss" to + "truestory", + "https://rss.amperwave.net/v2/feed/audacynetwork/search-engine" to + "truestory", + "https://www.hawaiipublicradio.org/podcast/this-is-our-hawaii/rss.xml" to + "truestory", + "https://feed.podbean.com/ghostloreofhawaii/feed.xml" to + "truestory", + "https://feeds.redcircle.com/7d028506-d699-4a1f-b251-c212423a2185" to + "truestory", + "https://feeds.megaphone.fm/thetrip" to + "truestory", + "https://thisislow.com/podcast.rss" to + "fiction", + "https://feeds.simplecast.com/LDNgBXht" to + "fiction", + "https://rss.libsyn.com/shows/104269/destinations/557696.xml" to + "fiction", + "https://feeds.megaphone.fm/CNE2112124476" to + "fiction", + "https://audioboom.com/channels/5094626.rss" to + "fiction", + "https://feeds.simplecast.com/Iy2WKI7A" to + "fiction", + "https://feeds.acast.com/public/shows/6a21ce941ddbe06b3af8fefc" to + "fiction", + "https://legendsfromthepacific.libsyn.com/rss" to + "fiction", + "https://feeds.feedburner.com/dancarlin/history?format=xml" to + "deep", + "https://feeds.feedburner.com/dancarlin/commonsense?format=xml" to + "deep", + "https://rss.libsyn.com/shows/137081/destinations/850607.xml" to + "deep", + ) + + /** + * `tier` becomes `category`: three values standing for priority alone become seven that say + * what the show is, with the order carrying the priority. See docs/show-kinds.md. + * + * The column is renamed rather than added beside, because two fields meaning + * overlapping things is the thing this avoids. Old values do not map: "ordinary" was + * two thirds of a library and says nothing about what a show is, and "topical" covered + * both the daily news and a daily horror serial. Everything lands on the default and is + * placed afresh -- by the seeder for the obvious ones, by hand for the rest. + * + * SQLite has had ALTER TABLE RENAME COLUMN since 3.25 and Room's bundled version is far + * newer, so the whole table is not rebuilt. + */ + val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + // The table is rebuilt rather than altered. RENAME COLUMN keeps the old + // column's DEFAULT -- 'ordinary', which is not a category -- and the schema + // Room validates against would not match. DROP COLUMN would do it but arrived + // in SQLite 3.35, and minSdk 29 ships 3.28. + db.execSQL( + "CREATE TABLE IF NOT EXISTS `subscriptions_new` (`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, " + + "`category` TEXT NOT NULL DEFAULT 'truestory', PRIMARY KEY(`id`))" + ) + // Everything but the old tier, which does not carry over: see the KDoc. + db.execSQL( + "INSERT INTO `subscriptions_new` (`id`, `feedUrl`, `kind`, `title`, " + + "`description`, `author`, `imageUrl`, `link`, `addedAtMs`, `etag`, " + + "`lastModified`, `lastFetchedAtMs`, `lastError`, `playbackSpeed`, " + + "`skipIntroMs`, `skipOutroMs`, `dripEnabled`, `dripIntervalMs`, " + + "`dripCount`, `dripOldestFirst`, `dripLastReleaseAtMs`, " + + "`skipAdChapters`, `midrollPendingEpisodeId`, `bedtime`, `category`) " + + "SELECT `id`, `feedUrl`, `kind`, `title`, `description`, `author`, " + + "`imageUrl`, `link`, `addedAtMs`, `etag`, `lastModified`, " + + "`lastFetchedAtMs`, `lastError`, `playbackSpeed`, `skipIntroMs`, " + + "`skipOutroMs`, `dripEnabled`, `dripIntervalMs`, `dripCount`, " + + "`dripOldestFirst`, `dripLastReleaseAtMs`, `skipAdChapters`, " + + "`midrollPendingEpisodeId`, `bedtime`, '${ShowCategory.DEFAULT.wire}' " + + "FROM `subscriptions`" + ) + db.execSQL("DROP TABLE `subscriptions`") + db.execSQL("ALTER TABLE `subscriptions_new` RENAME TO `subscriptions`") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` " + + "ON `subscriptions` (`feedUrl`)" + ) + PLACEMENTS.forEach { (feedUrl, category) -> + db.execSQL( + "UPDATE `subscriptions` SET `category` = ? WHERE `feedUrl` = ?", + arrayOf(category, feedUrl), + ) + } + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -529,6 +706,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, + MIGRATION_20_21, ) .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 e03a866..2dda8de 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -144,9 +144,9 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id") suspend fun setBedtime(id: String, enabled: Boolean) - /** How soon the server should do this show's work. See [org.terst.listen.data.ShowTier]. */ - @Query("UPDATE subscriptions SET tier = :tier WHERE id = :id") - suspend fun setTier(id: String, tier: String) + /** What sort of listening this show is, which is also its processing priority. See [ShowCategory]. */ + @Query("UPDATE subscriptions SET category = :category WHERE id = :id") + suspend fun setCategory(id: String, category: String) /** 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") 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 9101dd7..8ca5985 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -129,13 +129,18 @@ data class SubscriptionEntity( */ @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false, /** - * How soon the server should do this show's work -- see [ShowTier]. + * What sort of listening this show is, which is also its processing priority. See [ShowCategory]. * * Stored as the wire value rather than the enum so a tier written by a later version reads * back as something rather than crashing the query. Defaults to "ordinary", which starves * nothing. */ - @ColumnInfo(defaultValue = "ordinary") val tier: String = "ordinary", + /** + * What sort of listening this show is, and so how soon the server works on it. + * + * Not [kind], which is where the audio comes from. See [ShowCategory]. + */ + @ColumnInfo(defaultValue = "truestory") val category: String = ShowCategory.DEFAULT.wire, ) /** diff --git a/app/src/main/java/org/terst/listen/data/Preferences.kt b/app/src/main/java/org/terst/listen/data/Preferences.kt index 7ccecf1..65df0f3 100644 --- a/app/src/main/java/org/terst/listen/data/Preferences.kt +++ b/app/src/main/java/org/terst/listen/data/Preferences.kt @@ -62,18 +62,6 @@ class Preferences(private val context: Context) : ViewPreferences { */ override val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true } - /** - * Whether the one-time tier seed has run; see [TierSeeder]. - * - * A flag rather than "place anything still at the default", because moving a show back to - * Keeping up by hand is a decision, and without this the next refresh would undo it. - */ - val tiersSeeded: Flow<Boolean> = context.store.data.map { it[TIERS_SEEDED] ?: false } - - suspend fun setTiersSeeded(value: Boolean) { - context.store.edit { it[TIERS_SEEDED] = value } - } - suspend fun setSkipShowFurniture(value: Boolean) { context.store.edit { it[SKIP_SHOW_FURNITURE] = value } } @@ -150,7 +138,6 @@ class Preferences(private val context: Context) : ViewPreferences { val SERVER_URL = stringPreferencesKey("server_url") val SERVER_TOKEN = stringPreferencesKey("server_token") val SKIP_SHOW_FURNITURE = booleanPreferencesKey("skip_show_furniture") - val TIERS_SEEDED = booleanPreferencesKey("tiers_seeded") val FAST_PLAY_BREAKS = booleanPreferencesKey("fast_play_breaks") val REJECTED_URL = stringPreferencesKey("rejected_server_url") val REJECTED_TOKEN = stringPreferencesKey("rejected_server_token") diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index f873079..e4a35f0 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -4,7 +4,6 @@ import android.content.Context import coil3.ImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first import okhttp3.OkHttpClient import org.terst.listen.server.OkHttpSkipTransport import org.terst.listen.download.WaitingDownloads @@ -15,6 +14,7 @@ import org.terst.listen.server.SkipServer import org.terst.listen.server.SkipServerConfig import org.terst.listen.server.StoredRefusals import org.terst.listen.download.ChaquopyAudioSource +import kotlinx.coroutines.flow.first import org.terst.listen.download.EpisodeDownloader import org.terst.listen.feed.ChapterFetcher import org.terst.listen.feed.FeedFetcher @@ -203,14 +203,13 @@ class AppGraph(private val context: Context) : UiGraph { SubscriptionCollaborators( onDiscovered = { ruleApplier.apply(it) }, probe = { url -> ChaquopyAudioSource(context).probe(url) }, - // The tiers go with the list: they are about the same shows and the server + // The categories go with the list: they are about the same shows and the server // drops a tier for a feed the same push removed. onRefreshed = { feedUrls -> - skipServer.pushSubscriptions(feedUrls, placedTiers()) + skipServer.pushSubscriptions(feedUrls, placedCategories()) }, refreshSkips = { skipRefresher.refreshAll() }, retryWaiting = { waitingDownloads.retryAll() }, - seedTiers = { tierSeeding.seedOnce() }, ), ) } @@ -244,19 +243,16 @@ class AppGraph(private val context: Context) : UiGraph { * Only the exceptions: sending "ordinary" for sixty shows would be the same information as * sending nothing, in sixty times the space. */ - /** Places a library nobody has placed; see [TierSeeder] for the rule and why it exists. */ - val tierSeeding: TierSeeding by lazy { - TierSeeding( - subscriptions, episodes, - hasSeeded = { preferences.tiersSeeded.first() }, - markSeeded = { preferences.setTiersSeeded(true) }, - ) - } - - private suspend fun placedTiers(): Map<String, String> = + /** + * Every show that has been placed somewhere other than the default, keyed by feed URL. + * + * The default is left out so the map stays the size of the exceptions rather than the + * library, which is the same bargain the server's own store makes. + */ + private suspend fun placedCategories(): Map<String, String> = subscriptions.getAll() - .filter { ShowTier.of(it.tier) != ShowTier.ORDINARY } - .associate { it.feedUrl to it.tier } + .filter { ShowCategory.of(it.category) != ShowCategory.DEFAULT } + .associate { it.feedUrl to it.category } /** * Shared between the download path and [skipRefresher], which write the server's answer into diff --git a/app/src/main/java/org/terst/listen/data/ShowCategory.kt b/app/src/main/java/org/terst/listen/data/ShowCategory.kt new file mode 100644 index 0000000..c216032 --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/ShowCategory.kt @@ -0,0 +1,58 @@ +package org.terst.listen.data + +/** + * What sort of listening a show is, and -- the same thing -- how soon the server should work + * on it. + * + * One ordered list doing two jobs. The number is the priority, so there is nothing to keep in + * sync between "what kind of show" and "how urgent". It replaces the earlier three-value tier, + * which was priority alone and could not say that a daily horror serial and the daily news want + * very different things. + * + * **The order is not "how good in the car".** It is how perishable a show is, which is what the + * server needs. Sports sits in [NEWS] because a game recap is worthless once the next game is + * played -- and it is also fine to listen to while driving, unlike the rest of that bucket. The + * car groups by this; it does not rank by it. See docs/show-kinds.md. + */ +enum class ShowCategory(val wire: String, val label: String) { + /** Worth hearing today and worthless by Thursday. Includes sports, which is exactly that. */ + NEWS("news", "News"), + + /** Still topical, often weekly rather than daily. */ + ANALYSIS("analysis", "Analysis"), + + /** Science, tech and the generally curious. Good car listening and not urgent. */ + CURIOSITY("curiosity", "Curiosity"), + + /** Boats. A hobby-sized group for a one-person library, and it earns its place. */ + SAILING("sailing", "Sailing"), + + /** + * Narrative journalism and documentary: things that happened, told as a story. + * + * Stored as "truestory" rather than "true", which SQLite reads as the boolean keyword. The + * column's default is this value, so `DEFAULT true` would have made every unplaced show + * default to the integer 1 instead of a category. + */ + TRUE_STORIES("truestory", "True stories"), + + /** Made up. */ + FICTION("fiction", "Fiction"), + + /** Long, and in no hurry. */ + DEEP("deep", "Deep dives"); + + companion object { + /** + * The default for a show nobody has placed. + * + * [TRUE_STORIES] rather than the first or last value: it is the middle of the order, so + * an unplaced show neither jumps the queue nor starves, and it is the largest group in a + * real library so it is the likeliest guess. + */ + val DEFAULT = TRUE_STORIES + + /** Reads a stored value, defaulting anything unrecognised to [DEFAULT]. */ + fun of(stored: String?): ShowCategory = entries.firstOrNull { it.wire == stored } ?: DEFAULT + } +} diff --git a/app/src/main/java/org/terst/listen/data/ShowTier.kt b/app/src/main/java/org/terst/listen/data/ShowTier.kt deleted file mode 100644 index f0a2d97..0000000 --- a/app/src/main/java/org/terst/listen/data/ShowTier.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.terst.listen.data - -/** - * How soon a show's server-side work is worth doing. - * - * Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he - * keeps up with, and a long tail that is not topical at all. It matters because recognising one - * episode costs the server about five minutes, so a five-hour back-catalogue episode arriving - * the same morning as the news takes most of a cycle before the news is looked at. - * - * Three and no more. A number would invite fiddling and would not mean anything; these are the - * three distinctions that were actually described. - */ -enum class ShowTier(val wire: String, val label: String) { - /** News: worth hearing today and worthless by Thursday. */ - TOPICAL("topical", "Topical"), - - /** - * A show kept up with. The default, because it is what most of a library is and because a - * show nobody has placed should not be starved. - */ - ORDINARY("ordinary", "Keeping up"), - - /** A back catalogue, a slow drip, anything heard whenever. Its work waits. */ - ARCHIVE("archive", "Whenever"); - - companion object { - /** - * Reads a stored value, defaulting anything unrecognised to [ORDINARY]. - * - * A row written by a later version, or a column that has not been set, must not leave a - * show with no tier at all -- the default is the one that starves nothing. - */ - fun of(stored: String?): ShowTier = - entries.firstOrNull { it.wire == stored } ?: ORDINARY - } -} diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt index 6ca6177..933c838 100644 --- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt +++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt @@ -79,15 +79,6 @@ data class SubscriptionCollaborators( * refresh route passes through, the library's refresh button included. */ val retryWaiting: suspend () -> Unit = {}, - /** - * Places shows that have never been placed; see [TierSeeder]. - * - * Before the server is told, so a seeded tier goes up on the same refresh that worked it out - * rather than a cycle later. A no-op once it has run, and on a fresh install -- where there - * are no episodes yet to judge a cadence from -- it simply finds nothing and tries again on - * the next refresh. - */ - val seedTiers: suspend () -> Unit = {}, ) class SubscriptionRepository( @@ -154,7 +145,6 @@ class SubscriptionRepository( } suspend fun refreshAll(): Map<String, RefreshOutcome> { - collaborators.seedTiers() val shows = subscriptions.getAll() // The one place every refresh passes through, which is why the server is told here // rather than in a worker: the refresh button in the library calls this directly and diff --git a/app/src/main/java/org/terst/listen/data/TierSeeder.kt b/app/src/main/java/org/terst/listen/data/TierSeeder.kt deleted file mode 100644 index b7f4847..0000000 --- a/app/src/main/java/org/terst/listen/data/TierSeeder.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.terst.listen.data - -import java.util.concurrent.TimeUnit - -/** - * Places shows into [ShowTier] from how often they actually publish. - * - * The tier feature shipped complete -- a control per show, pushed to the server on every refresh, - * the server ordering its work by it -- and empty, because every show has to be placed by hand - * and none ever was. So the server worked a 43-show library in feed order and the news waited - * behind whatever else arrived. - * - * This is a seed, not a policy. It runs once, places only shows still sitting at the default, and - * never touches one that has been placed; afterwards the control in the show's settings is the - * only thing that decides. A guess that is right most of the time beats a default nobody sets, - * and a wrong one costs a tap. - */ -object TierSeeder { - - /** What a show's feed says about it: when it published, and whether it is being dripped. */ - data class Cadence(val subscriptionId: String, val publishedAtMs: List<Long>, val dripEnabled: Boolean) - - /** - * Enough recent episodes to see a rhythm, and few enough that an old one does not count. - * - * A show that was daily three years ago and is monthly now is not news, and taking the whole - * feed would say it was. - */ - private const val RECENT = 12 - - /** Fewer than this and the gaps between them are not evidence of anything. */ - private const val ENOUGH = 4 - - /** - * Below this, the episodes did not arrive on a cadence at all -- they were published together. - * - * The case that makes rate alone useless. Serial, LOW and The Outer Reach each measure a - * median gap of about zero because a whole season went up at once, which on publication rate - * makes them look more topical than the news and means the exact opposite. - */ - private const val BULK_UPLOAD_DAYS = 0.5 - - /** Daily, give or take. Tangle, Up First and Science Friday sit between 0.9 and 1.0. */ - private const val TOPICAL_DAYS = 2.0 - - /** Fortnightly or rarer. Radiolab is 14 days; Hardcore History is 223. */ - private const val ARCHIVE_DAYS = 14.0 - - /** The placements to apply: only the shows this would move off the default. */ - fun seed(shows: List<Cadence>): Map<String, ShowTier> = - shows.associate { it.subscriptionId to place(it) } - .filterValues { it != ShowTier.ORDINARY } - - /** Where one show belongs, or [ShowTier.ORDINARY] when its feed says nothing useful. */ - fun place(show: Cadence): ShowTier { - // Already declared to be heard whenever, so there is nothing to work out. - if (show.dripEnabled) return ShowTier.ARCHIVE - - val recent = show.publishedAtMs.sorted().takeLast(RECENT) - if (recent.size < ENOUGH) return ShowTier.ORDINARY - - val gaps = recent.zipWithNext { a, b -> (b - a).toDouble() / TimeUnit.DAYS.toMillis(1) } - return when (val median = median(gaps)) { - in 0.0..<BULK_UPLOAD_DAYS -> ShowTier.ARCHIVE - in BULK_UPLOAD_DAYS..<TOPICAL_DAYS -> ShowTier.TOPICAL - else -> if (median >= ARCHIVE_DAYS) ShowTier.ARCHIVE else ShowTier.ORDINARY - } - } - - /** The median, not the mean: one re-run or one long hiatus must not move the answer. */ - private fun median(values: List<Double>): Double { - val sorted = values.sorted() - val middle = sorted.size / 2 - return if (sorted.size % 2 == 1) sorted[middle] else (sorted[middle - 1] + sorted[middle]) / 2 - } -} diff --git a/app/src/main/java/org/terst/listen/data/TierSeeding.kt b/app/src/main/java/org/terst/listen/data/TierSeeding.kt deleted file mode 100644 index d86b8d5..0000000 --- a/app/src/main/java/org/terst/listen/data/TierSeeding.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.terst.listen.data - -/** - * Runs [TierSeeder] once over a library that has never been placed. - * - * Kept apart from the seeder itself so the rule stays a pure function with its own tests, and - * this holds only the reading and writing -- the same split as `RetentionSweeper` and its worker. - */ -class TierSeeding( - private val subscriptions: SubscriptionDao, - private val episodes: EpisodeDao, - /** Whether the seed has already run, and how to record that it has. */ - private val hasSeeded: suspend () -> Boolean, - private val markSeeded: suspend () -> Unit, -) { - - /** - * Returns how many shows were placed. - * - * The flag is set only when something was actually placed. A fresh install has no episodes - * to judge a cadence from, and marking it done there would mean the library was never - * placed at all. - */ - suspend fun seedOnce(): Int { - if (hasSeeded()) return 0 - - val shows = subscriptions.getAll() - if (shows.isEmpty()) return 0 - - val placements = TierSeeder.seed( - shows.map { show -> - TierSeeder.Cadence( - subscriptionId = show.id, - publishedAtMs = episodes.getForSubscription(show.id).mapNotNull { it.publishedAtMs }, - dripEnabled = show.dripEnabled, - ) - }, - ) - if (placements.isEmpty()) return 0 - - // Only shows still at the default. One already placed by hand is a decision. - val byId = shows.associateBy { it.id } - var placed = 0 - for ((id, tier) in placements) { - if (ShowTier.of(byId[id]?.tier) != ShowTier.ORDINARY) continue - subscriptions.setTier(id, tier.wire) - placed++ - } - markSeeded() - return placed - } -} diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt index c07309d..6d3ef38 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -236,11 +236,12 @@ class SkipServer( suspend fun pushSubscriptions( feedUrls: List<String>, /** - * How soon each show's work is worth doing, keyed by feed URL, for the shows placed - * anywhere other than the default. Sent beside the list rather than folded into it, so - * a server that knows nothing about tiers reads the list exactly as before. + * What sort of listening each show is, keyed by feed URL, for the shows placed anywhere + * other than the default -- which is also the order the server works in. Sent beside the + * list rather than folded into it, so a server that knows nothing about categories reads + * the list exactly as before. */ - tiers: Map<String, String> = emptyMap(), + categories: Map<String, String> = emptyMap(), ): Boolean { val target = config() ?: return false val body = buildString { @@ -250,11 +251,11 @@ class SkipServer( append(JsonPrimitive(url)) } append("]") - if (tiers.isNotEmpty()) { - append(""","tiers":{""") - tiers.entries.forEachIndexed { index, (url, tier) -> + if (categories.isNotEmpty()) { + append(""","categories":{""") + categories.entries.forEachIndexed { index, (url, category) -> if (index > 0) append(",") - append(JsonPrimitive(url)).append(":").append(JsonPrimitive(tier)) + append(JsonPrimitive(url)).append(":").append(JsonPrimitive(category)) } append("}") } diff --git a/app/src/main/java/org/terst/listen/ui/CategorySetting.kt b/app/src/main/java/org/terst/listen/ui/CategorySetting.kt new file mode 100644 index 0000000..82d2053 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/CategorySetting.kt @@ -0,0 +1,49 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.terst.listen.data.ShowCategory + +/** + * What sort of listening a show is. + * + * Chips, wrapping, because there are seven now rather than three and they no longer fit a row. + * Still chips rather than a dropdown for the same reason as before: the point is seeing at a + * glance where a show sits. + * + * The category is also the server's work order -- the list is in priority sequence -- but the + * description does not say so, because that is not what the listener is choosing. A show placed + * anywhere still plays, downloads and skips identically; the only visible effect is which lists + * it appears in, and how soon the server gets to it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CategorySetting(current: ShowCategory, onChoose: (ShowCategory) -> Unit) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Category", style = MaterialTheme.typography.bodyLarge) + Text( + "What kind of listening this is. Groups it in the car, and decides what the server " + + "works on first. Nothing about playback changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (category in ShowCategory.entries) { + FilterChip( + selected = category == current, + onClick = { onChoose(category) }, + label = { Text(category.label) }, + ) + } + } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt index f7780a5..1e6fd99 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -18,7 +18,7 @@ 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.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity /** @@ -64,7 +64,7 @@ fun ShowSettingsSheet( HorizontalDivider() - TierSetting(ShowTier.of(show.tier)) { settings.setTier(show.id, it) } + CategorySetting(ShowCategory.of(show.category)) { settings.setCategory(show.id, it) } HorizontalDivider() 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 221f876..ab22010 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.terst.listen.data.ArchiveOutcome import org.terst.listen.data.Graph -import org.terst.listen.data.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.UiGraph import org.terst.listen.fingerprint.SkipSummary @@ -136,17 +136,15 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr /** * Where this show sits in the server's queue. * - * The banner says what it does and what it does not, because it changes nothing the - * listener can hear and a setting that looks like it should is worth being plain about. + * The banner confirms the move rather than explaining it: the category's name is on screen + * already, and seven near-identical sentences about server ordering would say less than one. */ - fun setTier(subscriptionId: String, tier: ShowTier) { + fun setCategory(subscriptionId: String, category: ShowCategory) { viewModelScope.launch { - graph.subscriptions.setTier(subscriptionId, tier.wire) - _banner.value = when (tier) { - ShowTier.TOPICAL -> Banner("The server will do this show first") - ShowTier.ORDINARY -> Banner("The server will do this show in its usual turn") - ShowTier.ARCHIVE -> Banner("The server will do this show when nothing else is waiting") - } + graph.subscriptions.setCategory(subscriptionId, category.wire) + // One line per category would be seven near-identical sentences. The category's own + // name is what changed and is already on screen, so the banner says the consequence. + _banner.value = Banner("Filed under ${category.label}") } } diff --git a/app/src/main/java/org/terst/listen/ui/TierSetting.kt b/app/src/main/java/org/terst/listen/ui/TierSetting.kt deleted file mode 100644 index 6121db0..0000000 --- a/app/src/main/java/org/terst/listen/ui/TierSetting.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.terst.listen.ui - -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.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.terst.listen.data.ShowTier - -/** - * How soon the server should do this show's work. - * - * Chips rather than a dropdown: there are three, they are short, and the whole point is to see - * at a glance which one a show is in. The same shape the speed control already uses. - * - * It is a server-side scheduling hint and nothing else -- no episode is treated differently on - * the phone, and a show placed anywhere still plays, downloads and skips identically. The - * description says so, because a control that looks like it changes playback and does not is - * worse than no control. - */ -@Composable -fun TierSetting(current: ShowTier, onChoose: (ShowTier) -> Unit) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Priority", style = MaterialTheme.typography.bodyLarge) - Text( - "Which shows the server works on first. Nothing about playback changes.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - for (tier in ShowTier.entries) { - FilterChip( - selected = tier == current, - onClick = { onChoose(tier) }, - label = { Text(tier.label) }, - ) - } - } - } -} 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 ffb440a..b0e3d0c 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -62,8 +62,8 @@ class FakeSubscriptionDao : SubscriptionDao { replace(id) { it.copy(bedtime = enabled) } } - override suspend fun setTier(id: String, tier: String) { - replace(id) { it.copy(tier = tier) } + override suspend fun setCategory(id: String, category: String) { + replace(id) { it.copy(category = category) } } override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) { diff --git a/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt b/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt new file mode 100644 index 0000000..a9f430c --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt @@ -0,0 +1,51 @@ +package org.terst.listen.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The kinds, and the one property the whole design rests on: the order is the priority. + */ +class ShowCategoryTest { + + @Test + fun `the declared order is the processing order`() { + assertEquals( + listOf("news", "analysis", "curiosity", "sailing", "truestory", "fiction", "deep"), + ShowCategory.entries.map { it.wire }, + ) + } + + @Test + fun `an unrecognised or missing value falls back to the default`() { + assertEquals(ShowCategory.DEFAULT, ShowCategory.of(null)) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("")) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("topical")) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("a kind from a later version")) + } + + /** + * The default sits in the middle, so a show nobody has placed neither jumps the queue nor + * starves behind everything. The old tier default did the same for the same reason. + */ + @Test + fun `the default is neither first nor last`() { + val at = ShowCategory.entries.indexOf(ShowCategory.DEFAULT) + assertTrue("default at $at of ${ShowCategory.entries.size}", at > 0 && at < ShowCategory.entries.size - 1) + } + + /** The stored value is what crosses the wire and the database; it must not drift from the name. */ + @Test + fun `every kind has a distinct wire value and a label`() { + assertEquals(ShowCategory.entries.size, ShowCategory.entries.map { it.wire }.toSet().size) + assertTrue(ShowCategory.entries.all { it.wire.isNotBlank() && it.label.isNotBlank() }) + } + + @Test + fun `the old tier values are gone, so a stored one reads as unplaced`() { + for (old in listOf("topical", "ordinary", "archive")) { + assertEquals(ShowCategory.DEFAULT, ShowCategory.of(old)) + } + } +} diff --git a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt deleted file mode 100644 index 45e1886..0000000 --- a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.terst.listen.data - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Where a show sits in the server's queue. - * - * The default is load-bearing: a show nobody has placed must not be starved, and a value this - * version does not recognise -- written by a later one, or never set at all -- has to read as - * something rather than nothing. - */ -class ShowTierTest { - - @Test - fun `a stored tier reads back`() { - assertEquals(ShowTier.TOPICAL, ShowTier.of("topical")) - assertEquals(ShowTier.ARCHIVE, ShowTier.of("archive")) - } - - @Test - fun `nothing stored means keeping up`() { - assertEquals(ShowTier.ORDINARY, ShowTier.of(null)) - assertEquals(ShowTier.ORDINARY, ShowTier.of("")) - } - - /** A value from a later version must leave the show working, not unplaced. */ - @Test - fun `an unrecognised tier means keeping up`() { - assertEquals(ShowTier.ORDINARY, ShowTier.of("urgent-ish")) - } - - /** The wire value is what the server stores, so it must not drift from the enum. */ - @Test - fun `every tier round trips through its wire value`() { - for (tier in ShowTier.entries) { - assertEquals(tier, ShowTier.of(tier.wire)) - } - } - - /** Each one is offered to the listener by name, so none may be blank. */ - @Test - fun `every tier has something to show`() { - for (tier in ShowTier.entries) { - assertEquals(tier.label, tier.label.trim()) - assert(tier.label.isNotEmpty()) { "$tier has no label" } - } - } -} diff --git a/app/src/test/java/org/terst/listen/data/TierSeederTest.kt b/app/src/test/java/org/terst/listen/data/TierSeederTest.kt deleted file mode 100644 index 3935851..0000000 --- a/app/src/test/java/org/terst/listen/data/TierSeederTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.terst.listen.data - -import org.junit.Assert.assertEquals -import org.junit.Test -import java.util.concurrent.TimeUnit - -/** - * Placing a library's shows from how they actually publish. - * - * Peter's framing, in his words: "prioritize server processing for things like up first and - * tangle, and let things like Dan Carlin or radiolab wait for idle periods". The tier feature - * shipped built, tested and wired end to end -- and with nothing in it, because every show has to - * be placed by hand and none had been. A seed derived from the feeds does the obvious ones. - * - * Checked against the real library before it was written. The rule here puts Tangle, Up First, - * Science Friday and Wards Way in [ShowTier.TOPICAL]; both Dan Carlin shows, Radiolab, Reply All - * and the bulk-published back catalogues in [ShowTier.ARCHIVE]; and the weekly middle -- 23 of - * the 42 shows -- untouched. - */ -class TierSeederTest { - - private fun daily(count: Int) = cadenceOf(count, days = 1.0) - - private fun cadenceOf(count: Int, days: Double): List<Long> { - val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() - return (0 until count).map { it * step } - } - - private fun place(dates: List<Long>, dripEnabled: Boolean = false) = - TierSeeder.place(TierSeeder.Cadence("s1", dates, dripEnabled)) - - @Test - fun `a daily show is topical`() { - assertEquals(ShowTier.TOPICAL, place(daily(12))) - } - - @Test - fun `a weekly show is left where it is`() { - assertEquals(ShowTier.ORDINARY, place(cadenceOf(12, days = 7.0))) - } - - /** Radiolab is fortnightly; Dan Carlin is twice a year. Both wait. */ - @Test - fun `an occasional show waits`() { - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 14.0))) - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(6, days = 200.0))) - } - - /** - * The case that broke the first rule, and the reason the floor exists. - * - * Serial, LOW and The Outer Reach all measure a median gap of about zero -- a whole season - * published in one go. On publication rate alone they look more topical than the news, and - * they are the exact opposite: a back catalogue, which is what archive means. - */ - @Test - fun `a back catalogue published all at once is an archive, not the most topical thing there is`() { - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 0.0))) - } - - /** Already said to be heard whenever, so there is nothing to work out. */ - @Test - fun `a slow-drip show waits whatever its feed does`() { - assertEquals(ShowTier.ARCHIVE, place(daily(12), dripEnabled = true)) - } - - /** - * Too little to go on is not evidence of anything, and the default starves nothing -- the - * same reasoning ShowTier.of uses for an unrecognised stored value. - */ - @Test - fun `a show with too few episodes to judge is left alone`() { - assertEquals(ShowTier.ORDINARY, place(daily(3))) - assertEquals(ShowTier.ORDINARY, place(emptyList())) - } - - /** Only the newest stretch counts: a show that used to be daily and is now monthly is not news. */ - @Test - fun `an old burst does not make a quiet show topical`() { - val longAgo = cadenceOf(12, days = 1.0) - val recent = (1..12).map { longAgo.last() + it * TimeUnit.DAYS.toMillis(30) } - - assertEquals(ShowTier.ARCHIVE, place(longAgo + recent)) - } - - @Test - fun `seeding places only the shows still sitting at the default`() { - val placed = TierSeeder.seed( - listOf( - TierSeeder.Cadence("daily", daily(12), dripEnabled = false), - TierSeeder.Cadence("weekly", cadenceOf(12, days = 7.0), dripEnabled = false), - ), - ) - - assertEquals(mapOf("daily" to ShowTier.TOPICAL), placed) - } -} diff --git a/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt b/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt deleted file mode 100644 index 7f64ee3..0000000 --- a/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.terst.listen.data - -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test -import java.util.concurrent.TimeUnit - -/** - * Applying [TierSeeder]'s answer to a library, once. - * - * The rule is tested next door; this is about what it is allowed to touch. A placement nobody - * made is worth guessing at, and one somebody made is not. - */ -class TierSeedingTest { - - private val subscriptions = FakeSubscriptionDao() - private val episodes = FakeEpisodeDao() - private var seeded = false - - private fun seeding() = TierSeeding( - subscriptions, episodes, - hasSeeded = { seeded }, - markSeeded = { seeded = true }, - ) - - private fun show(id: String, tier: String = "ordinary", dripEnabled: Boolean = false) { - subscriptions.rows += SubscriptionEntity( - id = id, feedUrl = "https://$id", title = id, addedAtMs = 0, - tier = tier, dripEnabled = dripEnabled, - ) - } - - private fun publishesEvery(id: String, days: Double, count: Int = 12) { - val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() - repeat(count) { i -> - episodes.rows += EpisodeEntity( - id = "$id-$i", subscriptionId = id, guid = "$id-$i", title = "e$i", - mediaUrl = "https://cdn/$i.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, - publishedAtMs = i * step, - ) - } - } - - @Test - fun `a daily show is placed`() = runTest { - show("daily") - publishesEvery("daily", days = 1.0) - - assertEquals(1, seeding().seedOnce()) - assertEquals(ShowTier.TOPICAL.wire, subscriptions.rows.single().tier) - } - - /** A placement made by hand is a decision, and a guess must not overwrite one. */ - @Test - fun `a show already placed by hand is left alone`() = runTest { - show("daily", tier = ShowTier.ARCHIVE.wire) - publishesEvery("daily", days = 1.0) - - assertEquals(0, seeding().seedOnce()) - assertEquals(ShowTier.ARCHIVE.wire, subscriptions.rows.single().tier) - } - - @Test - fun `it does not run twice`() = runTest { - show("daily") - publishesEvery("daily", days = 1.0) - seeding().seedOnce() - - subscriptions.setTier("daily", ShowTier.ORDINARY.wire) - - assertEquals("moving a show back by hand must survive the next refresh", 0, seeding().seedOnce()) - assertEquals(ShowTier.ORDINARY.wire, subscriptions.rows.single().tier) - } - - /** - * A fresh install has subscriptions before it has episodes, and placing nothing there must - * not count as having placed the library -- or it would never be placed at all. - */ - @Test - fun `a library with nothing to judge is left for next time`() = runTest { - show("new") - - assertEquals(0, seeding().seedOnce()) - assertEquals(false, seeded) - } - - @Test - fun `a weekly show is left at the default and does not spend the one run`() = runTest { - show("weekly") - publishesEvery("weekly", days = 7.0) - - assertEquals(0, seeding().seedOnce()) - assertEquals(false, seeded) - } -} diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt index 78c3987..5d9d979 100644 --- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt +++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt @@ -402,7 +402,7 @@ class SkipServerTest { * * Pinned because it is hand-built JSON going over the wire: a missing comma or brace is a * push the server rejects, and the only symptom is a library that silently stops being - * mirrored. The tiers ride beside the list rather than inside it, so a server that knows + * mirrored. The categories ride beside the list rather than inside it, so a server that knows * nothing about them reads the list exactly as it always did. */ @@ -416,20 +416,20 @@ class SkipServerTest { } @Test - fun `sends the tiers beside the list`() = runTest { + fun `sends the categories beside the list`() = runTest { val transport = FakeTransport() - server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "topical")) + server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "fiction")) assertEquals( - """{"feeds":["https://a"],"tiers":{"https://a":"topical"}}""", + """{"feeds":["https://a"],"categories":{"https://a":"fiction"}}""", transport.posts.single().second, ) } - /** No tiers placed means no tiers sent, not an empty object. */ + /** No category placed means none sent, not an empty object. */ @Test - fun `sends no tiers when none are placed`() = runTest { + fun `sends no categories when none are placed`() = runTest { val transport = FakeTransport() server(transport).pushSubscriptions(listOf("https://a"), emptyMap()) @@ -443,7 +443,7 @@ class SkipServerTest { val transport = FakeTransport() val awkward = """https://x/f?a=1&b="two"""" - server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "archive")) + server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "deep")) val body = transport.posts.single().second assertTrue("body is not valid json: $body", body.startsWith("""{"feeds":[""")) diff --git a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt index 44ca546..061d75e 100644 --- a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt +++ b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt @@ -14,12 +14,12 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.terst.listen.data.EpisodeMidrollEntity -import org.terst.listen.data.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity import java.util.concurrent.TimeUnit /** - * The per-show sheet: drip, the archive walk, the tier and the skip readout. + * The per-show sheet: drip, the archive walk, the category and the skip readout. * * Every function here ends in a banner, and the banners are the point -- "nothing to release * yet" and "N episode(s) queued" are the difference between a feature that looks broken and one @@ -262,24 +262,35 @@ class ShowSettingsViewModelTest { assertTrue(graph.subscriptions.rows.single().skipAdChapters) } + /** + * Setting a category files the show and says so. + * + * It replaced a three-value tier whose banner explained the server's ordering in a different + * sentence per value. Seven of those would say less than one: the name is the thing that + * changed and is already on screen. + */ @Test - fun `each tier says what the server will do, and nothing about playback`() { - show() + fun `setting a category files the show and confirms it`() { + show() - vm.setTier("s1", ShowTier.TOPICAL) - assertEquals(Banner("The server will do this show first"), vm.banner.value) - assertEquals(ShowTier.TOPICAL.wire, graph.subscriptions.rows.single().tier) + vm.setCategory("s1", ShowCategory.FICTION) + awaitBanner() - vm.setTier("s1", ShowTier.ORDINARY) - assertEquals(Banner("The server will do this show in its usual turn"), vm.banner.value) + assertEquals(ShowCategory.FICTION.wire, graph.subscriptions.rows.single().category) + assertEquals(Banner("Filed under Fiction"), vm.banner.value) + } - vm.setTier("s1", ShowTier.ARCHIVE) - assertEquals( - Banner("The server will do this show when nothing else is waiting"), - vm.banner.value, - ) - assertEquals(ShowTier.ARCHIVE.wire, graph.subscriptions.rows.single().tier) - } + @Test + fun `a show can be refiled`() { + show() + + vm.setCategory("s1", ShowCategory.NEWS) + awaitBanner() + vm.setCategory("s1", ShowCategory.DEEP) + awaitBanner() + + assertEquals(ShowCategory.DEEP.wire, graph.subscriptions.rows.single().category) + } @Test fun `bedtime says where the show has gone, both ways`() { |
