summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-31 08:39:44 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-31 08:39:44 +0000
commitcb3377b4856e3419df1cbc52044eda8bf68a006d (patch)
tree939f55cf247f40b21009aa1f692dd7fe4b6faaf3
parentcbcd04cfb3c11ae0a171f4fce1954214baeff074 (diff)
Shows get a priority, so the news is not stuck behind a back catalogue
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 tiers and no more. A number would invite fiddling and would not mean anything; these are the three distinctions that were described. The tier rides beside the subscription list rather than inside it, so a server that knows nothing about tiers reads the list exactly as before and a phone that knows nothing keeps pushing what it always did. Only the shows placed somewhere other than the default are sent -- "ordinary" for sixty shows is the same information as nothing, in sixty times the space. It orders both the poll and the recognition queue. Recognition now sorts on three things in the order they matter: a topical show first, then an episode whose answer checks itself against the length gap, then newest. **A tier only exists for a subscribed feed**, which a test caught: the list and the tiers arrive in one push, and setting the tiers after the list put back a placement for a feed that same push had just removed. A resubscription months later would have inherited it. Unrecognised tiers are ignored at both ends and read as the default, which is the one that starves nothing -- the same rule the label store follows for a verdict it does not know. Schema 20, migrated and checked on a device. The control says what it does not do as well as what it does: nothing about playback changes, and a show placed anywhere still plays, downloads and skips identically. A setting that looks like it changes playback and does not is worse than no setting. The coverage gate caught the push body having no test, which was fair -- it is hand-built JSON going over the wire, where a missing brace is a library that silently stops being mirrored. Its exact output is now pinned, including a URL with a quote in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/20.json1040
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt44
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt18
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt4
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt8
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt17
-rw-r--r--app/src/main/java/org/terst/listen/data/ShowTier.kt37
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt21
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt5
-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/ShowTierTest.kt49
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt53
-rw-r--r--server/cmd/listend/main.go28
-rw-r--r--server/internal/api/api.go6
-rw-r--r--server/internal/api/api_test.go47
-rw-r--r--server/internal/store/store.go24
-rw-r--r--server/internal/store/tier.go85
-rw-r--r--server/internal/store/tier_test.go104
21 files changed, 1648 insertions, 11 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 807fb0b..5b8f017 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.41
+94.44
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/20.json b/app/schemas/org.terst.listen.data.AppDatabase/20.json
new file mode 100644
index 0000000..21f03b4
--- /dev/null
+++ b/app/schemas/org.terst.listen.data.AppDatabase/20.json
@@ -0,0 +1,1040 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 20,
+ "identityHash": "c740925b625f25c496d13bd98f5c26c4",
+ "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, `tier` TEXT NOT NULL DEFAULT 'ordinary', 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": "tier",
+ "columnName": "tier",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'ordinary'"
+ }
+ ],
+ "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, 'c740925b625f25c496d13bd98f5c26c4')"
+ ]
+ }
+} \ 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 a261149..5ef4379 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -906,4 +906,48 @@ class MigrationTest {
assertEquals("the crown jewel must survive", 314159L, it.getLong(0))
}
}
+
+ /**
+ * A show gains a tier, and existing shows default to the one that starves nothing.
+ *
+ * It is a scheduling hint for the server and changes nothing on the phone, so an older
+ * show arriving without one must keep behaving exactly as it did.
+ */
+ @Test
+ fun migrate19To20GivesEveryShowATierThatStarvesNothing() {
+ helper.createDatabase(name, 19).apply {
+ execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1, 0)"""
+ )
+ execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 271828, 'NONE')"""
+ )
+ close()
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 20, true, AppDatabase.MIGRATION_19_20)
+
+ db.query("SELECT tier, title FROM subscriptions WHERE id = 's1'").use {
+ it.moveToFirst()
+ assertEquals("a show nobody placed must not be starved", "ordinary", it.getString(0))
+ assertEquals("A Show", it.getString(1))
+ }
+ db.execSQL("UPDATE subscriptions SET tier = 'topical' WHERE id = 's1'")
+ db.query("SELECT tier FROM subscriptions WHERE id = 's1'").use {
+ it.moveToFirst()
+ assertEquals("topical", it.getString(0))
+ }
+ db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use {
+ it.moveToFirst()
+ assertEquals("the crown jewel must survive", 271828L, it.getLong(0))
+ }
+ }
}
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 dc9c094..95a984b 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 = 19,
+ version = 20,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -499,6 +499,21 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * A show gains a tier: how soon the server should do its work.
+ *
+ * Existing shows default to "ordinary", which is the tier that starves nothing. The
+ * server treats an absent or unrecognised tier the same way, so a phone and a server
+ * that disagree about this column still agree about the show.
+ */
+ val MIGRATION_19_20 = object : Migration(19, 20) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `subscriptions` ADD COLUMN `tier` TEXT NOT NULL DEFAULT 'ordinary'"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -513,6 +528,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14,
MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17,
MIGRATION_17_18, MIGRATION_18_19,
+ MIGRATION_19_20,
)
.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 43f713f..12c605c 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -144,6 +144,10 @@ 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)
+
/** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */
@Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id")
suspend fun updateSkips(id: String, introMs: Long, outroMs: Long)
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index 1ef3bde..9101dd7 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -128,6 +128,14 @@ data class SubscriptionEntity(
* -- see [AppDatabase.MIGRATION_8_9].
*/
@ColumnInfo(defaultValue = "0") val bedtime: Boolean = false,
+ /**
+ * How soon the server should do this show's work -- see [ShowTier].
+ *
+ * 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",
)
/**
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 1dc5bad..2655546 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -164,7 +164,11 @@ class AppGraph(private val context: Context) {
SubscriptionCollaborators(
onDiscovered = { ruleApplier.apply(it) },
probe = { url -> ChaquopyAudioSource(context).probe(url) },
- onRefreshed = { feedUrls -> skipServer.pushSubscriptions(feedUrls) },
+ // The tiers 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())
+ },
refreshSkips = { skipRefresher.refreshAll() },
retryWaiting = { waitingDownloads.retryAll() },
),
@@ -194,6 +198,17 @@ class AppGraph(private val context: Context) {
)
}
+ /**
+ * Where each show has been placed, for the shows placed anywhere but the default.
+ *
+ * Only the exceptions: sending "ordinary" for sixty shows would be the same information as
+ * sending nothing, in sixty times the space.
+ */
+ private suspend fun placedTiers(): Map<String, String> =
+ subscriptions.getAll()
+ .filter { ShowTier.of(it.tier) != ShowTier.ORDINARY }
+ .associate { it.feedUrl to it.tier }
+
/** Shared between the download path and [skipRefresher], which write the server's answer
* into exactly the same columns. */
val serverSkips: ServerSkips by lazy {
diff --git a/app/src/main/java/org/terst/listen/data/ShowTier.kt b/app/src/main/java/org/terst/listen/data/ShowTier.kt
new file mode 100644
index 0000000..f0a2d97
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/ShowTier.kt
@@ -0,0 +1,37 @@
+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/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt
index 68a04ed..8f2141c 100644
--- a/app/src/main/java/org/terst/listen/server/SkipServer.kt
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -232,7 +232,15 @@ class SkipServer(
* import, the queue rules, Android Auto's browse tree -- keeps working untouched, and the
* server stays something the app prefers rather than depends on.
*/
- suspend fun pushSubscriptions(feedUrls: List<String>): Boolean {
+ 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.
+ */
+ tiers: Map<String, String> = emptyMap(),
+ ): Boolean {
val target = config() ?: return false
val body = buildString {
append("""{"feeds":[""")
@@ -240,7 +248,16 @@ class SkipServer(
if (index > 0) append(",")
append(JsonPrimitive(url))
}
- append("]}")
+ append("]")
+ if (tiers.isNotEmpty()) {
+ append(""","tiers":{""")
+ tiers.entries.forEachIndexed { index, (url, tier) ->
+ if (index > 0) append(",")
+ append(JsonPrimitive(url)).append(":").append(JsonPrimitive(tier))
+ }
+ append("}")
+ }
+ append("}")
}
return observe(target, transport.post(base(target) + "/api/subscriptions", target.token, body)) != null
}
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 b387a2c..693fee3 100644
--- a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt
@@ -18,6 +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.SubscriptionEntity
/**
@@ -63,6 +64,10 @@ fun ShowSettingsSheet(
HorizontalDivider()
+ TierSetting(ShowTier.of(show.tier)) { settings.setTier(show.id, it) }
+
+ HorizontalDivider()
+
DripSettings(
subscription = show,
busy = busy,
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 a84fa40..56d172f 100644
--- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.launch
import org.terst.listen.data.ArchiveOutcome
import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.Graph
+import org.terst.listen.data.ShowTier
import org.terst.listen.fingerprint.IntroOutroDetector
import org.terst.listen.fingerprint.MidrollDetector
import org.terst.listen.fingerprint.SkipSummary
@@ -115,6 +116,23 @@ class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) {
}
/** Moves a show in or out of the bedtime list. */
+ /**
+ * 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.
+ */
+ fun setTier(subscriptionId: String, tier: ShowTier) {
+ 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")
+ }
+ }
+ }
+
fun setBedtime(subscriptionId: String, enabled: Boolean) {
viewModelScope.launch {
graph.subscriptions.setBedtime(subscriptionId, enabled)
diff --git a/app/src/main/java/org/terst/listen/ui/TierSetting.kt b/app/src/main/java/org/terst/listen/ui/TierSetting.kt
new file mode 100644
index 0000000..6121db0
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/TierSetting.kt
@@ -0,0 +1,45 @@
+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 ec76368..f3b9a23 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -62,6 +62,10 @@ 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 updateSkips(id: String, introMs: Long, outroMs: Long) {
replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) }
}
diff --git a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt
new file mode 100644
index 0000000..45e1886
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt
@@ -0,0 +1,49 @@
+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/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
index 650c992..78c3987 100644
--- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
+++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
@@ -396,4 +396,57 @@ class SkipServerTest {
assertEquals(false, server(transport, token = null).isConfigured())
assertTrue("asking must not touch the network", transport.gets.isEmpty())
}
+
+ /*
+ * The body the subscription push actually sends.
+ *
+ * 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
+ * nothing about them reads the list exactly as it always did.
+ */
+
+ @Test
+ fun `sends the feed list as json`() = runTest {
+ val transport = FakeTransport()
+
+ server(transport).pushSubscriptions(listOf("https://a", "https://b"))
+
+ assertEquals("""{"feeds":["https://a","https://b"]}""", transport.posts.single().second)
+ }
+
+ @Test
+ fun `sends the tiers beside the list`() = runTest {
+ val transport = FakeTransport()
+
+ server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "topical"))
+
+ assertEquals(
+ """{"feeds":["https://a"],"tiers":{"https://a":"topical"}}""",
+ transport.posts.single().second,
+ )
+ }
+
+ /** No tiers placed means no tiers sent, not an empty object. */
+ @Test
+ fun `sends no tiers when none are placed`() = runTest {
+ val transport = FakeTransport()
+
+ server(transport).pushSubscriptions(listOf("https://a"), emptyMap())
+
+ assertEquals("""{"feeds":["https://a"]}""", transport.posts.single().second)
+ }
+
+ /** A feed URL carries query strings and ampersands; JSON quoting has to survive them. */
+ @Test
+ fun `quotes a url that would otherwise break the json`() = runTest {
+ val transport = FakeTransport()
+ val awkward = """https://x/f?a=1&b="two""""
+
+ server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "archive"))
+
+ val body = transport.posts.single().second
+ assertTrue("body is not valid json: $body", body.startsWith("""{"feeds":["""))
+ assertTrue("the quote was not escaped: $body", body.contains("""\"two\""""))
+ }
}
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index 377fedc..43921bd 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -40,6 +40,18 @@ import (
"github.com/terst/listen/server/internal/transcript"
)
+// 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
+ }
+}
+
// minRecurringCorpus is how many of a show's episodes must have words before what they share
// means anything. Three: the target and the two others a stretch has to turn up in.
const minRecurringCorpus = 3
@@ -291,7 +303,9 @@ func (w *worker) save() {
// pollAll refreshes every subscribed feed, then mirrors and detects what changed.
func (w *worker) pollAll(ctx context.Context) {
- feeds := w.state.Feeds()
+ // 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())
if len(feeds) == 0 {
log.Printf("no subscriptions yet; waiting for the phone to send its list")
return
@@ -960,8 +974,20 @@ func (w *worker) pendingRecognition() []*store.Episode {
}
}
}
+ // Three things decide the order, and they are in the order they matter. A topical show is
+ // worthless by Thursday. An episode whose answer checks itself against the length gap is
+ // worth more than one that cannot. And within that, newest first.
+ rank := map[string]int{}
+ for _, show := range w.state.Shows() {
+ for _, e := range show.Episodes {
+ rank[e.ID] = tierRankOf(w.state.Tier(show.FeedURL))
+ }
+ }
sort.SliceStable(pending, func(i, j int) bool {
left, right := pending[i], pending[j]
+ if rank[left.ID] != rank[right.ID] {
+ return rank[left.ID] < rank[right.ID]
+ }
if (left.MasterCues > 0) != (right.MasterCues > 0) {
return left.MasterCues > 0
}
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
index 2db0f91..4b7fb2d 100644
--- a/server/internal/api/api.go
+++ b/server/internal/api/api.go
@@ -169,12 +169,18 @@ 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
+ // 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"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"})
return
}
removed := s.state.SetFeeds(body.Feeds)
+ // After the list, so a tier for a feed that was just removed is dropped with it.
+ s.state.SetTiers(body.Tiers)
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 19e40db..94f7ef4 100644
--- a/server/internal/api/api_test.go
+++ b/server/internal/api/api_test.go
@@ -371,3 +371,50 @@ func TestEpisodeResponseCarriesHowManyEpisodesSharedEachBreak(t *testing.T) {
t.Errorf("partners %d, want 6", got.Breaks[0].Partners)
}
}
+
+/*
+Where the phone places a show, and what happens when it does not.
+
+Tiers arrive beside the list rather than folded into it, so a phone that knows nothing about
+them keeps working unchanged -- which every phone did until this week.
+*/
+
+func TestTakesTheTiersThePhoneSends(t *testing.T) {
+ s, st, _, _ := newServer(t)
+
+ post(t, s, "/api/subscriptions",
+ `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`)
+
+ if got := st.Tier("https://a"); got != store.TierTopical {
+ t.Errorf("got %q, want %q", got, store.TierTopical)
+ }
+ if got := st.Tier("https://b"); got != store.TierOrdinary {
+ t.Errorf("got %q, want the default", got)
+ }
+}
+
+func TestAListWithNoTiersStillWorks(t *testing.T) {
+ s, st, _, _ := newServer(t)
+
+ rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ if len(st.Feeds()) != 1 {
+ t.Errorf("feeds = %v, want the list taken", st.Feeds())
+ }
+}
+
+// A tier for a feed the same push removed goes with it, rather than lingering for a
+// resubscription months later to inherit.
+func TestATierForARemovedFeedIsDropped(t *testing.T) {
+ s, st, _, _ := newServer(t)
+ post(t, s, "/api/subscriptions", `{"feeds":["https://a"],"tiers":{"https://a":"archive"}}`)
+
+ post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`)
+
+ if got := st.Tier("https://a"); got != store.TierOrdinary {
+ 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 931cdd3..f69ff90 100644
--- a/server/internal/store/store.go
+++ b/server/internal/store/store.go
@@ -276,17 +276,22 @@ type State struct {
// labels are the listener's own statements about stretches of audio. Not held on the
// episodes: an episode leaves the mirror after eight and a label has to outlive it.
labels []Label
+ // tiers is where the phone placed each feed: how soon its work is worth doing. Only the
+ // feeds placed somewhere other than the default are held, so the map stays the size of
+ // the exceptions rather than the size of the library.
+ tiers map[string]string
}
type persisted struct {
- Feeds []string `json:"feeds"`
- Shows map[string]*Show `json:"shows"`
- Labels []Label `json:"labels,omitempty"`
+ Feeds []string `json:"feeds"`
+ Shows map[string]*Show `json:"shows"`
+ Labels []Label `json:"labels,omitempty"`
+ Tiers map[string]string `json:"tiers,omitempty"`
}
// New returns an empty state.
func New() *State {
- return &State{shows: map[string]*Show{}}
+ return &State{shows: map[string]*Show{}, tiers: map[string]string{}}
}
// Load reads state from disk. A missing file is a first run, not an error.
@@ -308,6 +313,9 @@ func Load(path string) (*State, error) {
if p.Shows != nil {
s.shows = p.Shows
}
+ if p.Tiers != nil {
+ s.tiers = p.Tiers
+ }
return s, nil
}
@@ -318,7 +326,8 @@ func Load(path string) (*State, error) {
// away hours of detection.
func (s *State) Save(path string) error {
s.mu.RLock()
- raw, err := json.MarshalIndent(persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels}, "", " ")
+ raw, err := json.MarshalIndent(
+ persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels, Tiers: s.tiers}, "", " ")
s.mu.RUnlock()
if err != nil {
return fmt.Errorf("save state: %w", err)
@@ -374,6 +383,11 @@ func (s *State) SetFeeds(feeds []string) (removed []string) {
removed = append(removed, url)
}
}
+ for url := range s.tiers {
+ if !wanted[url] {
+ s.forgetTier(url)
+ }
+ }
sort.Strings(removed)
s.feeds = clean
return removed
diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go
new file mode 100644
index 0000000..94d2634
--- /dev/null
+++ b/server/internal/store/tier.go
@@ -0,0 +1,85 @@
+package store
+
+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
+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.
+
+Three and no more. A number would invite fiddling and would not mean anything; these are the
+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"
+)
+
+// tierRank orders the work. Lower goes first.
+var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2}
+
+/*
+SetTiers 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
+inherited by a resubscription months later.
+
+A tier 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) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.tiers == nil {
+ s.tiers = map[string]string{}
+ }
+ subscribed := make(map[string]bool, len(s.feeds))
+ for _, url := range s.feeds {
+ subscribed[url] = true
+ }
+ for url, tier := range tiers {
+ _, known := tierRank[tier]
+ if !subscribed[url] || !known || tier == TierOrdinary {
+ delete(s.tiers, url)
+ continue
+ }
+ s.tiers[url] = tier
+ }
+}
+
+// Tier is where a feed sits, or [TierOrdinary].
+func (s *State) Tier(feedURL string) string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if tier, ok := s.tiers[feedURL]; ok {
+ return tier
+ }
+ return TierOrdinary
+}
+
+// ByTier orders feed URLs so the topical ones are worked first and the archive last.
+//
+// Stable, so an ordering within a tier -- 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 {
+ 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 out
+}
+
+// forgetTier 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) }
diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go
new file mode 100644
index 0000000..971ee51
--- /dev/null
+++ b/server/internal/store/tier_test.go
@@ -0,0 +1,104 @@
+package store
+
+import "testing"
+
+/*
+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
+up with, and a long tail that is not topical at all. 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.
+
+Three tiers and no more. A number would invite fiddling and would not mean anything; these are
+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})
+
+ if got := s.Tier("https://a"); got != TierTopical {
+ t.Errorf("got %q, want %q", got, TierTopical)
+ }
+}
+
+// A show nobody has placed is in the middle: worth keeping up with, not urgent.
+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)
+ }
+}
+
+/*
+A tier this server does not understand is ignored rather than stored.
+
+A newer phone inventing a fourth tier must not quietly stop a show being polled at all -- the
+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"})
+
+ if got := s.Tier("https://a"); got != TierOrdinary {
+ t.Errorf("got %q, want the ordinary tier", got)
+ }
+}
+
+// Unsubscribing takes the tier with it, or a feed resubscribed months later would arrive
+// carrying a placement nobody chose.
+func TestForgettingAFeedForgetsItsTier(t *testing.T) {
+ s := New()
+ s.SetFeeds([]string{"https://a"})
+ s.SetTiers(map[string]string{"https://a": TierTopical})
+ s.SetFeeds([]string{"https://b"})
+
+ if got := s.Tier("https://a"); got != TierOrdinary {
+ t.Errorf("got %q, want the tier forgotten with the feed", got)
+ }
+}
+
+/*
+Ordering runs topical first and archive last, and keeps the given order within a tier.
+
+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) {
+ 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,
+ })
+
+ got := s.ByTier([]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] {
+ t.Fatalf("got %v, want %v", got, want)
+ }
+ }
+}
+
+/*
+A tier for a feed that is not subscribed is not kept.
+
+The phone sends its list and its tiers in one push, so a tier naming a feed the same push
+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})
+
+ if got := s.Tier("https://a"); got != TierOrdinary {
+ t.Errorf("got %q, want nothing kept for an unsubscribed feed", got)
+ }
+}