summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)
+ }
+}