summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
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`() {