summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-11 08:54:52 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-11 08:54:52 +0000
commitaba4fb18ef8a542865e10a891081681352b64fcd (patch)
treebff91fa0c0426886d47da5d1a90311e554ee1928 /app
parent8db54bc24d7d5d0cdfce8a2e19199d7e9a42fbd4 (diff)
Shows have a category, not a tier
Tier said "how important" and the shows needed "what kind". Seven ordered categories replace it: news, analysis, curiosity, sailing, true stories, fiction, deep dives. The order is still the server's processing priority, so one field carries both axes -- which is the idiosyncrasy docs/show-kinds.md is written to watch, with four signs that a better abstraction has arrived. All 42 known feeds are refiled by migration 20 -> 21, which rebuilds the subscriptions table rather than renaming the column: RENAME COLUMN keeps the old 'ordinary' DEFAULT and fails schema validation, and DROP COLUMN needs SQLite 3.35 against minSdk 29's 3.28. The wire value for true stories is "truestory", not "true", because SQLite reads an unquoted true in a DEFAULT as the boolean and stores 1. Schema 21 is a new exported JSON; migrate20To21RenamesTierAndRefilesTheKnownShows covers it, and all 22 device migration tests pass. The coverage exclusion for the settings composable follows the file's rename; left pointing at TierSettingKt it read as a 0.6% drop. The remaining 0.08% is the deletion of the covered tier-seeding code, taken as an exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
-rw-r--r--app/build.gradle.kts2
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/21.json1040
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt56
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt180
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt6
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt9
-rw-r--r--app/src/main/java/org/terst/listen/data/Preferences.kt13
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt28
-rw-r--r--app/src/main/java/org/terst/listen/data/ShowCategory.kt58
-rw-r--r--app/src/main/java/org/terst/listen/data/ShowTier.kt37
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt10
-rw-r--r--app/src/main/java/org/terst/listen/data/TierSeeder.kt76
-rw-r--r--app/src/main/java/org/terst/listen/data/TierSeeding.kt52
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt17
-rw-r--r--app/src/main/java/org/terst/listen/ui/CategorySetting.kt49
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt18
-rw-r--r--app/src/main/java/org/terst/listen/ui/TierSetting.kt45
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt4
-rw-r--r--app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt51
-rw-r--r--app/src/test/java/org/terst/listen/data/ShowTierTest.kt49
-rw-r--r--app/src/test/java/org/terst/listen/data/TierSeederTest.kt97
-rw-r--r--app/src/test/java/org/terst/listen/data/TierSeedingTest.kt95
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt14
-rw-r--r--app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt43
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`() {