diff options
35 files changed, 1668 insertions, 619 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 4b2c573..8b8ff10 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -94.27 +94.19 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2769b3..e06aa69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -324,7 +324,7 @@ val coverageExclusions = listOf( "**/ui/SkipSettingsKt*", "**/ui/SubscriptionRowKt*", "**/ui/SubscriptionScreenKt*", - "**/ui/TierSettingKt*", + "**/ui/CategorySettingKt*", "**/ui/TranscriptScreenKt*", // A screen's own parameter and state objects: data classes of callbacks and already-decided diff --git a/app/schemas/org.terst.listen.data.AppDatabase/21.json b/app/schemas/org.terst.listen.data.AppDatabase/21.json new file mode 100644 index 0000000..2e514fb --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/21.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "3d5e18421f875c9344915a9981b9f99f", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, `midrollPendingEpisodeId` TEXT, `bedtime` INTEGER NOT NULL DEFAULT 0, `category` TEXT NOT NULL DEFAULT 'truestory', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feedUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAtMs", + "columnName": "addedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFetchedAtMs", + "columnName": "lastFetchedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "playbackSpeed", + "columnName": "playbackSpeed", + "affinity": "REAL" + }, + { + "fieldPath": "skipIntroMs", + "columnName": "skipIntroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipOutroMs", + "columnName": "skipOutroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dripEnabled", + "columnName": "dripEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripIntervalMs", + "columnName": "dripIntervalMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "86400000" + }, + { + "fieldPath": "dripCount", + "columnName": "dripCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripOldestFirst", + "columnName": "dripOldestFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripLastReleaseAtMs", + "columnName": "dripLastReleaseAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "skipAdChapters", + "columnName": "skipAdChapters", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "midrollPendingEpisodeId", + "columnName": "midrollPendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "bedtime", + "columnName": "bedtime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'truestory'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `actualDurationMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `audioFromServer` INTEGER NOT NULL DEFAULT 0, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "actualDurationMs", + "columnName": "actualDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioFromServer", + "columnName": "audioFromServer", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "midrollCheckedAtMs", + "columnName": "midrollCheckedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "show_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPath` TEXT NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPath", + "columnName": "referenceClipPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "id" + ] + }, + "indices": [ + { + "name": "index_show_midrolls_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, `origin` TEXT NOT NULL DEFAULT 'audio', PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "candidateId", + "columnName": "candidateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'audio'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "candidateId" + ] + }, + "indices": [ + { + "name": "index_episode_midrolls_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "break_verdicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`, `endMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "verdict", + "columnName": "verdict", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decidedAtMs", + "columnName": "decidedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs", + "endMs" + ] + }, + "indices": [ + { + "name": "index_break_verdicts_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "ad_reports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `reportedAtMs` INTEGER NOT NULL, `correctionWindowMs` INTEGER NOT NULL, `endCorrected` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportedAtMs", + "columnName": "reportedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "correctionWindowMs", + "columnName": "correctionWindowMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endCorrected", + "columnName": "endCorrected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_ad_reports_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ad_reports_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "episode_transcripts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `source` TEXT NOT NULL, `cues` INTEGER NOT NULL, `note` TEXT NOT NULL, `fetchedAtMs` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cues", + "columnName": "cues", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAtMs", + "columnName": "fetchedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "transcript_cues", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `text` TEXT NOT NULL, `advertising` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "advertising", + "columnName": "advertising", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_transcript_cues_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_cues_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d5e18421f875c9344915a9981b9f99f')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt index 5ef4379..05102d1 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -4,6 +4,8 @@ import androidx.room.testing.MigrationTestHelper import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -950,4 +952,58 @@ class MigrationTest { assertEquals("the crown jewel must survive", 271828L, it.getLong(0)) } } + + /** + * 20 to 21: `tier` becomes `category`. + * + * The rename is the easy half. What matters is that a real library survives it: a show the + * listener had placed keeps its row, its title and its drip settings, and only the one + * column's meaning changes. Old values deliberately do not carry over -- "ordinary" said + * nothing about what a show is -- so everything lands on the default and the known feeds + * are placed from the list baked into the migration. + */ + @Test + fun migrate20To21RenamesTierAndRefilesTheKnownShows() { + val name = "migration-20-21" + helper.createDatabase(name, 20).apply { + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime, tier) + VALUES ('s1', 'https://feeds.npr.org/510318/podcast.xml', 'PODCAST', + 'Up First from NPR', 1000, 0, 0, 1, 86400000, 2, 1, 1, 0, 'topical')""" + ) + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime, tier) + VALUES ('s2', 'https://example.com/unknown.xml', 'PODCAST', 'Someone Else', + 2000, 0, 0, 0, 86400000, 1, 1, 1, 0, 'archive')""" + ) + close() + } + + val db = helper.runMigrationsAndValidate(name, 21, true, AppDatabase.MIGRATION_20_21) + + // A feed in the baked-in list is placed by it. + db.query("SELECT category, title, dripCount FROM subscriptions WHERE id = 's1'").use { + it.moveToFirst() + assertEquals("news", it.getString(0)) + assertEquals("the rest of the row must survive a column rename", "Up First from NPR", it.getString(1)) + assertEquals(2, it.getInt(2)) + } + // One that is not keeps the default rather than its old tier. + db.query("SELECT category FROM subscriptions WHERE id = 's2'").use { + it.moveToFirst() + assertEquals("truestory", it.getString(0)) + } + // And the old column is gone, not merely ignored. + db.query("PRAGMA table_info(subscriptions)").use { info -> + val columns = buildList { + while (info.moveToNext()) add(info.getString(info.getColumnIndexOrThrow("name"))) + } + assertTrue("category missing: $columns", "category" in columns) + assertFalse("tier should be gone: $columns", "tier" in columns) + } + } } diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index 95a984b..0ba2e82 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -34,7 +34,7 @@ class Converters { EpisodeTranscriptEntity::class, TranscriptCueEntity::class, ], - version = 20, + version = 21, exportSchema = true, ) @TypeConverters(Converters::class) @@ -514,6 +514,183 @@ abstract class AppDatabase : RoomDatabase() { } } + + /** + * The library as it stood on 2026-11-11, placed by hand. + * + * Data, not logic, and it lives in the migration because that is the one place it runs + * once and is then history. The previous attempt derived a show's placement from how + * often it publishes, and that cannot work: Creepy is daily horror fiction and Science + * Friday is daily science, and a cadence reads both as news. Nothing about a feed says + * what sort of listening it is, so somebody has to say, and saying it once here beats + * forty-two taps. + * + * Matched on feed URL because titles get rewritten by publishers. A show whose URL has + * moved since, or one subscribed later, keeps the default and is set in its own + * settings -- which is the ordinary way to do this and always was. + */ + /** Five of the feeds below share this, and spelling it out five times overran the line. */ + private const val OMNY = + "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/" + + private val PLACEMENTS = listOf( + "https://feeds.npr.org/510318/podcast.xml" to + "news", + "https://feeds.acast.com/public/shows/66510864c0852400122bc7be" to + "news", + "https://feeds.megaphone.fm/TBIEA2761282490" to + "news", + OMNY + + "654d6eb2-3c1a-4a7c-9933-aeac01321781/a4484dce-90df-4bdb-b4e8-aeac0132178a/podcast.rss" to + "news", + OMNY + + "b275c45e-40fc-4358-b7d7-aeac0132174e/15955f66-3fc4-4561-b974-aeac01321760/podcast.rss" to + "news", + "https://feeds.simplecast.com/kEKXbjuJ" to + "analysis", + "https://feeds.megaphone.fm/BVLLC2163264914" to + "analysis", + "https://rss.libsyn.com/shows/59621/destinations/228606.xml" to + "analysis", + "https://rss.libsyn.com/shows/561260/destinations/4849865.xml" to + "analysis", + "https://www.spreaker.com/show/6019906/episodes/feed" to + "analysis", + "https://feeds.npr.org/g-s1-84651/podcast.xml" to + "analysis", + "https://feeds.simplecast.com/h18ZIZD_" to + "curiosity", + "https://feeds.simplecast.com/EmVW7VGp" to + "curiosity", + "https://feeds.simplecast.com/fS_LWlfx" to + "curiosity", + "https://rss.libsyn.com/shows/51362/destinations/182214.xml" to + "curiosity", + "https://feeds.simplecast.com/6HKOhNgS" to + "curiosity", + "https://feeds.acast.com/public/shows/60aaa81306baac0012e485c8" to + "sailing", + "https://rss.libsyn.com/shows/283658/destinations/2179424.xml" to + "sailing", + "https://rss.buzzsprout.com/2270245.rss" to + "sailing", + "https://feeds.simplecast.com/xl36XBC2" to + "truestory", + OMNY + + "afbd76b8-eff2-442a-b938-b28e0126edad/d08826cd-f888-4cd3-b700-b28e0126edbb/podcast.rss" to + "truestory", + "https://feeds.megaphone.fm/replyall" to + "truestory", + "https://podcast.darknetdiaries.com" to + "truestory", + OMNY + + "86877fb0-9178-405d-92b1-b347010e3649/77ead75a-0897-48be-86c9-b347010e3c42/podcast.rss" to + "truestory", + "https://feeds.simplecast.com/6HzeyO6b" to + "truestory", + OMNY + + "56c1736b-308b-48e9-803a-b3cb0112d291/48090b89-a05d-49e6-9a6c-b3cb0112d6d4/podcast.rss" to + "truestory", + "https://rss.amperwave.net/v2/feed/audacynetwork/search-engine" to + "truestory", + "https://www.hawaiipublicradio.org/podcast/this-is-our-hawaii/rss.xml" to + "truestory", + "https://feed.podbean.com/ghostloreofhawaii/feed.xml" to + "truestory", + "https://feeds.redcircle.com/7d028506-d699-4a1f-b251-c212423a2185" to + "truestory", + "https://feeds.megaphone.fm/thetrip" to + "truestory", + "https://thisislow.com/podcast.rss" to + "fiction", + "https://feeds.simplecast.com/LDNgBXht" to + "fiction", + "https://rss.libsyn.com/shows/104269/destinations/557696.xml" to + "fiction", + "https://feeds.megaphone.fm/CNE2112124476" to + "fiction", + "https://audioboom.com/channels/5094626.rss" to + "fiction", + "https://feeds.simplecast.com/Iy2WKI7A" to + "fiction", + "https://feeds.acast.com/public/shows/6a21ce941ddbe06b3af8fefc" to + "fiction", + "https://legendsfromthepacific.libsyn.com/rss" to + "fiction", + "https://feeds.feedburner.com/dancarlin/history?format=xml" to + "deep", + "https://feeds.feedburner.com/dancarlin/commonsense?format=xml" to + "deep", + "https://rss.libsyn.com/shows/137081/destinations/850607.xml" to + "deep", + ) + + /** + * `tier` becomes `category`: three values standing for priority alone become seven that say + * what the show is, with the order carrying the priority. See docs/show-kinds.md. + * + * The column is renamed rather than added beside, because two fields meaning + * overlapping things is the thing this avoids. Old values do not map: "ordinary" was + * two thirds of a library and says nothing about what a show is, and "topical" covered + * both the daily news and a daily horror serial. Everything lands on the default and is + * placed afresh -- by the seeder for the obvious ones, by hand for the rest. + * + * SQLite has had ALTER TABLE RENAME COLUMN since 3.25 and Room's bundled version is far + * newer, so the whole table is not rebuilt. + */ + val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + // The table is rebuilt rather than altered. RENAME COLUMN keeps the old + // column's DEFAULT -- 'ordinary', which is not a category -- and the schema + // Room validates against would not match. DROP COLUMN would do it but arrived + // in SQLite 3.35, and minSdk 29 ships 3.28. + db.execSQL( + "CREATE TABLE IF NOT EXISTS `subscriptions_new` (`id` TEXT NOT NULL, " + + "`feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " + + "`description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, " + + "`addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, " + + "`lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, " + + "`skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, " + + "`dripEnabled` INTEGER NOT NULL DEFAULT 0, " + + "`dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, " + + "`dripCount` INTEGER NOT NULL DEFAULT 1, " + + "`dripOldestFirst` INTEGER NOT NULL DEFAULT 1, " + + "`dripLastReleaseAtMs` INTEGER, " + + "`skipAdChapters` INTEGER NOT NULL DEFAULT 0, " + + "`midrollPendingEpisodeId` TEXT, `bedtime` INTEGER NOT NULL DEFAULT 0, " + + "`category` TEXT NOT NULL DEFAULT 'truestory', PRIMARY KEY(`id`))" + ) + // Everything but the old tier, which does not carry over: see the KDoc. + db.execSQL( + "INSERT INTO `subscriptions_new` (`id`, `feedUrl`, `kind`, `title`, " + + "`description`, `author`, `imageUrl`, `link`, `addedAtMs`, `etag`, " + + "`lastModified`, `lastFetchedAtMs`, `lastError`, `playbackSpeed`, " + + "`skipIntroMs`, `skipOutroMs`, `dripEnabled`, `dripIntervalMs`, " + + "`dripCount`, `dripOldestFirst`, `dripLastReleaseAtMs`, " + + "`skipAdChapters`, `midrollPendingEpisodeId`, `bedtime`, `category`) " + + "SELECT `id`, `feedUrl`, `kind`, `title`, `description`, `author`, " + + "`imageUrl`, `link`, `addedAtMs`, `etag`, `lastModified`, " + + "`lastFetchedAtMs`, `lastError`, `playbackSpeed`, `skipIntroMs`, " + + "`skipOutroMs`, `dripEnabled`, `dripIntervalMs`, `dripCount`, " + + "`dripOldestFirst`, `dripLastReleaseAtMs`, `skipAdChapters`, " + + "`midrollPendingEpisodeId`, `bedtime`, '${ShowCategory.DEFAULT.wire}' " + + "FROM `subscriptions`" + ) + db.execSQL("DROP TABLE `subscriptions`") + db.execSQL("ALTER TABLE `subscriptions_new` RENAME TO `subscriptions`") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` " + + "ON `subscriptions` (`feedUrl`)" + ) + PLACEMENTS.forEach { (feedUrl, category) -> + db.execSQL( + "UPDATE `subscriptions` SET `category` = ? WHERE `feedUrl` = ?", + arrayOf(category, feedUrl), + ) + } + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -529,6 +706,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, + MIGRATION_20_21, ) .addCallback(SEED_DEFAULT_FILTERS) // No fallbackToDestructiveMigration. A listener's playback positions and play diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index e03a866..2dda8de 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -144,9 +144,9 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id") suspend fun setBedtime(id: String, enabled: Boolean) - /** How soon the server should do this show's work. See [org.terst.listen.data.ShowTier]. */ - @Query("UPDATE subscriptions SET tier = :tier WHERE id = :id") - suspend fun setTier(id: String, tier: String) + /** What sort of listening this show is, which is also its processing priority. See [ShowCategory]. */ + @Query("UPDATE subscriptions SET category = :category WHERE id = :id") + suspend fun setCategory(id: String, category: String) /** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */ @Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id") diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt index 9101dd7..8ca5985 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -129,13 +129,18 @@ data class SubscriptionEntity( */ @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false, /** - * How soon the server should do this show's work -- see [ShowTier]. + * What sort of listening this show is, which is also its processing priority. See [ShowCategory]. * * Stored as the wire value rather than the enum so a tier written by a later version reads * back as something rather than crashing the query. Defaults to "ordinary", which starves * nothing. */ - @ColumnInfo(defaultValue = "ordinary") val tier: String = "ordinary", + /** + * What sort of listening this show is, and so how soon the server works on it. + * + * Not [kind], which is where the audio comes from. See [ShowCategory]. + */ + @ColumnInfo(defaultValue = "truestory") val category: String = ShowCategory.DEFAULT.wire, ) /** diff --git a/app/src/main/java/org/terst/listen/data/Preferences.kt b/app/src/main/java/org/terst/listen/data/Preferences.kt index 7ccecf1..65df0f3 100644 --- a/app/src/main/java/org/terst/listen/data/Preferences.kt +++ b/app/src/main/java/org/terst/listen/data/Preferences.kt @@ -62,18 +62,6 @@ class Preferences(private val context: Context) : ViewPreferences { */ override val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true } - /** - * Whether the one-time tier seed has run; see [TierSeeder]. - * - * A flag rather than "place anything still at the default", because moving a show back to - * Keeping up by hand is a decision, and without this the next refresh would undo it. - */ - val tiersSeeded: Flow<Boolean> = context.store.data.map { it[TIERS_SEEDED] ?: false } - - suspend fun setTiersSeeded(value: Boolean) { - context.store.edit { it[TIERS_SEEDED] = value } - } - suspend fun setSkipShowFurniture(value: Boolean) { context.store.edit { it[SKIP_SHOW_FURNITURE] = value } } @@ -150,7 +138,6 @@ class Preferences(private val context: Context) : ViewPreferences { val SERVER_URL = stringPreferencesKey("server_url") val SERVER_TOKEN = stringPreferencesKey("server_token") val SKIP_SHOW_FURNITURE = booleanPreferencesKey("skip_show_furniture") - val TIERS_SEEDED = booleanPreferencesKey("tiers_seeded") val FAST_PLAY_BREAKS = booleanPreferencesKey("fast_play_breaks") val REJECTED_URL = stringPreferencesKey("rejected_server_url") val REJECTED_TOKEN = stringPreferencesKey("rejected_server_token") diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index f873079..e4a35f0 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -4,7 +4,6 @@ import android.content.Context import coil3.ImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first import okhttp3.OkHttpClient import org.terst.listen.server.OkHttpSkipTransport import org.terst.listen.download.WaitingDownloads @@ -15,6 +14,7 @@ import org.terst.listen.server.SkipServer import org.terst.listen.server.SkipServerConfig import org.terst.listen.server.StoredRefusals import org.terst.listen.download.ChaquopyAudioSource +import kotlinx.coroutines.flow.first import org.terst.listen.download.EpisodeDownloader import org.terst.listen.feed.ChapterFetcher import org.terst.listen.feed.FeedFetcher @@ -203,14 +203,13 @@ class AppGraph(private val context: Context) : UiGraph { SubscriptionCollaborators( onDiscovered = { ruleApplier.apply(it) }, probe = { url -> ChaquopyAudioSource(context).probe(url) }, - // The tiers go with the list: they are about the same shows and the server + // The categories go with the list: they are about the same shows and the server // drops a tier for a feed the same push removed. onRefreshed = { feedUrls -> - skipServer.pushSubscriptions(feedUrls, placedTiers()) + skipServer.pushSubscriptions(feedUrls, placedCategories()) }, refreshSkips = { skipRefresher.refreshAll() }, retryWaiting = { waitingDownloads.retryAll() }, - seedTiers = { tierSeeding.seedOnce() }, ), ) } @@ -244,19 +243,16 @@ class AppGraph(private val context: Context) : UiGraph { * Only the exceptions: sending "ordinary" for sixty shows would be the same information as * sending nothing, in sixty times the space. */ - /** Places a library nobody has placed; see [TierSeeder] for the rule and why it exists. */ - val tierSeeding: TierSeeding by lazy { - TierSeeding( - subscriptions, episodes, - hasSeeded = { preferences.tiersSeeded.first() }, - markSeeded = { preferences.setTiersSeeded(true) }, - ) - } - - private suspend fun placedTiers(): Map<String, String> = + /** + * Every show that has been placed somewhere other than the default, keyed by feed URL. + * + * The default is left out so the map stays the size of the exceptions rather than the + * library, which is the same bargain the server's own store makes. + */ + private suspend fun placedCategories(): Map<String, String> = subscriptions.getAll() - .filter { ShowTier.of(it.tier) != ShowTier.ORDINARY } - .associate { it.feedUrl to it.tier } + .filter { ShowCategory.of(it.category) != ShowCategory.DEFAULT } + .associate { it.feedUrl to it.category } /** * Shared between the download path and [skipRefresher], which write the server's answer into diff --git a/app/src/main/java/org/terst/listen/data/ShowCategory.kt b/app/src/main/java/org/terst/listen/data/ShowCategory.kt new file mode 100644 index 0000000..c216032 --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/ShowCategory.kt @@ -0,0 +1,58 @@ +package org.terst.listen.data + +/** + * What sort of listening a show is, and -- the same thing -- how soon the server should work + * on it. + * + * One ordered list doing two jobs. The number is the priority, so there is nothing to keep in + * sync between "what kind of show" and "how urgent". It replaces the earlier three-value tier, + * which was priority alone and could not say that a daily horror serial and the daily news want + * very different things. + * + * **The order is not "how good in the car".** It is how perishable a show is, which is what the + * server needs. Sports sits in [NEWS] because a game recap is worthless once the next game is + * played -- and it is also fine to listen to while driving, unlike the rest of that bucket. The + * car groups by this; it does not rank by it. See docs/show-kinds.md. + */ +enum class ShowCategory(val wire: String, val label: String) { + /** Worth hearing today and worthless by Thursday. Includes sports, which is exactly that. */ + NEWS("news", "News"), + + /** Still topical, often weekly rather than daily. */ + ANALYSIS("analysis", "Analysis"), + + /** Science, tech and the generally curious. Good car listening and not urgent. */ + CURIOSITY("curiosity", "Curiosity"), + + /** Boats. A hobby-sized group for a one-person library, and it earns its place. */ + SAILING("sailing", "Sailing"), + + /** + * Narrative journalism and documentary: things that happened, told as a story. + * + * Stored as "truestory" rather than "true", which SQLite reads as the boolean keyword. The + * column's default is this value, so `DEFAULT true` would have made every unplaced show + * default to the integer 1 instead of a category. + */ + TRUE_STORIES("truestory", "True stories"), + + /** Made up. */ + FICTION("fiction", "Fiction"), + + /** Long, and in no hurry. */ + DEEP("deep", "Deep dives"); + + companion object { + /** + * The default for a show nobody has placed. + * + * [TRUE_STORIES] rather than the first or last value: it is the middle of the order, so + * an unplaced show neither jumps the queue nor starves, and it is the largest group in a + * real library so it is the likeliest guess. + */ + val DEFAULT = TRUE_STORIES + + /** Reads a stored value, defaulting anything unrecognised to [DEFAULT]. */ + fun of(stored: String?): ShowCategory = entries.firstOrNull { it.wire == stored } ?: DEFAULT + } +} diff --git a/app/src/main/java/org/terst/listen/data/ShowTier.kt b/app/src/main/java/org/terst/listen/data/ShowTier.kt deleted file mode 100644 index f0a2d97..0000000 --- a/app/src/main/java/org/terst/listen/data/ShowTier.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.terst.listen.data - -/** - * How soon a show's server-side work is worth doing. - * - * Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he - * keeps up with, and a long tail that is not topical at all. It matters because recognising one - * episode costs the server about five minutes, so a five-hour back-catalogue episode arriving - * the same morning as the news takes most of a cycle before the news is looked at. - * - * Three and no more. A number would invite fiddling and would not mean anything; these are the - * three distinctions that were actually described. - */ -enum class ShowTier(val wire: String, val label: String) { - /** News: worth hearing today and worthless by Thursday. */ - TOPICAL("topical", "Topical"), - - /** - * A show kept up with. The default, because it is what most of a library is and because a - * show nobody has placed should not be starved. - */ - ORDINARY("ordinary", "Keeping up"), - - /** A back catalogue, a slow drip, anything heard whenever. Its work waits. */ - ARCHIVE("archive", "Whenever"); - - companion object { - /** - * Reads a stored value, defaulting anything unrecognised to [ORDINARY]. - * - * A row written by a later version, or a column that has not been set, must not leave a - * show with no tier at all -- the default is the one that starves nothing. - */ - fun of(stored: String?): ShowTier = - entries.firstOrNull { it.wire == stored } ?: ORDINARY - } -} diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt index 6ca6177..933c838 100644 --- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt +++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt @@ -79,15 +79,6 @@ data class SubscriptionCollaborators( * refresh route passes through, the library's refresh button included. */ val retryWaiting: suspend () -> Unit = {}, - /** - * Places shows that have never been placed; see [TierSeeder]. - * - * Before the server is told, so a seeded tier goes up on the same refresh that worked it out - * rather than a cycle later. A no-op once it has run, and on a fresh install -- where there - * are no episodes yet to judge a cadence from -- it simply finds nothing and tries again on - * the next refresh. - */ - val seedTiers: suspend () -> Unit = {}, ) class SubscriptionRepository( @@ -154,7 +145,6 @@ class SubscriptionRepository( } suspend fun refreshAll(): Map<String, RefreshOutcome> { - collaborators.seedTiers() val shows = subscriptions.getAll() // The one place every refresh passes through, which is why the server is told here // rather than in a worker: the refresh button in the library calls this directly and diff --git a/app/src/main/java/org/terst/listen/data/TierSeeder.kt b/app/src/main/java/org/terst/listen/data/TierSeeder.kt deleted file mode 100644 index b7f4847..0000000 --- a/app/src/main/java/org/terst/listen/data/TierSeeder.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.terst.listen.data - -import java.util.concurrent.TimeUnit - -/** - * Places shows into [ShowTier] from how often they actually publish. - * - * The tier feature shipped complete -- a control per show, pushed to the server on every refresh, - * the server ordering its work by it -- and empty, because every show has to be placed by hand - * and none ever was. So the server worked a 43-show library in feed order and the news waited - * behind whatever else arrived. - * - * This is a seed, not a policy. It runs once, places only shows still sitting at the default, and - * never touches one that has been placed; afterwards the control in the show's settings is the - * only thing that decides. A guess that is right most of the time beats a default nobody sets, - * and a wrong one costs a tap. - */ -object TierSeeder { - - /** What a show's feed says about it: when it published, and whether it is being dripped. */ - data class Cadence(val subscriptionId: String, val publishedAtMs: List<Long>, val dripEnabled: Boolean) - - /** - * Enough recent episodes to see a rhythm, and few enough that an old one does not count. - * - * A show that was daily three years ago and is monthly now is not news, and taking the whole - * feed would say it was. - */ - private const val RECENT = 12 - - /** Fewer than this and the gaps between them are not evidence of anything. */ - private const val ENOUGH = 4 - - /** - * Below this, the episodes did not arrive on a cadence at all -- they were published together. - * - * The case that makes rate alone useless. Serial, LOW and The Outer Reach each measure a - * median gap of about zero because a whole season went up at once, which on publication rate - * makes them look more topical than the news and means the exact opposite. - */ - private const val BULK_UPLOAD_DAYS = 0.5 - - /** Daily, give or take. Tangle, Up First and Science Friday sit between 0.9 and 1.0. */ - private const val TOPICAL_DAYS = 2.0 - - /** Fortnightly or rarer. Radiolab is 14 days; Hardcore History is 223. */ - private const val ARCHIVE_DAYS = 14.0 - - /** The placements to apply: only the shows this would move off the default. */ - fun seed(shows: List<Cadence>): Map<String, ShowTier> = - shows.associate { it.subscriptionId to place(it) } - .filterValues { it != ShowTier.ORDINARY } - - /** Where one show belongs, or [ShowTier.ORDINARY] when its feed says nothing useful. */ - fun place(show: Cadence): ShowTier { - // Already declared to be heard whenever, so there is nothing to work out. - if (show.dripEnabled) return ShowTier.ARCHIVE - - val recent = show.publishedAtMs.sorted().takeLast(RECENT) - if (recent.size < ENOUGH) return ShowTier.ORDINARY - - val gaps = recent.zipWithNext { a, b -> (b - a).toDouble() / TimeUnit.DAYS.toMillis(1) } - return when (val median = median(gaps)) { - in 0.0..<BULK_UPLOAD_DAYS -> ShowTier.ARCHIVE - in BULK_UPLOAD_DAYS..<TOPICAL_DAYS -> ShowTier.TOPICAL - else -> if (median >= ARCHIVE_DAYS) ShowTier.ARCHIVE else ShowTier.ORDINARY - } - } - - /** The median, not the mean: one re-run or one long hiatus must not move the answer. */ - private fun median(values: List<Double>): Double { - val sorted = values.sorted() - val middle = sorted.size / 2 - return if (sorted.size % 2 == 1) sorted[middle] else (sorted[middle - 1] + sorted[middle]) / 2 - } -} diff --git a/app/src/main/java/org/terst/listen/data/TierSeeding.kt b/app/src/main/java/org/terst/listen/data/TierSeeding.kt deleted file mode 100644 index d86b8d5..0000000 --- a/app/src/main/java/org/terst/listen/data/TierSeeding.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.terst.listen.data - -/** - * Runs [TierSeeder] once over a library that has never been placed. - * - * Kept apart from the seeder itself so the rule stays a pure function with its own tests, and - * this holds only the reading and writing -- the same split as `RetentionSweeper` and its worker. - */ -class TierSeeding( - private val subscriptions: SubscriptionDao, - private val episodes: EpisodeDao, - /** Whether the seed has already run, and how to record that it has. */ - private val hasSeeded: suspend () -> Boolean, - private val markSeeded: suspend () -> Unit, -) { - - /** - * Returns how many shows were placed. - * - * The flag is set only when something was actually placed. A fresh install has no episodes - * to judge a cadence from, and marking it done there would mean the library was never - * placed at all. - */ - suspend fun seedOnce(): Int { - if (hasSeeded()) return 0 - - val shows = subscriptions.getAll() - if (shows.isEmpty()) return 0 - - val placements = TierSeeder.seed( - shows.map { show -> - TierSeeder.Cadence( - subscriptionId = show.id, - publishedAtMs = episodes.getForSubscription(show.id).mapNotNull { it.publishedAtMs }, - dripEnabled = show.dripEnabled, - ) - }, - ) - if (placements.isEmpty()) return 0 - - // Only shows still at the default. One already placed by hand is a decision. - val byId = shows.associateBy { it.id } - var placed = 0 - for ((id, tier) in placements) { - if (ShowTier.of(byId[id]?.tier) != ShowTier.ORDINARY) continue - subscriptions.setTier(id, tier.wire) - placed++ - } - markSeeded() - return placed - } -} diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt index c07309d..6d3ef38 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -236,11 +236,12 @@ class SkipServer( suspend fun pushSubscriptions( feedUrls: List<String>, /** - * How soon each show's work is worth doing, keyed by feed URL, for the shows placed - * anywhere other than the default. Sent beside the list rather than folded into it, so - * a server that knows nothing about tiers reads the list exactly as before. + * What sort of listening each show is, keyed by feed URL, for the shows placed anywhere + * other than the default -- which is also the order the server works in. Sent beside the + * list rather than folded into it, so a server that knows nothing about categories reads + * the list exactly as before. */ - tiers: Map<String, String> = emptyMap(), + categories: Map<String, String> = emptyMap(), ): Boolean { val target = config() ?: return false val body = buildString { @@ -250,11 +251,11 @@ class SkipServer( append(JsonPrimitive(url)) } append("]") - if (tiers.isNotEmpty()) { - append(""","tiers":{""") - tiers.entries.forEachIndexed { index, (url, tier) -> + if (categories.isNotEmpty()) { + append(""","categories":{""") + categories.entries.forEachIndexed { index, (url, category) -> if (index > 0) append(",") - append(JsonPrimitive(url)).append(":").append(JsonPrimitive(tier)) + append(JsonPrimitive(url)).append(":").append(JsonPrimitive(category)) } append("}") } diff --git a/app/src/main/java/org/terst/listen/ui/CategorySetting.kt b/app/src/main/java/org/terst/listen/ui/CategorySetting.kt new file mode 100644 index 0000000..82d2053 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/CategorySetting.kt @@ -0,0 +1,49 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.terst.listen.data.ShowCategory + +/** + * What sort of listening a show is. + * + * Chips, wrapping, because there are seven now rather than three and they no longer fit a row. + * Still chips rather than a dropdown for the same reason as before: the point is seeing at a + * glance where a show sits. + * + * The category is also the server's work order -- the list is in priority sequence -- but the + * description does not say so, because that is not what the listener is choosing. A show placed + * anywhere still plays, downloads and skips identically; the only visible effect is which lists + * it appears in, and how soon the server gets to it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CategorySetting(current: ShowCategory, onChoose: (ShowCategory) -> Unit) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Category", style = MaterialTheme.typography.bodyLarge) + Text( + "What kind of listening this is. Groups it in the car, and decides what the server " + + "works on first. Nothing about playback changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (category in ShowCategory.entries) { + FilterChip( + selected = category == current, + onClick = { onChoose(category) }, + label = { Text(category.label) }, + ) + } + } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt index f7780a5..1e6fd99 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import org.terst.listen.data.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity /** @@ -64,7 +64,7 @@ fun ShowSettingsSheet( HorizontalDivider() - TierSetting(ShowTier.of(show.tier)) { settings.setTier(show.id, it) } + CategorySetting(ShowCategory.of(show.category)) { settings.setCategory(show.id, it) } HorizontalDivider() diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt index 221f876..ab22010 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.terst.listen.data.ArchiveOutcome import org.terst.listen.data.Graph -import org.terst.listen.data.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.UiGraph import org.terst.listen.fingerprint.SkipSummary @@ -136,17 +136,15 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr /** * Where this show sits in the server's queue. * - * The banner says what it does and what it does not, because it changes nothing the - * listener can hear and a setting that looks like it should is worth being plain about. + * The banner confirms the move rather than explaining it: the category's name is on screen + * already, and seven near-identical sentences about server ordering would say less than one. */ - fun setTier(subscriptionId: String, tier: ShowTier) { + fun setCategory(subscriptionId: String, category: ShowCategory) { viewModelScope.launch { - graph.subscriptions.setTier(subscriptionId, tier.wire) - _banner.value = when (tier) { - ShowTier.TOPICAL -> Banner("The server will do this show first") - ShowTier.ORDINARY -> Banner("The server will do this show in its usual turn") - ShowTier.ARCHIVE -> Banner("The server will do this show when nothing else is waiting") - } + graph.subscriptions.setCategory(subscriptionId, category.wire) + // One line per category would be seven near-identical sentences. The category's own + // name is what changed and is already on screen, so the banner says the consequence. + _banner.value = Banner("Filed under ${category.label}") } } diff --git a/app/src/main/java/org/terst/listen/ui/TierSetting.kt b/app/src/main/java/org/terst/listen/ui/TierSetting.kt deleted file mode 100644 index 6121db0..0000000 --- a/app/src/main/java/org/terst/listen/ui/TierSetting.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.terst.listen.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.terst.listen.data.ShowTier - -/** - * How soon the server should do this show's work. - * - * Chips rather than a dropdown: there are three, they are short, and the whole point is to see - * at a glance which one a show is in. The same shape the speed control already uses. - * - * It is a server-side scheduling hint and nothing else -- no episode is treated differently on - * the phone, and a show placed anywhere still plays, downloads and skips identically. The - * description says so, because a control that looks like it changes playback and does not is - * worse than no control. - */ -@Composable -fun TierSetting(current: ShowTier, onChoose: (ShowTier) -> Unit) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Priority", style = MaterialTheme.typography.bodyLarge) - Text( - "Which shows the server works on first. Nothing about playback changes.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - for (tier in ShowTier.entries) { - FilterChip( - selected = tier == current, - onClick = { onChoose(tier) }, - label = { Text(tier.label) }, - ) - } - } - } -} diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index ffb440a..b0e3d0c 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -62,8 +62,8 @@ class FakeSubscriptionDao : SubscriptionDao { replace(id) { it.copy(bedtime = enabled) } } - override suspend fun setTier(id: String, tier: String) { - replace(id) { it.copy(tier = tier) } + override suspend fun setCategory(id: String, category: String) { + replace(id) { it.copy(category = category) } } override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) { diff --git a/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt b/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt new file mode 100644 index 0000000..a9f430c --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/ShowCategoryTest.kt @@ -0,0 +1,51 @@ +package org.terst.listen.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The kinds, and the one property the whole design rests on: the order is the priority. + */ +class ShowCategoryTest { + + @Test + fun `the declared order is the processing order`() { + assertEquals( + listOf("news", "analysis", "curiosity", "sailing", "truestory", "fiction", "deep"), + ShowCategory.entries.map { it.wire }, + ) + } + + @Test + fun `an unrecognised or missing value falls back to the default`() { + assertEquals(ShowCategory.DEFAULT, ShowCategory.of(null)) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("")) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("topical")) + assertEquals(ShowCategory.DEFAULT, ShowCategory.of("a kind from a later version")) + } + + /** + * The default sits in the middle, so a show nobody has placed neither jumps the queue nor + * starves behind everything. The old tier default did the same for the same reason. + */ + @Test + fun `the default is neither first nor last`() { + val at = ShowCategory.entries.indexOf(ShowCategory.DEFAULT) + assertTrue("default at $at of ${ShowCategory.entries.size}", at > 0 && at < ShowCategory.entries.size - 1) + } + + /** The stored value is what crosses the wire and the database; it must not drift from the name. */ + @Test + fun `every kind has a distinct wire value and a label`() { + assertEquals(ShowCategory.entries.size, ShowCategory.entries.map { it.wire }.toSet().size) + assertTrue(ShowCategory.entries.all { it.wire.isNotBlank() && it.label.isNotBlank() }) + } + + @Test + fun `the old tier values are gone, so a stored one reads as unplaced`() { + for (old in listOf("topical", "ordinary", "archive")) { + assertEquals(ShowCategory.DEFAULT, ShowCategory.of(old)) + } + } +} diff --git a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt deleted file mode 100644 index 45e1886..0000000 --- a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.terst.listen.data - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Where a show sits in the server's queue. - * - * The default is load-bearing: a show nobody has placed must not be starved, and a value this - * version does not recognise -- written by a later one, or never set at all -- has to read as - * something rather than nothing. - */ -class ShowTierTest { - - @Test - fun `a stored tier reads back`() { - assertEquals(ShowTier.TOPICAL, ShowTier.of("topical")) - assertEquals(ShowTier.ARCHIVE, ShowTier.of("archive")) - } - - @Test - fun `nothing stored means keeping up`() { - assertEquals(ShowTier.ORDINARY, ShowTier.of(null)) - assertEquals(ShowTier.ORDINARY, ShowTier.of("")) - } - - /** A value from a later version must leave the show working, not unplaced. */ - @Test - fun `an unrecognised tier means keeping up`() { - assertEquals(ShowTier.ORDINARY, ShowTier.of("urgent-ish")) - } - - /** The wire value is what the server stores, so it must not drift from the enum. */ - @Test - fun `every tier round trips through its wire value`() { - for (tier in ShowTier.entries) { - assertEquals(tier, ShowTier.of(tier.wire)) - } - } - - /** Each one is offered to the listener by name, so none may be blank. */ - @Test - fun `every tier has something to show`() { - for (tier in ShowTier.entries) { - assertEquals(tier.label, tier.label.trim()) - assert(tier.label.isNotEmpty()) { "$tier has no label" } - } - } -} diff --git a/app/src/test/java/org/terst/listen/data/TierSeederTest.kt b/app/src/test/java/org/terst/listen/data/TierSeederTest.kt deleted file mode 100644 index 3935851..0000000 --- a/app/src/test/java/org/terst/listen/data/TierSeederTest.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.terst.listen.data - -import org.junit.Assert.assertEquals -import org.junit.Test -import java.util.concurrent.TimeUnit - -/** - * Placing a library's shows from how they actually publish. - * - * Peter's framing, in his words: "prioritize server processing for things like up first and - * tangle, and let things like Dan Carlin or radiolab wait for idle periods". The tier feature - * shipped built, tested and wired end to end -- and with nothing in it, because every show has to - * be placed by hand and none had been. A seed derived from the feeds does the obvious ones. - * - * Checked against the real library before it was written. The rule here puts Tangle, Up First, - * Science Friday and Wards Way in [ShowTier.TOPICAL]; both Dan Carlin shows, Radiolab, Reply All - * and the bulk-published back catalogues in [ShowTier.ARCHIVE]; and the weekly middle -- 23 of - * the 42 shows -- untouched. - */ -class TierSeederTest { - - private fun daily(count: Int) = cadenceOf(count, days = 1.0) - - private fun cadenceOf(count: Int, days: Double): List<Long> { - val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() - return (0 until count).map { it * step } - } - - private fun place(dates: List<Long>, dripEnabled: Boolean = false) = - TierSeeder.place(TierSeeder.Cadence("s1", dates, dripEnabled)) - - @Test - fun `a daily show is topical`() { - assertEquals(ShowTier.TOPICAL, place(daily(12))) - } - - @Test - fun `a weekly show is left where it is`() { - assertEquals(ShowTier.ORDINARY, place(cadenceOf(12, days = 7.0))) - } - - /** Radiolab is fortnightly; Dan Carlin is twice a year. Both wait. */ - @Test - fun `an occasional show waits`() { - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 14.0))) - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(6, days = 200.0))) - } - - /** - * The case that broke the first rule, and the reason the floor exists. - * - * Serial, LOW and The Outer Reach all measure a median gap of about zero -- a whole season - * published in one go. On publication rate alone they look more topical than the news, and - * they are the exact opposite: a back catalogue, which is what archive means. - */ - @Test - fun `a back catalogue published all at once is an archive, not the most topical thing there is`() { - assertEquals(ShowTier.ARCHIVE, place(cadenceOf(12, days = 0.0))) - } - - /** Already said to be heard whenever, so there is nothing to work out. */ - @Test - fun `a slow-drip show waits whatever its feed does`() { - assertEquals(ShowTier.ARCHIVE, place(daily(12), dripEnabled = true)) - } - - /** - * Too little to go on is not evidence of anything, and the default starves nothing -- the - * same reasoning ShowTier.of uses for an unrecognised stored value. - */ - @Test - fun `a show with too few episodes to judge is left alone`() { - assertEquals(ShowTier.ORDINARY, place(daily(3))) - assertEquals(ShowTier.ORDINARY, place(emptyList())) - } - - /** Only the newest stretch counts: a show that used to be daily and is now monthly is not news. */ - @Test - fun `an old burst does not make a quiet show topical`() { - val longAgo = cadenceOf(12, days = 1.0) - val recent = (1..12).map { longAgo.last() + it * TimeUnit.DAYS.toMillis(30) } - - assertEquals(ShowTier.ARCHIVE, place(longAgo + recent)) - } - - @Test - fun `seeding places only the shows still sitting at the default`() { - val placed = TierSeeder.seed( - listOf( - TierSeeder.Cadence("daily", daily(12), dripEnabled = false), - TierSeeder.Cadence("weekly", cadenceOf(12, days = 7.0), dripEnabled = false), - ), - ) - - assertEquals(mapOf("daily" to ShowTier.TOPICAL), placed) - } -} diff --git a/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt b/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt deleted file mode 100644 index 7f64ee3..0000000 --- a/app/src/test/java/org/terst/listen/data/TierSeedingTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package org.terst.listen.data - -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Test -import java.util.concurrent.TimeUnit - -/** - * Applying [TierSeeder]'s answer to a library, once. - * - * The rule is tested next door; this is about what it is allowed to touch. A placement nobody - * made is worth guessing at, and one somebody made is not. - */ -class TierSeedingTest { - - private val subscriptions = FakeSubscriptionDao() - private val episodes = FakeEpisodeDao() - private var seeded = false - - private fun seeding() = TierSeeding( - subscriptions, episodes, - hasSeeded = { seeded }, - markSeeded = { seeded = true }, - ) - - private fun show(id: String, tier: String = "ordinary", dripEnabled: Boolean = false) { - subscriptions.rows += SubscriptionEntity( - id = id, feedUrl = "https://$id", title = id, addedAtMs = 0, - tier = tier, dripEnabled = dripEnabled, - ) - } - - private fun publishesEvery(id: String, days: Double, count: Int = 12) { - val step = (days * TimeUnit.DAYS.toMillis(1)).toLong() - repeat(count) { i -> - episodes.rows += EpisodeEntity( - id = "$id-$i", subscriptionId = id, guid = "$id-$i", title = "e$i", - mediaUrl = "https://cdn/$i.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, - publishedAtMs = i * step, - ) - } - } - - @Test - fun `a daily show is placed`() = runTest { - show("daily") - publishesEvery("daily", days = 1.0) - - assertEquals(1, seeding().seedOnce()) - assertEquals(ShowTier.TOPICAL.wire, subscriptions.rows.single().tier) - } - - /** A placement made by hand is a decision, and a guess must not overwrite one. */ - @Test - fun `a show already placed by hand is left alone`() = runTest { - show("daily", tier = ShowTier.ARCHIVE.wire) - publishesEvery("daily", days = 1.0) - - assertEquals(0, seeding().seedOnce()) - assertEquals(ShowTier.ARCHIVE.wire, subscriptions.rows.single().tier) - } - - @Test - fun `it does not run twice`() = runTest { - show("daily") - publishesEvery("daily", days = 1.0) - seeding().seedOnce() - - subscriptions.setTier("daily", ShowTier.ORDINARY.wire) - - assertEquals("moving a show back by hand must survive the next refresh", 0, seeding().seedOnce()) - assertEquals(ShowTier.ORDINARY.wire, subscriptions.rows.single().tier) - } - - /** - * A fresh install has subscriptions before it has episodes, and placing nothing there must - * not count as having placed the library -- or it would never be placed at all. - */ - @Test - fun `a library with nothing to judge is left for next time`() = runTest { - show("new") - - assertEquals(0, seeding().seedOnce()) - assertEquals(false, seeded) - } - - @Test - fun `a weekly show is left at the default and does not spend the one run`() = runTest { - show("weekly") - publishesEvery("weekly", days = 7.0) - - assertEquals(0, seeding().seedOnce()) - assertEquals(false, seeded) - } -} diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt index 78c3987..5d9d979 100644 --- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt +++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt @@ -402,7 +402,7 @@ class SkipServerTest { * * Pinned because it is hand-built JSON going over the wire: a missing comma or brace is a * push the server rejects, and the only symptom is a library that silently stops being - * mirrored. The tiers ride beside the list rather than inside it, so a server that knows + * mirrored. The categories ride beside the list rather than inside it, so a server that knows * nothing about them reads the list exactly as it always did. */ @@ -416,20 +416,20 @@ class SkipServerTest { } @Test - fun `sends the tiers beside the list`() = runTest { + fun `sends the categories beside the list`() = runTest { val transport = FakeTransport() - server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "topical")) + server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "fiction")) assertEquals( - """{"feeds":["https://a"],"tiers":{"https://a":"topical"}}""", + """{"feeds":["https://a"],"categories":{"https://a":"fiction"}}""", transport.posts.single().second, ) } - /** No tiers placed means no tiers sent, not an empty object. */ + /** No category placed means none sent, not an empty object. */ @Test - fun `sends no tiers when none are placed`() = runTest { + fun `sends no categories when none are placed`() = runTest { val transport = FakeTransport() server(transport).pushSubscriptions(listOf("https://a"), emptyMap()) @@ -443,7 +443,7 @@ class SkipServerTest { val transport = FakeTransport() val awkward = """https://x/f?a=1&b="two"""" - server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "archive")) + server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "deep")) val body = transport.posts.single().second assertTrue("body is not valid json: $body", body.startsWith("""{"feeds":[""")) diff --git a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt index 44ca546..061d75e 100644 --- a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt +++ b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt @@ -14,12 +14,12 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.terst.listen.data.EpisodeMidrollEntity -import org.terst.listen.data.ShowTier +import org.terst.listen.data.ShowCategory import org.terst.listen.data.SubscriptionEntity import java.util.concurrent.TimeUnit /** - * The per-show sheet: drip, the archive walk, the tier and the skip readout. + * The per-show sheet: drip, the archive walk, the category and the skip readout. * * Every function here ends in a banner, and the banners are the point -- "nothing to release * yet" and "N episode(s) queued" are the difference between a feature that looks broken and one @@ -262,24 +262,35 @@ class ShowSettingsViewModelTest { assertTrue(graph.subscriptions.rows.single().skipAdChapters) } + /** + * Setting a category files the show and says so. + * + * It replaced a three-value tier whose banner explained the server's ordering in a different + * sentence per value. Seven of those would say less than one: the name is the thing that + * changed and is already on screen. + */ @Test - fun `each tier says what the server will do, and nothing about playback`() { - show() + fun `setting a category files the show and confirms it`() { + show() - vm.setTier("s1", ShowTier.TOPICAL) - assertEquals(Banner("The server will do this show first"), vm.banner.value) - assertEquals(ShowTier.TOPICAL.wire, graph.subscriptions.rows.single().tier) + vm.setCategory("s1", ShowCategory.FICTION) + awaitBanner() - vm.setTier("s1", ShowTier.ORDINARY) - assertEquals(Banner("The server will do this show in its usual turn"), vm.banner.value) + assertEquals(ShowCategory.FICTION.wire, graph.subscriptions.rows.single().category) + assertEquals(Banner("Filed under Fiction"), vm.banner.value) + } - vm.setTier("s1", ShowTier.ARCHIVE) - assertEquals( - Banner("The server will do this show when nothing else is waiting"), - vm.banner.value, - ) - assertEquals(ShowTier.ARCHIVE.wire, graph.subscriptions.rows.single().tier) - } + @Test + fun `a show can be refiled`() { + show() + + vm.setCategory("s1", ShowCategory.NEWS) + awaitBanner() + vm.setCategory("s1", ShowCategory.DEEP) + awaitBanner() + + assertEquals(ShowCategory.DEEP.wire, graph.subscriptions.rows.single().category) + } @Test fun `bedtime says where the show has gone, both ways`() { diff --git a/docs/show-kinds.md b/docs/show-kinds.md new file mode 100644 index 0000000..bd08b4a --- /dev/null +++ b/docs/show-kinds.md @@ -0,0 +1,62 @@ +# Show kinds + +One ordered list, doing two jobs: what sort of listening a show is, and how soon the server +should work on it. The number *is* the priority. + +| # | kind | shows | notes | +|---|---|---|---| +| 1 | News | 5 | Up First, Tangle, 404 Media, Seahawks Rewind, Seahawks Insiders | +| 2 | Political analysis | 6 | topical, often weekly | +| 3 | Science & curiosity | 5 | Science Friday, Radiolab, Terrestrials, Rationally Speaking, Hard Fork | +| 4 | Sailing | 3 | Liveaboard, Offshore Sailing, Wards Way | +| 5 | True stories | 12 | narrative journalism and documentary | +| 6 | Fiction | 8 | made up, including the daily horror one | +| 7 | Deep dives | 3 | both Carlins, Conversations with Tyler | + +Bedtime stays the flag it already was. It is not a kind: a bedtime show is absent from the +ordinary library entirely, which no kind does. + +## Why this replaces `tier` rather than joining it + +`tier` was topical / ordinary / archive — processing priority and nothing else. Loading "what +sort of show is this" onto a second field would have meant two things to set per show, and the +two would have disagreed within a week. This is the same field with a longer, more useful set of +values, so there is nothing new to keep in sync. + +## The compromise, written down because it will look like a mistake later + +**The order is not purely "how good in the car".** It is mostly how perishable a show is, which +is what the server needs. Sports is the clearest case: a game recap is worthless once the next +game is played, so it belongs at 1 with the news — and it is also perfectly good car listening, +unlike the rest of that bucket. Daily news and political analysis sit at the top for processing +and are the *last* things wanted while driving. + +So the car does not read this list as a ranking. It groups by it and offers what it offers. +Anyone who later assumes "lower number = show it first in the car" will be wrong, and this +paragraph is why. + +## Sailing is three shows, and that is fine + +Peter's call, and worth defending: these are hobby-sized groups for a 45-show library belonging +to one person. Idiosyncratic beats principled here — the categories that match how he actually +chooses will beat a taxonomy that would survive someone else's library. They can be merged if +they stop earning their place. + +## What to watch for, because this is a list of names and lists of names rot + +A better abstraction may exist, and this one was chosen knowing that. Signs it has arrived: + +- **A show needs two kinds.** Terrestrials is children's science storytelling; it is in Science & + curiosity and could as fairly be in Fiction. One more like it and the single-value assumption + is the problem, not the categories. +- **A kind grows past about a dozen.** True stories is at 12 and was 19 before Fiction split off. + Past that a kind stops narrowing anything and becomes a second library. +- **The priority order and the grouping order diverge further.** Sports already breaks it. If a + second kind needs to sit somewhere for processing that makes no sense for browsing, the two + jobs have genuinely come apart and the field should split — at which point the thing resisted + here becomes the right answer. +- **Length starts doing the work instead.** "Something short" cuts across every kind and is + already a car list of its own. If kinds are rarely used and length always is, that is the + signal. + +Added 2026-11-11, from a classification of all 45 subscribed shows. diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index a165feb..79290c1 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -295,7 +295,7 @@ func (w *worker) save() { func (w *worker) pollAll(ctx context.Context) { // Topical shows first and archive last, so a five-hour back-catalogue episode cannot take // a cycle's recognition budget before the morning news has been looked at. - feeds := w.state.ByTier(w.state.Feeds()) + feeds := w.state.ByCategory(w.state.Feeds()) if len(feeds) == 0 { log.Printf("no subscriptions yet; waiting for the phone to send its list") return diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 2d2e279..5a11c92 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -181,10 +181,10 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { } var body struct { Feeds []string `json:"feeds"` - // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and + // Categories says what sort of listening each feed is, keyed by URL, which is also // separate from the list rather than folded into it, so a phone that knows nothing // about tiers keeps working unchanged. - Tiers map[string]string `json:"tiers,omitempty"` + Categories map[string]string `json:"categories,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"}) @@ -200,7 +200,7 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { return } // After the list, so a tier for a feed that was just removed is dropped with it. - s.state.SetTiers(body.Tiers) + s.state.SetCategories(body.Categories) if s.opts.OnSubscriptions != nil { s.opts.OnSubscriptions(s.state.Feeds()) } diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index b50454b..5ae4435 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -379,21 +379,21 @@ Tiers arrive beside the list rather than folded into it, so a phone that knows n them keeps working unchanged -- which every phone did until this week. */ -func TestTakesTheTiersThePhoneSends(t *testing.T) { +func TestTakesTheCategoriesThePhoneSends(t *testing.T) { s, st, _, _ := newServer(t) post(t, s, "/api/subscriptions", - `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`) + `{"feeds":["https://a","https://b"],"categories":{"https://a":"news"}}`) - if got := st.Tier("https://a"); got != store.TierTopical { - t.Errorf("got %q, want %q", got, store.TierTopical) + if got := st.Category("https://a"); got != store.CategoryNews { + t.Errorf("got %q, want %q", got, store.CategoryNews) } - if got := st.Tier("https://b"); got != store.TierOrdinary { + if got := st.Category("https://b"); got != store.CategoryDefault { t.Errorf("got %q, want the default", got) } } -func TestAListWithNoTiersStillWorks(t *testing.T) { +func TestAListWithNoCategoriesStillWorks(t *testing.T) { s, st, _, _ := newServer(t) rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`) @@ -414,7 +414,7 @@ func TestATierForARemovedFeedIsDropped(t *testing.T) { post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`) - if got := st.Tier("https://a"); got != store.TierOrdinary { + if got := st.Category("https://a"); got != store.CategoryDefault { t.Errorf("got %q, want it dropped with the feed", got) } } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index de763ed..caf5365 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -469,7 +469,7 @@ func (s *State) SetFeeds(feeds []string) (removed []string) { } for url := range s.tiers { if !wanted[url] { - s.forgetTier(url) + s.forgetCategory(url) } } sort.Strings(removed) diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go index 94d2634..eb46aca 100644 --- a/server/internal/store/tier.go +++ b/server/internal/store/tier.go @@ -5,7 +5,7 @@ import "sort" /* How soon a show's work is worth doing. -Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +Peter's framing: a few shows are news and stop being worth hearing within a day, a category he keeps up with, and a long tail that is not topical at all. It matters because recognition costs about five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up First takes most of a cycle's budget before the news is touched. @@ -14,31 +14,55 @@ Three and no more. A number would invite fiddling and would not mean anything; t three distinctions he actually described. */ const ( - // TierTopical is news: worth hearing today and worthless by Thursday. - TierTopical = "topical" - // TierOrdinary is a show kept up with. The default, because it is what most of a library - // is and because a show nobody has placed should not be starved. - TierOrdinary = "ordinary" - // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits - // for the time nothing else wants. - TierArchive = "archive" + // The categories, in the order the work is done. The phone sends these; see + // docs/show-kinds.md in the app for what each means and why the order is what it is. + // + // They replaced three values that were priority alone -- topical, ordinary, archive -- + // which could not tell the daily news from a daily horror serial. A category says what a + // show *is*, and the order of the list is the priority, so there is one thing to set + // rather than two that can disagree. + CategoryNews = "news" + CategoryAnalysis = "analysis" + CategoryCuriosity = "curiosity" + CategorySailing = "sailing" + CategoryTrue = "truestory" + CategoryFiction = "fiction" + CategoryDeep = "deep" ) -// tierRank orders the work. Lower goes first. -var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2} +// categoryRank orders the work. Lower goes first, and the order is the declaration order above. +// +// A category this server does not know -- sent by a newer phone -- ranks with the default +// rather than first or last, so an unrecognised value neither jumps the queue nor starves. +var categoryRank = map[string]int{ + CategoryNews: 0, CategoryAnalysis: 1, CategoryCuriosity: 2, CategorySailing: 3, + CategoryTrue: 4, CategoryFiction: 5, CategoryDeep: 6, +} + +// CategoryDefault is where an unplaced or unrecognised show sits: the middle of the order. +const CategoryDefault = CategoryTrue + +// CategoryRank is where a category sits in the work order, for callers outside this package. +// An unknown one ranks with the default rather than first or last. +func CategoryRank(category string) int { + if rank, ok := categoryRank[category]; ok { + return rank + } + return categoryRank[CategoryDefault] +} /* -SetTiers records where the phone placed each feed, replacing what was there. +SetCategories records where the phone placed each feed, replacing what was there. -Only for feeds that are actually subscribed. The phone sends its list and its tiers in one -push, and a tier naming a feed the same push dropped would otherwise outlive it and be +Only for feeds that are actually subscribed. The phone sends its list and its categories in one +push, and a category naming a feed the same push dropped would otherwise outlive it and be inherited by a resubscription months later. -A tier this server does not understand is dropped rather than stored: a newer phone inventing a +A category this server does not understand is dropped rather than stored: a newer phone inventing a fourth must not quietly stop a show being polled, which is the rule the label store already follows for a verdict it does not recognise. */ -func (s *State) SetTiers(tiers map[string]string) { +func (s *State) SetCategories(categories map[string]string) { s.mu.Lock() defer s.mu.Unlock() if s.tiers == nil { @@ -48,38 +72,38 @@ func (s *State) SetTiers(tiers map[string]string) { for _, url := range s.feeds { subscribed[url] = true } - for url, tier := range tiers { - _, known := tierRank[tier] - if !subscribed[url] || !known || tier == TierOrdinary { + for url, category := range categories { + _, known := categoryRank[category] + if !subscribed[url] || !known || category == CategoryDefault { delete(s.tiers, url) continue } - s.tiers[url] = tier + s.tiers[url] = category } } -// Tier is where a feed sits, or [TierOrdinary]. -func (s *State) Tier(feedURL string) string { +// Category is where a feed sits, or [CategoryDefault]. +func (s *State) Category(feedURL string) string { s.mu.RLock() defer s.mu.RUnlock() - if tier, ok := s.tiers[feedURL]; ok { - return tier + if category, ok := s.tiers[feedURL]; ok { + return category } - return TierOrdinary + return CategoryDefault } -// ByTier orders feed URLs so the topical ones are worked first and the archive last. +// ByCategory orders feed URLs so the news is worked first and the deep dives last. // -// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A +// Stable, so an ordering within a category -- newest first, usually -- survives being grouped. A // sort that reshuffled equals would make the queue unpredictable between cycles for no gain. -func (s *State) ByTier(feedURLs []string) []string { +func (s *State) ByCategory(feedURLs []string) []string { out := append([]string(nil), feedURLs...) sort.SliceStable(out, func(i, j int) bool { - return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])] + return categoryRank[s.Category(out[i])] < categoryRank[s.Category(out[j])] }) return out } -// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months +// forgetCategory drops a feed's placement. Called when the feed goes, or a feed resubscribed months // later would arrive carrying a placement nobody chose. -func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) } +func (s *State) forgetCategory(feedURL string) { delete(s.tiers, feedURL) } diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go index 971ee51..025f323 100644 --- a/server/internal/store/tier_test.go +++ b/server/internal/store/tier_test.go @@ -17,10 +17,10 @@ the three distinctions he actually described. func TestATierIsRememberedForAFeed(t *testing.T) { s := New() s.SetFeeds([]string{"https://a", "https://b"}) - s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetCategories(map[string]string{"https://a": CategoryNews}) - if got := s.Tier("https://a"); got != TierTopical { - t.Errorf("got %q, want %q", got, TierTopical) + if got := s.Category("https://a"); got != CategoryNews { + t.Errorf("got %q, want %q", got, CategoryNews) } } @@ -29,8 +29,8 @@ func TestAShowWithNoTierIsOrdinary(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - if got := s.Tier("https://a"); got != TierOrdinary { - t.Errorf("got %q, want %q", got, TierOrdinary) + if got := s.Category("https://a"); got != CategoryDefault { + t.Errorf("got %q, want %q", got, CategoryDefault) } } @@ -43,9 +43,9 @@ same rule the label store already follows for a verdict it does not recognise. func TestAnUnknownTierIsIgnored(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - s.SetTiers(map[string]string{"https://a": "urgent-ish"}) + s.SetCategories(map[string]string{"https://a": "urgent-ish"}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want the ordinary tier", got) } } @@ -55,10 +55,10 @@ func TestAnUnknownTierIsIgnored(t *testing.T) { func TestForgettingAFeedForgetsItsTier(t *testing.T) { s := New() s.SetFeeds([]string{"https://a"}) - s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetCategories(map[string]string{"https://a": CategoryNews}) s.SetFeeds([]string{"https://b"}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want the tier forgotten with the feed", got) } } @@ -69,16 +69,16 @@ Ordering runs topical first and archive last, and keeps the given order within a Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would make the queue unpredictable from one cycle to the next for no gain. */ -func TestWorkIsOrderedByTier(t *testing.T) { +func TestWorkIsOrderedByCategory(t *testing.T) { s := New() s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"}) - s.SetTiers(map[string]string{ - "https://news": TierTopical, - "https://news2": TierTopical, - "https://archive": TierArchive, + s.SetCategories(map[string]string{ + "https://news": CategoryNews, + "https://news2": CategoryNews, + "https://archive": CategoryDeep, }) - got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"}) + got := s.ByCategory([]string{"https://usual", "https://archive", "https://news", "https://news2"}) want := []string{"https://news", "https://news2", "https://usual", "https://archive"} for i := range want { if got[i] != want[i] { @@ -96,9 +96,9 @@ dropped would outlive it and be inherited by a resubscription months later. func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) { s := New() s.SetFeeds([]string{"https://b"}) - s.SetTiers(map[string]string{"https://a": TierArchive}) + s.SetCategories(map[string]string{"https://a": CategoryDeep}) - if got := s.Tier("https://a"); got != TierOrdinary { + if got := s.Category("https://a"); got != CategoryDefault { t.Errorf("got %q, want nothing kept for an unsubscribed feed", got) } } diff --git a/server/internal/words/pipeline.go b/server/internal/words/pipeline.go index 8cd18ed..d8de8d8 100644 --- a/server/internal/words/pipeline.go +++ b/server/internal/words/pipeline.go @@ -102,17 +102,11 @@ func (p *Pipeline) Forget(e *store.Episode) { e.WordBreaks, e.WordAdsRefused = nil, "" } -// tierRankOf orders the tiers for work: topical first, archive last. -func tierRankOf(tier string) int { - switch tier { - case store.TierTopical: - return 0 - case store.TierArchive: - return 2 - default: - return 1 - } -} +// categoryRankOf orders the categories for work, news first and deep dives last. +// +// Delegates rather than keeping its own table: the order is the category list's declaration +// order, and two copies of it would drift the first time one is added. +func categoryRankOf(category string) int { return store.CategoryRank(category) } // clock renders a duration the way every readout in this project does. func clock(ms int64) string { @@ -725,7 +719,7 @@ func (p *Pipeline) pendingRecognition() []*store.Episode { rank := map[string]int{} for _, show := range p.State.Shows() { for _, e := range show.Episodes { - rank[e.ID] = tierRankOf(p.State.Tier(show.FeedURL)) + rank[e.ID] = categoryRankOf(p.State.Category(show.FeedURL)) } } sort.SliceStable(pending, func(i, j int) bool { diff --git a/server/internal/words/pipeline_test.go b/server/internal/words/pipeline_test.go index 9982a10..cf23d8f 100644 --- a/server/internal/words/pipeline_test.go +++ b/server/internal/words/pipeline_test.go @@ -76,13 +76,13 @@ A five-hour back-catalogue episode arriving the same morning as the news would o most of a cycle's recognition budget before the news was looked at. */ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) { - if tierRankOf(store.TierTopical) >= tierRankOf(store.TierOrdinary) { + if categoryRankOf(store.CategoryNews) >= categoryRankOf(store.CategoryDefault) { t.Error("a topical show does not come first") } - if tierRankOf(store.TierArchive) <= tierRankOf(store.TierOrdinary) { + if categoryRankOf(store.CategoryDeep) <= categoryRankOf(store.CategoryDefault) { t.Error("an archive show does not come last") } - if tierRankOf("") != tierRankOf(store.TierOrdinary) { + if categoryRankOf("") != categoryRankOf(store.CategoryDefault) { t.Error("a show nobody placed is not treated as ordinary, so it could be starved") } } @@ -90,7 +90,7 @@ func TestTheWorkOrderPutsTopicalShowsFirst(t *testing.T) { func TestRecognitionIsOrderedByTierThenByWhatChecksItself(t *testing.T) { state := store.New() state.SetFeeds([]string{"https://news", "https://usual"}) - state.SetTiers(map[string]string{"https://news": store.TierTopical}) + state.SetCategories(map[string]string{"https://news": store.CategoryNews}) state.Merge("https://usual", "Usual", nil) state.Merge("https://news", "News", nil) |
