diff options
21 files changed, 1648 insertions, 11 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 807fb0b..5b8f017 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -94.41 +94.44 diff --git a/app/schemas/org.terst.listen.data.AppDatabase/20.json b/app/schemas/org.terst.listen.data.AppDatabase/20.json new file mode 100644 index 0000000..21f03b4 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/20.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "c740925b625f25c496d13bd98f5c26c4", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, `midrollPendingEpisodeId` TEXT, `bedtime` INTEGER NOT NULL DEFAULT 0, `tier` TEXT NOT NULL DEFAULT 'ordinary', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feedUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAtMs", + "columnName": "addedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFetchedAtMs", + "columnName": "lastFetchedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "playbackSpeed", + "columnName": "playbackSpeed", + "affinity": "REAL" + }, + { + "fieldPath": "skipIntroMs", + "columnName": "skipIntroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipOutroMs", + "columnName": "skipOutroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dripEnabled", + "columnName": "dripEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripIntervalMs", + "columnName": "dripIntervalMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "86400000" + }, + { + "fieldPath": "dripCount", + "columnName": "dripCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripOldestFirst", + "columnName": "dripOldestFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripLastReleaseAtMs", + "columnName": "dripLastReleaseAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "skipAdChapters", + "columnName": "skipAdChapters", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "midrollPendingEpisodeId", + "columnName": "midrollPendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "bedtime", + "columnName": "bedtime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "tier", + "columnName": "tier", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ordinary'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `actualDurationMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `audioFromServer` INTEGER NOT NULL DEFAULT 0, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "actualDurationMs", + "columnName": "actualDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioFromServer", + "columnName": "audioFromServer", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "midrollCheckedAtMs", + "columnName": "midrollCheckedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "show_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPath` TEXT NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPath", + "columnName": "referenceClipPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "id" + ] + }, + "indices": [ + { + "name": "index_show_midrolls_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, `origin` TEXT NOT NULL DEFAULT 'audio', PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "candidateId", + "columnName": "candidateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'audio'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "candidateId" + ] + }, + "indices": [ + { + "name": "index_episode_midrolls_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "break_verdicts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `verdict` TEXT NOT NULL, `decidedAtMs` INTEGER NOT NULL, `kind` TEXT NOT NULL DEFAULT 'ad', `partners` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`, `endMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "verdict", + "columnName": "verdict", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decidedAtMs", + "columnName": "decidedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ad'" + }, + { + "fieldPath": "partners", + "columnName": "partners", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs", + "endMs" + ] + }, + "indices": [ + { + "name": "index_break_verdicts_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_break_verdicts_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "ad_reports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `reportedAtMs` INTEGER NOT NULL, `correctionWindowMs` INTEGER NOT NULL, `endCorrected` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`))", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportedAtMs", + "columnName": "reportedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "correctionWindowMs", + "columnName": "correctionWindowMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endCorrected", + "columnName": "endCorrected", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_ad_reports_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ad_reports_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ] + }, + { + "tableName": "episode_transcripts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `source` TEXT NOT NULL, `cues` INTEGER NOT NULL, `note` TEXT NOT NULL, `fetchedAtMs` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cues", + "columnName": "cues", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAtMs", + "columnName": "fetchedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "transcript_cues", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, `text` TEXT NOT NULL, `advertising` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`episodeId`, `startMs`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "advertising", + "columnName": "advertising", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "startMs" + ] + }, + "indices": [ + { + "name": "index_transcript_cues_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_cues_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c740925b625f25c496d13bd98f5c26c4')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt index a261149..5ef4379 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -906,4 +906,48 @@ class MigrationTest { assertEquals("the crown jewel must survive", 314159L, it.getLong(0)) } } + + /** + * A show gains a tier, and existing shows default to the one that starves nothing. + * + * It is a scheduling hint for the server and changes nothing on the phone, so an older + * show arriving without one must keep behaving exactly as it did. + */ + @Test + fun migrate19To20GivesEveryShowATierThatStarvesNothing() { + helper.createDatabase(name, 19).apply { + execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime) + VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0, + 0, 86400000, 1, 1, 1, 0)""" + ) + execSQL( + """INSERT INTO episodes + (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs, + positionMs, downloadState) + VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000, + 271828, 'NONE')""" + ) + close() + } + + val db = helper.runMigrationsAndValidate(name, 20, true, AppDatabase.MIGRATION_19_20) + + db.query("SELECT tier, title FROM subscriptions WHERE id = 's1'").use { + it.moveToFirst() + assertEquals("a show nobody placed must not be starved", "ordinary", it.getString(0)) + assertEquals("A Show", it.getString(1)) + } + db.execSQL("UPDATE subscriptions SET tier = 'topical' WHERE id = 's1'") + db.query("SELECT tier FROM subscriptions WHERE id = 's1'").use { + it.moveToFirst() + assertEquals("topical", it.getString(0)) + } + db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use { + it.moveToFirst() + assertEquals("the crown jewel must survive", 271828L, it.getLong(0)) + } + } } diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index dc9c094..95a984b 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -34,7 +34,7 @@ class Converters { EpisodeTranscriptEntity::class, TranscriptCueEntity::class, ], - version = 19, + version = 20, exportSchema = true, ) @TypeConverters(Converters::class) @@ -499,6 +499,21 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * A show gains a tier: how soon the server should do its work. + * + * Existing shows default to "ordinary", which is the tier that starves nothing. The + * server treats an absent or unrecognised tier the same way, so a phone and a server + * that disagree about this column still agree about the show. + */ + val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `subscriptions` ADD COLUMN `tier` TEXT NOT NULL DEFAULT 'ordinary'" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -513,6 +528,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, + MIGRATION_19_20, ) .addCallback(SEED_DEFAULT_FILTERS) // No fallbackToDestructiveMigration. A listener's playback positions and play diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 43f713f..12c605c 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -144,6 +144,10 @@ interface SubscriptionDao { @Query("UPDATE subscriptions SET bedtime = :enabled WHERE id = :id") suspend fun setBedtime(id: String, enabled: Boolean) + /** How soon the server should do this show's work. See [org.terst.listen.data.ShowTier]. */ + @Query("UPDATE subscriptions SET tier = :tier WHERE id = :id") + suspend fun setTier(id: String, tier: String) + /** Tier 2 of the skip feature: how much of the top and tail of every episode to pass over. */ @Query("UPDATE subscriptions SET skipIntroMs = :introMs, skipOutroMs = :outroMs WHERE id = :id") suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt index 1ef3bde..9101dd7 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -128,6 +128,14 @@ data class SubscriptionEntity( * -- see [AppDatabase.MIGRATION_8_9]. */ @ColumnInfo(defaultValue = "0") val bedtime: Boolean = false, + /** + * How soon the server should do this show's work -- see [ShowTier]. + * + * Stored as the wire value rather than the enum so a tier written by a later version reads + * back as something rather than crashing the query. Defaults to "ordinary", which starves + * nothing. + */ + @ColumnInfo(defaultValue = "ordinary") val tier: String = "ordinary", ) /** diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index 1dc5bad..2655546 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -164,7 +164,11 @@ class AppGraph(private val context: Context) { SubscriptionCollaborators( onDiscovered = { ruleApplier.apply(it) }, probe = { url -> ChaquopyAudioSource(context).probe(url) }, - onRefreshed = { feedUrls -> skipServer.pushSubscriptions(feedUrls) }, + // The tiers go with the list: they are about the same shows and the server + // drops a tier for a feed the same push removed. + onRefreshed = { feedUrls -> + skipServer.pushSubscriptions(feedUrls, placedTiers()) + }, refreshSkips = { skipRefresher.refreshAll() }, retryWaiting = { waitingDownloads.retryAll() }, ), @@ -194,6 +198,17 @@ class AppGraph(private val context: Context) { ) } + /** + * Where each show has been placed, for the shows placed anywhere but the default. + * + * Only the exceptions: sending "ordinary" for sixty shows would be the same information as + * sending nothing, in sixty times the space. + */ + private suspend fun placedTiers(): Map<String, String> = + subscriptions.getAll() + .filter { ShowTier.of(it.tier) != ShowTier.ORDINARY } + .associate { it.feedUrl to it.tier } + /** Shared between the download path and [skipRefresher], which write the server's answer * into exactly the same columns. */ val serverSkips: ServerSkips by lazy { diff --git a/app/src/main/java/org/terst/listen/data/ShowTier.kt b/app/src/main/java/org/terst/listen/data/ShowTier.kt new file mode 100644 index 0000000..f0a2d97 --- /dev/null +++ b/app/src/main/java/org/terst/listen/data/ShowTier.kt @@ -0,0 +1,37 @@ +package org.terst.listen.data + +/** + * How soon a show's server-side work is worth doing. + * + * Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he + * keeps up with, and a long tail that is not topical at all. It matters because recognising one + * episode costs the server about five minutes, so a five-hour back-catalogue episode arriving + * the same morning as the news takes most of a cycle before the news is looked at. + * + * Three and no more. A number would invite fiddling and would not mean anything; these are the + * three distinctions that were actually described. + */ +enum class ShowTier(val wire: String, val label: String) { + /** News: worth hearing today and worthless by Thursday. */ + TOPICAL("topical", "Topical"), + + /** + * A show kept up with. The default, because it is what most of a library is and because a + * show nobody has placed should not be starved. + */ + ORDINARY("ordinary", "Keeping up"), + + /** A back catalogue, a slow drip, anything heard whenever. Its work waits. */ + ARCHIVE("archive", "Whenever"); + + companion object { + /** + * Reads a stored value, defaulting anything unrecognised to [ORDINARY]. + * + * A row written by a later version, or a column that has not been set, must not leave a + * show with no tier at all -- the default is the one that starves nothing. + */ + fun of(stored: String?): ShowTier = + entries.firstOrNull { it.wire == stored } ?: ORDINARY + } +} diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt index 68a04ed..8f2141c 100644 --- a/app/src/main/java/org/terst/listen/server/SkipServer.kt +++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt @@ -232,7 +232,15 @@ class SkipServer( * import, the queue rules, Android Auto's browse tree -- keeps working untouched, and the * server stays something the app prefers rather than depends on. */ - suspend fun pushSubscriptions(feedUrls: List<String>): Boolean { + suspend fun pushSubscriptions( + feedUrls: List<String>, + /** + * How soon each show's work is worth doing, keyed by feed URL, for the shows placed + * anywhere other than the default. Sent beside the list rather than folded into it, so + * a server that knows nothing about tiers reads the list exactly as before. + */ + tiers: Map<String, String> = emptyMap(), + ): Boolean { val target = config() ?: return false val body = buildString { append("""{"feeds":[""") @@ -240,7 +248,16 @@ class SkipServer( if (index > 0) append(",") append(JsonPrimitive(url)) } - append("]}") + append("]") + if (tiers.isNotEmpty()) { + append(""","tiers":{""") + tiers.entries.forEachIndexed { index, (url, tier) -> + if (index > 0) append(",") + append(JsonPrimitive(url)).append(":").append(JsonPrimitive(tier)) + } + append("}") + } + append("}") } return observe(target, transport.post(base(target) + "/api/subscriptions", target.token, body)) != null } diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt index b387a2c..693fee3 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsSheet.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import org.terst.listen.data.ShowTier import org.terst.listen.data.SubscriptionEntity /** @@ -63,6 +64,10 @@ fun ShowSettingsSheet( HorizontalDivider() + TierSetting(ShowTier.of(show.tier)) { settings.setTier(show.id, it) } + + HorizontalDivider() + DripSettings( subscription = show, busy = busy, diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt index a84fa40..56d172f 100644 --- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.launch import org.terst.listen.data.ArchiveOutcome import org.terst.listen.data.FingerprintKind import org.terst.listen.data.Graph +import org.terst.listen.data.ShowTier import org.terst.listen.fingerprint.IntroOutroDetector import org.terst.listen.fingerprint.MidrollDetector import org.terst.listen.fingerprint.SkipSummary @@ -115,6 +116,23 @@ class ShowSettingsViewModel(app: Application) : AndroidViewModel(app) { } /** Moves a show in or out of the bedtime list. */ + /** + * Where this show sits in the server's queue. + * + * The banner says what it does and what it does not, because it changes nothing the + * listener can hear and a setting that looks like it should is worth being plain about. + */ + fun setTier(subscriptionId: String, tier: ShowTier) { + viewModelScope.launch { + graph.subscriptions.setTier(subscriptionId, tier.wire) + _banner.value = when (tier) { + ShowTier.TOPICAL -> Banner("The server will do this show first") + ShowTier.ORDINARY -> Banner("The server will do this show in its usual turn") + ShowTier.ARCHIVE -> Banner("The server will do this show when nothing else is waiting") + } + } + } + fun setBedtime(subscriptionId: String, enabled: Boolean) { viewModelScope.launch { graph.subscriptions.setBedtime(subscriptionId, enabled) diff --git a/app/src/main/java/org/terst/listen/ui/TierSetting.kt b/app/src/main/java/org/terst/listen/ui/TierSetting.kt new file mode 100644 index 0000000..6121db0 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/TierSetting.kt @@ -0,0 +1,45 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.terst.listen.data.ShowTier + +/** + * How soon the server should do this show's work. + * + * Chips rather than a dropdown: there are three, they are short, and the whole point is to see + * at a glance which one a show is in. The same shape the speed control already uses. + * + * It is a server-side scheduling hint and nothing else -- no episode is treated differently on + * the phone, and a show placed anywhere still plays, downloads and skips identically. The + * description says so, because a control that looks like it changes playback and does not is + * worse than no control. + */ +@Composable +fun TierSetting(current: ShowTier, onChoose: (ShowTier) -> Unit) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Priority", style = MaterialTheme.typography.bodyLarge) + Text( + "Which shows the server works on first. Nothing about playback changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (tier in ShowTier.entries) { + FilterChip( + selected = tier == current, + onClick = { onChoose(tier) }, + label = { Text(tier.label) }, + ) + } + } + } +} diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index ec76368..f3b9a23 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -62,6 +62,10 @@ class FakeSubscriptionDao : SubscriptionDao { replace(id) { it.copy(bedtime = enabled) } } + override suspend fun setTier(id: String, tier: String) { + replace(id) { it.copy(tier = tier) } + } + override suspend fun updateSkips(id: String, introMs: Long, outroMs: Long) { replace(id) { it.copy(skipIntroMs = introMs, skipOutroMs = outroMs) } } diff --git a/app/src/test/java/org/terst/listen/data/ShowTierTest.kt b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt new file mode 100644 index 0000000..45e1886 --- /dev/null +++ b/app/src/test/java/org/terst/listen/data/ShowTierTest.kt @@ -0,0 +1,49 @@ +package org.terst.listen.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Where a show sits in the server's queue. + * + * The default is load-bearing: a show nobody has placed must not be starved, and a value this + * version does not recognise -- written by a later one, or never set at all -- has to read as + * something rather than nothing. + */ +class ShowTierTest { + + @Test + fun `a stored tier reads back`() { + assertEquals(ShowTier.TOPICAL, ShowTier.of("topical")) + assertEquals(ShowTier.ARCHIVE, ShowTier.of("archive")) + } + + @Test + fun `nothing stored means keeping up`() { + assertEquals(ShowTier.ORDINARY, ShowTier.of(null)) + assertEquals(ShowTier.ORDINARY, ShowTier.of("")) + } + + /** A value from a later version must leave the show working, not unplaced. */ + @Test + fun `an unrecognised tier means keeping up`() { + assertEquals(ShowTier.ORDINARY, ShowTier.of("urgent-ish")) + } + + /** The wire value is what the server stores, so it must not drift from the enum. */ + @Test + fun `every tier round trips through its wire value`() { + for (tier in ShowTier.entries) { + assertEquals(tier, ShowTier.of(tier.wire)) + } + } + + /** Each one is offered to the listener by name, so none may be blank. */ + @Test + fun `every tier has something to show`() { + for (tier in ShowTier.entries) { + assertEquals(tier.label, tier.label.trim()) + assert(tier.label.isNotEmpty()) { "$tier has no label" } + } + } +} diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt index 650c992..78c3987 100644 --- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt +++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt @@ -396,4 +396,57 @@ class SkipServerTest { assertEquals(false, server(transport, token = null).isConfigured()) assertTrue("asking must not touch the network", transport.gets.isEmpty()) } + + /* + * The body the subscription push actually sends. + * + * Pinned because it is hand-built JSON going over the wire: a missing comma or brace is a + * push the server rejects, and the only symptom is a library that silently stops being + * mirrored. The tiers ride beside the list rather than inside it, so a server that knows + * nothing about them reads the list exactly as it always did. + */ + + @Test + fun `sends the feed list as json`() = runTest { + val transport = FakeTransport() + + server(transport).pushSubscriptions(listOf("https://a", "https://b")) + + assertEquals("""{"feeds":["https://a","https://b"]}""", transport.posts.single().second) + } + + @Test + fun `sends the tiers beside the list`() = runTest { + val transport = FakeTransport() + + server(transport).pushSubscriptions(listOf("https://a"), mapOf("https://a" to "topical")) + + assertEquals( + """{"feeds":["https://a"],"tiers":{"https://a":"topical"}}""", + transport.posts.single().second, + ) + } + + /** No tiers placed means no tiers sent, not an empty object. */ + @Test + fun `sends no tiers when none are placed`() = runTest { + val transport = FakeTransport() + + server(transport).pushSubscriptions(listOf("https://a"), emptyMap()) + + assertEquals("""{"feeds":["https://a"]}""", transport.posts.single().second) + } + + /** A feed URL carries query strings and ampersands; JSON quoting has to survive them. */ + @Test + fun `quotes a url that would otherwise break the json`() = runTest { + val transport = FakeTransport() + val awkward = """https://x/f?a=1&b="two"""" + + server(transport).pushSubscriptions(listOf(awkward), mapOf(awkward to "archive")) + + val body = transport.posts.single().second + assertTrue("body is not valid json: $body", body.startsWith("""{"feeds":[""")) + assertTrue("the quote was not escaped: $body", body.contains("""\"two\"""")) + } } diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index 377fedc..43921bd 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -40,6 +40,18 @@ import ( "github.com/terst/listen/server/internal/transcript" ) +// tierRankOf orders the tiers for work: topical first, archive last. +func tierRankOf(tier string) int { + switch tier { + case store.TierTopical: + return 0 + case store.TierArchive: + return 2 + default: + return 1 + } +} + // minRecurringCorpus is how many of a show's episodes must have words before what they share // means anything. Three: the target and the two others a stretch has to turn up in. const minRecurringCorpus = 3 @@ -291,7 +303,9 @@ func (w *worker) save() { // pollAll refreshes every subscribed feed, then mirrors and detects what changed. func (w *worker) pollAll(ctx context.Context) { - feeds := w.state.Feeds() + // Topical shows first and archive last, so a five-hour back-catalogue episode cannot take + // a cycle's recognition budget before the morning news has been looked at. + feeds := w.state.ByTier(w.state.Feeds()) if len(feeds) == 0 { log.Printf("no subscriptions yet; waiting for the phone to send its list") return @@ -960,8 +974,20 @@ func (w *worker) pendingRecognition() []*store.Episode { } } } + // Three things decide the order, and they are in the order they matter. A topical show is + // worthless by Thursday. An episode whose answer checks itself against the length gap is + // worth more than one that cannot. And within that, newest first. + rank := map[string]int{} + for _, show := range w.state.Shows() { + for _, e := range show.Episodes { + rank[e.ID] = tierRankOf(w.state.Tier(show.FeedURL)) + } + } sort.SliceStable(pending, func(i, j int) bool { left, right := pending[i], pending[j] + if rank[left.ID] != rank[right.ID] { + return rank[left.ID] < rank[right.ID] + } if (left.MasterCues > 0) != (right.MasterCues > 0) { return left.MasterCues > 0 } diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 2db0f91..4b7fb2d 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -169,12 +169,18 @@ func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) { } var body struct { Feeds []string `json:"feeds"` + // Tiers says how soon each feed's work is worth doing, keyed by URL. Optional and + // separate from the list rather than folded into it, so a phone that knows nothing + // about tiers keeps working unchanged. + Tiers map[string]string `json:"tiers,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"}) return } removed := s.state.SetFeeds(body.Feeds) + // After the list, so a tier for a feed that was just removed is dropped with it. + s.state.SetTiers(body.Tiers) if s.opts.OnSubscriptions != nil { s.opts.OnSubscriptions(s.state.Feeds()) } diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 19e40db..94f7ef4 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -371,3 +371,50 @@ func TestEpisodeResponseCarriesHowManyEpisodesSharedEachBreak(t *testing.T) { t.Errorf("partners %d, want 6", got.Breaks[0].Partners) } } + +/* +Where the phone places a show, and what happens when it does not. + +Tiers arrive beside the list rather than folded into it, so a phone that knows nothing about +them keeps working unchanged -- which every phone did until this week. +*/ + +func TestTakesTheTiersThePhoneSends(t *testing.T) { + s, st, _, _ := newServer(t) + + post(t, s, "/api/subscriptions", + `{"feeds":["https://a","https://b"],"tiers":{"https://a":"topical"}}`) + + if got := st.Tier("https://a"); got != store.TierTopical { + t.Errorf("got %q, want %q", got, store.TierTopical) + } + if got := st.Tier("https://b"); got != store.TierOrdinary { + t.Errorf("got %q, want the default", got) + } +} + +func TestAListWithNoTiersStillWorks(t *testing.T) { + s, st, _, _ := newServer(t) + + rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a"]}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if len(st.Feeds()) != 1 { + t.Errorf("feeds = %v, want the list taken", st.Feeds()) + } +} + +// A tier for a feed the same push removed goes with it, rather than lingering for a +// resubscription months later to inherit. +func TestATierForARemovedFeedIsDropped(t *testing.T) { + s, st, _, _ := newServer(t) + post(t, s, "/api/subscriptions", `{"feeds":["https://a"],"tiers":{"https://a":"archive"}}`) + + post(t, s, "/api/subscriptions", `{"feeds":["https://b"],"tiers":{"https://a":"archive"}}`) + + if got := st.Tier("https://a"); got != store.TierOrdinary { + t.Errorf("got %q, want it dropped with the feed", got) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 931cdd3..f69ff90 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -276,17 +276,22 @@ type State struct { // labels are the listener's own statements about stretches of audio. Not held on the // episodes: an episode leaves the mirror after eight and a label has to outlive it. labels []Label + // tiers is where the phone placed each feed: how soon its work is worth doing. Only the + // feeds placed somewhere other than the default are held, so the map stays the size of + // the exceptions rather than the size of the library. + tiers map[string]string } type persisted struct { - Feeds []string `json:"feeds"` - Shows map[string]*Show `json:"shows"` - Labels []Label `json:"labels,omitempty"` + Feeds []string `json:"feeds"` + Shows map[string]*Show `json:"shows"` + Labels []Label `json:"labels,omitempty"` + Tiers map[string]string `json:"tiers,omitempty"` } // New returns an empty state. func New() *State { - return &State{shows: map[string]*Show{}} + return &State{shows: map[string]*Show{}, tiers: map[string]string{}} } // Load reads state from disk. A missing file is a first run, not an error. @@ -308,6 +313,9 @@ func Load(path string) (*State, error) { if p.Shows != nil { s.shows = p.Shows } + if p.Tiers != nil { + s.tiers = p.Tiers + } return s, nil } @@ -318,7 +326,8 @@ func Load(path string) (*State, error) { // away hours of detection. func (s *State) Save(path string) error { s.mu.RLock() - raw, err := json.MarshalIndent(persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels}, "", " ") + raw, err := json.MarshalIndent( + persisted{Feeds: s.feeds, Shows: s.shows, Labels: s.labels, Tiers: s.tiers}, "", " ") s.mu.RUnlock() if err != nil { return fmt.Errorf("save state: %w", err) @@ -374,6 +383,11 @@ func (s *State) SetFeeds(feeds []string) (removed []string) { removed = append(removed, url) } } + for url := range s.tiers { + if !wanted[url] { + s.forgetTier(url) + } + } sort.Strings(removed) s.feeds = clean return removed diff --git a/server/internal/store/tier.go b/server/internal/store/tier.go new file mode 100644 index 0000000..94d2634 --- /dev/null +++ b/server/internal/store/tier.go @@ -0,0 +1,85 @@ +package store + +import "sort" + +/* +How soon a show's work is worth doing. + +Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +up with, and a long tail that is not topical at all. It matters because recognition costs about +five minutes of CPU an episode, so a five-hour Dan Carlin episode landing the same morning as Up +First takes most of a cycle's budget before the news is touched. + +Three and no more. A number would invite fiddling and would not mean anything; these are the +three distinctions he actually described. +*/ +const ( + // TierTopical is news: worth hearing today and worthless by Thursday. + TierTopical = "topical" + // TierOrdinary is a show kept up with. The default, because it is what most of a library + // is and because a show nobody has placed should not be starved. + TierOrdinary = "ordinary" + // TierArchive is a back catalogue, a slow drip, anything heard whenever. Its work waits + // for the time nothing else wants. + TierArchive = "archive" +) + +// tierRank orders the work. Lower goes first. +var tierRank = map[string]int{TierTopical: 0, TierOrdinary: 1, TierArchive: 2} + +/* +SetTiers records where the phone placed each feed, replacing what was there. + +Only for feeds that are actually subscribed. The phone sends its list and its tiers in one +push, and a tier naming a feed the same push dropped would otherwise outlive it and be +inherited by a resubscription months later. + +A tier this server does not understand is dropped rather than stored: a newer phone inventing a +fourth must not quietly stop a show being polled, which is the rule the label store already +follows for a verdict it does not recognise. +*/ +func (s *State) SetTiers(tiers map[string]string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.tiers == nil { + s.tiers = map[string]string{} + } + subscribed := make(map[string]bool, len(s.feeds)) + for _, url := range s.feeds { + subscribed[url] = true + } + for url, tier := range tiers { + _, known := tierRank[tier] + if !subscribed[url] || !known || tier == TierOrdinary { + delete(s.tiers, url) + continue + } + s.tiers[url] = tier + } +} + +// Tier is where a feed sits, or [TierOrdinary]. +func (s *State) Tier(feedURL string) string { + s.mu.RLock() + defer s.mu.RUnlock() + if tier, ok := s.tiers[feedURL]; ok { + return tier + } + return TierOrdinary +} + +// ByTier orders feed URLs so the topical ones are worked first and the archive last. +// +// Stable, so an ordering within a tier -- newest first, usually -- survives being grouped. A +// sort that reshuffled equals would make the queue unpredictable between cycles for no gain. +func (s *State) ByTier(feedURLs []string) []string { + out := append([]string(nil), feedURLs...) + sort.SliceStable(out, func(i, j int) bool { + return tierRank[s.Tier(out[i])] < tierRank[s.Tier(out[j])] + }) + return out +} + +// forgetTier drops a feed's placement. Called when the feed goes, or a feed resubscribed months +// later would arrive carrying a placement nobody chose. +func (s *State) forgetTier(feedURL string) { delete(s.tiers, feedURL) } diff --git a/server/internal/store/tier_test.go b/server/internal/store/tier_test.go new file mode 100644 index 0000000..971ee51 --- /dev/null +++ b/server/internal/store/tier_test.go @@ -0,0 +1,104 @@ +package store + +import "testing" + +/* +How soon a show's work is worth doing. + +Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps +up with, and a long tail that is not topical at all. Recognition costs about five minutes of CPU +an episode, so a five-hour Dan Carlin episode landing the same morning as Up First takes most of +a cycle's budget before the news is touched. + +Three tiers and no more. A number would invite fiddling and would not mean anything; these are +the three distinctions he actually described. +*/ + +func TestATierIsRememberedForAFeed(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a", "https://b"}) + s.SetTiers(map[string]string{"https://a": TierTopical}) + + if got := s.Tier("https://a"); got != TierTopical { + t.Errorf("got %q, want %q", got, TierTopical) + } +} + +// A show nobody has placed is in the middle: worth keeping up with, not urgent. +func TestAShowWithNoTierIsOrdinary(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want %q", got, TierOrdinary) + } +} + +/* +A tier this server does not understand is ignored rather than stored. + +A newer phone inventing a fourth tier must not quietly stop a show being polled at all -- the +same rule the label store already follows for a verdict it does not recognise. +*/ +func TestAnUnknownTierIsIgnored(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + s.SetTiers(map[string]string{"https://a": "urgent-ish"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want the ordinary tier", got) + } +} + +// Unsubscribing takes the tier with it, or a feed resubscribed months later would arrive +// carrying a placement nobody chose. +func TestForgettingAFeedForgetsItsTier(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://a"}) + s.SetTiers(map[string]string{"https://a": TierTopical}) + s.SetFeeds([]string{"https://b"}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want the tier forgotten with the feed", got) + } +} + +/* +Ordering runs topical first and archive last, and keeps the given order within a tier. + +Stable so that "newest first" survives being grouped: an ordering that reshuffled equals would +make the queue unpredictable from one cycle to the next for no gain. +*/ +func TestWorkIsOrderedByTier(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://news", "https://usual", "https://archive", "https://news2"}) + s.SetTiers(map[string]string{ + "https://news": TierTopical, + "https://news2": TierTopical, + "https://archive": TierArchive, + }) + + got := s.ByTier([]string{"https://usual", "https://archive", "https://news", "https://news2"}) + want := []string{"https://news", "https://news2", "https://usual", "https://archive"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +/* +A tier for a feed that is not subscribed is not kept. + +The phone sends its list and its tiers in one push, so a tier naming a feed the same push +dropped would outlive it and be inherited by a resubscription months later. +*/ +func TestATierForAFeedNotSubscribedIsDropped(t *testing.T) { + s := New() + s.SetFeeds([]string{"https://b"}) + s.SetTiers(map[string]string{"https://a": TierArchive}) + + if got := s.Tier("https://a"); got != TierOrdinary { + t.Errorf("got %q, want nothing kept for an unsubscribed feed", got) + } +} |
