summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-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
-rw-r--r--docs/show-kinds.md62
-rw-r--r--server/cmd/listend/main.go2
-rw-r--r--server/internal/api/api.go6
-rw-r--r--server/internal/api/api_test.go14
-rw-r--r--server/internal/store/store.go2
-rw-r--r--server/internal/store/tier.go86
-rw-r--r--server/internal/store/tier_test.go34
-rw-r--r--server/internal/words/pipeline.go18
-rw-r--r--server/internal/words/pipeline_test.go8
35 files changed, 1668 insertions, 619 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 4b2c573..8b8ff10 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.27
+94.19
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`() {
diff --git a/docs/show-kinds.md b/docs/show-kinds.md
new file mode 100644
index 0000000..bd08b4a
--- /dev/null
+++ b/docs/show-kinds.md
@@ -0,0 +1,62 @@
+# Show kinds
+
+One ordered list, doing two jobs: what sort of listening a show is, and how soon the server
+should work on it. The number *is* the priority.
+
+| # | kind | shows | notes |
+|---|---|---|---|
+| 1 | News | 5 | Up First, Tangle, 404 Media, Seahawks Rewind, Seahawks Insiders |
+| 2 | Political analysis | 6 | topical, often weekly |
+| 3 | Science & curiosity | 5 | Science Friday, Radiolab, Terrestrials, Rationally Speaking, Hard Fork |
+| 4 | Sailing | 3 | Liveaboard, Offshore Sailing, Wards Way |
+| 5 | True stories | 12 | narrative journalism and documentary |
+| 6 | Fiction | 8 | made up, including the daily horror one |
+| 7 | Deep dives | 3 | both Carlins, Conversations with Tyler |
+
+Bedtime stays the flag it already was. It is not a kind: a bedtime show is absent from the
+ordinary library entirely, which no kind does.
+
+## Why this replaces `tier` rather than joining it
+
+`tier` was topical / ordinary / archive — processing priority and nothing else. Loading "what
+sort of show is this" onto a second field would have meant two things to set per show, and the
+two would have disagreed within a week. This is the same field with a longer, more useful set of
+values, so there is nothing new to keep in sync.
+
+## The compromise, written down because it will look like a mistake later
+
+**The order is not purely "how good in the car".** It is mostly how perishable a show is, which
+is what the server needs. Sports is the clearest case: a game recap is worthless once the next
+game is played, so it belongs at 1 with the news — and it is also perfectly good car listening,
+unlike the rest of that bucket. Daily news and political analysis sit at the top for processing
+and are the *last* things wanted while driving.
+
+So the car does not read this list as a ranking. It groups by it and offers what it offers.
+Anyone who later assumes "lower number = show it first in the car" will be wrong, and this
+paragraph is why.
+
+## Sailing is three shows, and that is fine
+
+Peter's call, and worth defending: these are hobby-sized groups for a 45-show library belonging
+to one person. Idiosyncratic beats principled here — the categories that match how he actually
+chooses will beat a taxonomy that would survive someone else's library. They can be merged if
+they stop earning their place.
+
+## What to watch for, because this is a list of names and lists of names rot
+
+A better abstraction may exist, and this one was chosen knowing that. Signs it has arrived:
+
+- **A show needs two kinds.** Terrestrials is children's science storytelling; it is in Science &
+ curiosity and could as fairly be in Fiction. One more like it and the single-value assumption
+ is the problem, not the categories.
+- **A kind grows past about a dozen.** True stories is at 12 and was 19 before Fiction split off.
+ Past that a kind stops narrowing anything and becomes a second library.
+- **The priority order and the grouping order diverge further.** Sports already breaks it. If a
+ second kind needs to sit somewhere for processing that makes no sense for browsing, the two
+ jobs have genuinely come apart and the field should split — at which point the thing resisted
+ here becomes the right answer.
+- **Length starts doing the work instead.** "Something short" cuts across every kind and is
+ already a car list of its own. If kinds are rarely used and length always is, that is the
+ signal.
+
+Added 2026-11-11, from a classification of all 45 subscribed shows.
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index a165feb..79290c1 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -295,7 +295,7 @@ func (w *worker) save() {
func (w *worker) pollAll(ctx context.Context) {
// Topical shows first and archive last, so a five-hour back-catalogue episode cannot take
// a cycle's recognition budget before the morning news has been looked at.
- feeds := w.state.ByTier(w.state.Feeds())
+ feeds := w.state.ByCategory(w.state.Feeds())
if len(feeds) == 0 {
log.Printf("no subscriptions yet; waiting for the phone to send its list")
return
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
index 2d2e279..5a11c92 100644
--- a/server/internal/api/api.go
+++ b/server/internal/api/api.go
@@ -181,10 +181,10 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) {
}
var body struct {
Feeds []string `json:"feeds"`
- // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and
+ // Categories says what sort of listening each feed is, keyed by URL, which is also
// separate from the list rather than folded into it, so a phone that knows nothing
// about tiers keeps working unchanged.
- Tiers map[string]string `json:"tiers,omitempty"`
+ Categories map[string]string `json:"categories,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"})
@@ -200,7 +200,7 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) {
return
}
// After the list, so a tier for a feed that was just removed is dropped with it.
- s.state.SetTiers(body.Tiers)
+ s.state.SetCategories(body.Categories)
if s.opts.OnSubscriptions != nil {
s.opts.OnSubscriptions(s.state.Feeds())
}
diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go
index b50454b..5ae4435 100644
--- a/server/internal/api/api_test.go
+++ b/server/internal/api/api_test.go
@@ -379,21 +379,21 @@ Tiers arrive beside the list rather than folded into it, so a phone that knows n
them keeps working unchanged -- which every phone did until this week.
*/
-func TestTakesTheTiersThePhoneSends(t *testing.T) {
+func TestTakesTheCategoriesThePhoneSends(t *testing.T) {
s, st, _, _ := newServer(t)
post(t, s, "/api/subscriptions",
- `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`)
+ `{"feeds":["https://a","https://b"],"categories":{"https://a":"news"}}`)
- if got := st.Tier("https://a"); got != store.TierTopical {
- t.Errorf("got %q, want %q", got, store.TierTopical)
+ if got := st.Category("https://a"); got != store.CategoryNews {
+ t.Errorf("got %q, want %q", got, store.CategoryNews)
}
- if got := st.Tier("https://b"); got != store.TierOrdinary {
+ if got := st.Category("https://b"); got != store.CategoryDefault {
t.Errorf("got %q, want the default", got)
}
}
-func TestAListWithNoTiersStillWorks(t *testing.T) {
+func TestAListWithNoCategoriesStillWorks(t *testing.T) {
s, st, _, _ := newServer(t)
rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`)
@@ -414,7 +414,7 @@ func TestATierForARemovedFeedIsDropped(t *testing.T) {
post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`)
- if got := st.Tier("https://a"); got != store.TierOrdinary {
+ if got := st.Category("https://a"); got != store.CategoryDefault {
t.Errorf("got %q, want it dropped with the feed", got)
}
}
diff --git a/server/internal/store/store.go b/server/internal/store/store.go
index de763ed..caf5365 100644
--- a/server/internal/store/store.go
+++ b/server/internal/store/store.go
@@ -469,7 +469,7 @@ func (s *State) SetFeeds(feeds []string) (removed []string) {
}
for url := range s.tiers {
if !wanted[url] {
- s.forgetTier(url)
+ s.forgetCategory(url)
}
}
sort.Strings(removed)
diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go
index 94d2634..eb46aca 100644
--- a/server/internal/store/tier.go
+++ b/server/internal/store/tier.go
@@ -5,7 +5,7 @@ import "sort"
/*
How soon a show's work is worth doing.
-Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps
+Peter's framing: a few shows are news and stop being worth hearing within a day, a category he keeps
up with, and a long tail that is not topical at all. It matters because recognition costs about
five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up
First takes most of a cycle's budget before the news is touched.
@@ -14,31 +14,55 @@ Three and no more. A number would invite fiddling and would not mean anything; t
three distinctions he actually described.
*/
const (
- // TierTopical is news: worth hearing today and worthless by Thursday.
- TierTopical = "topical"
- // TierOrdinary is 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.
- TierOrdinary = "ordinary"
- // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits
- // for the time nothing else wants.
- TierArchive = "archive"
+ // The categories, in the order the work is done. The phone sends these; see
+ // docs/show-kinds.md in the app for what each means and why the order is what it is.
+ //
+ // They replaced three values that were priority alone -- topical, ordinary, archive --
+ // which could not tell the daily news from a daily horror serial. A category says what a
+ // show *is*, and the order of the list is the priority, so there is one thing to set
+ // rather than two that can disagree.
+ CategoryNews = "news"
+ CategoryAnalysis = "analysis"
+ CategoryCuriosity = "curiosity"
+ CategorySailing = "sailing"
+ CategoryTrue = "truestory"
+ CategoryFiction = "fiction"
+ CategoryDeep = "deep"
)
-// tierRank orders the work. Lower goes first.
-var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2}
+// categoryRank orders the work. Lower goes first, and the order is the declaration order above.
+//
+// A category this server does not know -- sent by a newer phone -- ranks with the default
+// rather than first or last, so an unrecognised value neither jumps the queue nor starves.
+var categoryRank = map[string]int{
+ CategoryNews: 0, CategoryAnalysis: 1, CategoryCuriosity: 2, CategorySailing: 3,
+ CategoryTrue: 4, CategoryFiction: 5, CategoryDeep: 6,
+}
+
+// CategoryDefault is where an unplaced or unrecognised show sits: the middle of the order.
+const CategoryDefault = CategoryTrue
+
+// CategoryRank is where a category sits in the work order, for callers outside this package.
+// An unknown one ranks with the default rather than first or last.
+func CategoryRank(category string) int {
+ if rank, ok := categoryRank[category]; ok {
+ return rank
+ }
+ return categoryRank[CategoryDefault]
+}
/*
-SetTiers records where the phone placed each feed, replacing what was there.
+SetCategories records where the phone placed each feed, replacing what was there.
-Only for feeds that are actually subscribed. The phone sends its list and its tiers in one
-push, and a tier naming a feed the same push dropped would otherwise outlive it and be
+Only for feeds that are actually subscribed. The phone sends its list and its categories in one
+push, and a category naming a feed the same push dropped would otherwise outlive it and be
inherited by a resubscription months later.
-A tier this server does not understand is dropped rather than stored: a newer phone inventing a
+A category this server does not understand is dropped rather than stored: a newer phone inventing a
fourth must not quietly stop a show being polled, which is the rule the label store already
follows for a verdict it does not recognise.
*/
-func (s *State) SetTiers(tiers map[string]string) {
+func (s *State) SetCategories(categories map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.tiers == nil {
@@ -48,38 +72,38 @@ func (s *State) SetTiers(tiers map[string]string) {
for _, url := range s.feeds {
subscribed[url] = true
}
- for url, tier := range tiers {
- _, known := tierRank[tier]
- if !subscribed[url] || !known || tier == TierOrdinary {
+ for url, category := range categories {
+ _, known := categoryRank[category]
+ if !subscribed[url] || !known || category == CategoryDefault {
delete(s.tiers, url)
continue
}
- s.tiers[url] = tier
+ s.tiers[url] = category
}
}
-// Tier is where a feed sits, or [TierOrdinary].
-func (s *State) Tier(feedURL string) string {
+// Category is where a feed sits, or [CategoryDefault].
+func (s *State) Category(feedURL string) string {
s.mu.RLock()
defer s.mu.RUnlock()
- if tier, ok := s.tiers[feedURL]; ok {
- return tier
+ if category, ok := s.tiers[feedURL]; ok {
+ return category
}
- return TierOrdinary
+ return CategoryDefault
}
-// ByTier orders feed URLs so the topical ones are worked first and the archive last.
+// ByCategory orders feed URLs so the news is worked first and the deep dives last.
//
-// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A
+// Stable, so an ordering within a category -- newest first, usually -- survives being grouped. A
// sort that reshuffled equals would make the queue unpredictable between cycles for no gain.
-func (s *State) ByTier(feedURLs []string) []string {
+func (s *State) ByCategory(feedURLs []string) []string {
out := append([]string(nil), feedURLs...)
sort.SliceStable(out, func(i, j int) bool {
- return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])]
+ return categoryRank[s.Category(out[i])] < categoryRank[s.Category(out[j])]
})
return out
}
-// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months
+// forgetCategory drops a feed's placement. Called when the feed goes, or a feed resubscribed months
// later would arrive carrying a placement nobody chose.
-func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) }
+func (s *State) forgetCategory(feedURL string) { delete(s.tiers, feedURL) }
diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go
index 971ee51..025f323 100644
--- a/server/internal/store/tier_test.go
+++ b/server/internal/store/tier_test.go
@@ -17,10 +17,10 @@ the three distinctions he actually described.
func TestATierIsRememberedForAFeed(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a", "https://b"})
- s.SetTiers(map[string]string{"https://a": TierTopical})
+ s.SetCategories(map[string]string{"https://a": CategoryNews})
- if got := s.Tier("https://a"); got != TierTopical {
- t.Errorf("got %q, want %q", got, TierTopical)
+ if got := s.Category("https://a"); got != CategoryNews {
+ t.Errorf("got %q, want %q", got, CategoryNews)
}
}
@@ -29,8 +29,8 @@ func TestAShowWithNoTierIsOrdinary(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- if got := s.Tier("https://a"); got != TierOrdinary {
- t.Errorf("got %q, want %q", got, TierOrdinary)
+ if got := s.Category("https://a"); got != CategoryDefault {
+ t.Errorf("got %q, want %q", got, CategoryDefault)
}
}
@@ -43,9 +43,9 @@ same rule the label store already follows for a verdict it does not recognise.
func TestAnUnknownTierIsIgnored(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- s.SetTiers(map[string]string{"https://a": "urgent-ish"})
+ s.SetCategories(map[string]string{"https://a": "urgent-ish"})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want the ordinary tier", got)
}
}
@@ -55,10 +55,10 @@ func TestAnUnknownTierIsIgnored(t *testing.T) {
func TestForgettingAFeedForgetsItsTier(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://a"})
- s.SetTiers(map[string]string{"https://a": TierTopical})
+ s.SetCategories(map[string]string{"https://a": CategoryNews})
s.SetFeeds([]string{"https://b"})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want the tier forgotten with the feed", got)
}
}
@@ -69,16 +69,16 @@ Ordering runs topical first and archive last, and keeps the given order within a
Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would
make the queue unpredictable from one cycle to the next for no gain.
*/
-func TestWorkIsOrderedByTier(t *testing.T) {
+func TestWorkIsOrderedByCategory(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"})
- s.SetTiers(map[string]string{
- "https://news": TierTopical,
- "https://news2": TierTopical,
- "https://archive": TierArchive,
+ s.SetCategories(map[string]string{
+ "https://news": CategoryNews,
+ "https://news2": CategoryNews,
+ "https://archive": CategoryDeep,
})
- got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"})
+ got := s.ByCategory([]string{"https://usual", "https://archive", "https://news", "https://news2"})
want := []string{"https://news", "https://news2", "https://usual", "https://archive"}
for i := range want {
if got[i] != want[i] {
@@ -96,9 +96,9 @@ dropped would outlive it and be inherited by a resubscription months later.
func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) {
s := New()
s.SetFeeds([]string{"https://b"})
- s.SetTiers(map[string]string{"https://a": TierArchive})
+ s.SetCategories(map[string]string{"https://a": CategoryDeep})
- if got := s.Tier("https://a"); got != TierOrdinary {
+ if got := s.Category("https://a"); got != CategoryDefault {
t.Errorf("got %q, want nothing kept for an unsubscribed feed", got)
}
}
diff --git a/server/internal/words/pipeline.go b/server/internal/words/pipeline.go
index 8cd18ed..d8de8d8 100644
--- a/server/internal/words/pipeline.go
+++ b/server/internal/words/pipeline.go
@@ -102,17 +102,11 @@ func (p *Pipeline) Forget(e *store.Episode) {
e.WordBreaks, e.WordAdsRefused = nil, ""
}
-// tierRankOf orders the tiers for work: topical first, archive last.
-func tierRankOf(tier string) int {
- switch tier {
- case store.TierTopical:
- return 0
- case store.TierArchive:
- return 2
- default:
- return 1
- }
-}
+// categoryRankOf orders the categories for work, news first and deep dives last.
+//
+// Delegates rather than keeping its own table: the order is the category list's declaration
+// order, and two copies of it would drift the first time one is added.
+func categoryRankOf(category string) int { return store.CategoryRank(category) }
// clock renders a duration the way every readout in this project does.
func clock(ms int64) string {
@@ -725,7 +719,7 @@ func (p *Pipeline) pendingRecognition() []*store.Episode {
rank := map[string]int{}
for _, show := range p.State.Shows() {
for _, e := range show.Episodes {
- rank[e.ID] = tierRankOf(p.State.Tier(show.FeedURL))
+ rank[e.ID] = categoryRankOf(p.State.Category(show.FeedURL))
}
}
sort.SliceStable(pending, func(i, j int) bool {
diff --git a/server/internal/words/pipeline_test.go b/server/internal/words/pipeline_test.go
index 9982a10..cf23d8f 100644
--- a/server/internal/words/pipeline_test.go
+++ b/server/internal/words/pipeline_test.go
@@ -76,13 +76,13 @@ A five-hour back-catalogue episode arriving the same morning as the news would o
most of a cycle's recognition budget before the news was looked at.
*/
func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) {
- if tierRankOf(store.TierTopical) >= tierRankOf(store.TierOrdinary) {
+ if categoryRankOf(store.CategoryNews) >= categoryRankOf(store.CategoryDefault) {
t.Error("a topical show does not come first")
}
- if tierRankOf(store.TierArchive) <= tierRankOf(store.TierOrdinary) {
+ if categoryRankOf(store.CategoryDeep) <= categoryRankOf(store.CategoryDefault) {
t.Error("an archive show does not come last")
}
- if tierRankOf("") != tierRankOf(store.TierOrdinary) {
+ if categoryRankOf("") != categoryRankOf(store.CategoryDefault) {
t.Error("a show nobody placed is not treated as ordinary, so it could be starved")
}
}
@@ -90,7 +90,7 @@ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) {
func TestRecognitionIsOrderedByTierThenByWhatChecksItself(t *testing.T) {
state := store.New()
state.SetFeeds([]string{"https://news", "https://usual"})
- state.SetTiers(map[string]string{"https://news": store.TierTopical})
+ state.SetCategories(map[string]string{"https://news": store.CategoryNews})
state.Merge("https://usual", "Usual", nil)
state.Merge("https://news", "News", nil)