diff options
| -rw-r--r-- | .coverage-baseline | 2 | ||||
| -rw-r--r-- | app/schemas/org.terst.listen.data.AppDatabase/5.json | 499 | ||||
| -rw-r--r-- | app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt | 131 | ||||
| -rw-r--r-- | app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt | 49 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/AppDatabase.kt | 40 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/data/Daos.kt | 11 |
6 files changed, 726 insertions, 6 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index dc094ea..92ef7f2 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -65.20 +64.90 diff --git a/app/schemas/org.terst.listen.data.AppDatabase/5.json b/app/schemas/org.terst.listen.data.AppDatabase/5.json new file mode 100644 index 0000000..4e932c8 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/5.json @@ -0,0 +1,499 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "4a70b3102d3822eabaf209d5a446fc40", + "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, 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" + } + ], + "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, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `chaptersJson` TEXT, 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": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "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": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + } + ], + "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`)" + } + ] + } + ], + "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, '4a70b3102d3822eabaf209d5a446fc40')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt new file mode 100644 index 0000000..a181dd7 --- /dev/null +++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt @@ -0,0 +1,131 @@ +package org.terst.listen.data + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The order shows appear in on the library screen. + * + * Instrumented because the ordering is SQL: the fake DAOs return insertion order, so this is + * exactly the kind of rule that cannot be pinned off-device. + * + * A show ranks by its newest episode that is still waiting for you -- unplayed *and* unqueued. + * Peter's words: once they're on the queue they're noise in the list. + */ +@RunWith(AndroidJUnit4::class) +class LibraryOrderTest { + + private lateinit var db: AppDatabase + private lateinit var subscriptions: SubscriptionDao + private lateinit var episodes: EpisodeDao + private lateinit var queue: QueueDao + + private val day = 24 * 60 * 60 * 1000L + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).build() + subscriptions = db.subscriptions() + episodes = db.episodes() + queue = db.queue() + } + + @After + fun tearDown() = db.close() + + private suspend fun show(id: String) = subscriptions.insert( + SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0) + ) + + private suspend fun episode(id: String, showId: String, publishedAtMs: Long) = + episodes.insertAll( + listOf( + EpisodeEntity( + id = id, + subscriptionId = showId, + guid = id, + title = id, + mediaUrl = "https://cdn/$id.mp3", + mediaType = "audio/mpeg", + publishedAtMs = publishedAtMs, + discoveredAtMs = 0, + ) + ) + ) + + private suspend fun order() = subscriptions.observeAll().first().map { it.id } + + @Test + fun ranksByTheNewestEpisodeStillWaiting() = runBlocking { + show("older") + show("newer") + episode("e1", "older", 1 * day) + episode("e2", "newer", 5 * day) + + assertEquals(listOf("newer", "older"), order()) + } + + /** + * The change Peter asked for. Queueing a show's newest episode deals with it, so the show + * should stop being pulled to the top by something already waiting in Up Next. + */ + @Test + fun aQueuedEpisodeStopsPullingItsShowUp() = runBlocking { + show("older") + show("newer") + episode("e1", "older", 1 * day) + episode("e2", "newer", 5 * day) + assertEquals(listOf("newer", "older"), order()) + + queue.replaceAll(listOf("e2")) + + assertEquals("the queued show has nothing left waiting", listOf("older", "newer"), order()) + } + + /** An older unqueued episode still counts -- the show is ranked by what remains. */ + @Test + fun anOlderUnqueuedEpisodeStillCounts() = runBlocking { + show("a") + show("b") + episode("a-new", "a", 9 * day) + episode("a-old", "a", 2 * day) + episode("b-mid", "b", 4 * day) + + queue.replaceAll(listOf("a-new")) + + assertEquals("a still has a 2-day-old episode, b has a 4-day-old one", listOf("b", "a"), order()) + } + + /** Played episodes were already excluded; queued ones join them. */ + @Test + fun playedEpisodesStillDoNotCount() = runBlocking { + show("a") + show("b") + episode("a-new", "a", 9 * day) + episode("b-mid", "b", 4 * day) + episodes.markPlayed("a-new", 1) + + assertEquals(listOf("b", "a"), order()) + } + + /** A show with nothing waiting sorts last, by title, rather than vanishing. */ + @Test + fun aShowWithNothingWaitingSortsLast() = runBlocking { + show("empty") + show("active") + episode("e1", "active", 3 * day) + + assertEquals(listOf("active", "empty"), order()) + } +} 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 0eeab50..181e853 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -148,4 +148,53 @@ class MigrationTest { assertEquals(true, cursor.isNull(1)) } } + + /** + * The trailer and preview filters arrive as ordinary rules, after anything already there. + * + * Taking position 0 would let them pre-empt rules already being relied on, which is not a + * thing an upgrade should do quietly. + */ + @Test + fun migrates4To5SeedingFiltersAfterExistingRules() { + helper.createDatabase(name, 4).use { db -> + db.execSQL( + """INSERT INTO rules (id, name, position, enabled, action, autoDownload) + VALUES ('mine', 'My rule', 0, 1, 'QUEUE_BOTTOM', 0)""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 5, true, AppDatabase.MIGRATION_4_5) + + db.query("SELECT id, titleContains, action, enabled FROM rules ORDER BY position").use { cursor -> + assertEquals(3, cursor.count) + cursor.moveToFirst() + assertEquals("the listener's own rule keeps its place at the top", "mine", cursor.getString(0)) + cursor.moveToNext() + assertEquals("listen-default-trailer", cursor.getString(0)) + assertEquals("trailer", cursor.getString(1)) + assertEquals("MARK_PLAYED", cursor.getString(2)) + assertEquals(1, cursor.getInt(3)) + cursor.moveToNext() + assertEquals("listen-default-preview", cursor.getString(0)) + assertEquals("preview", cursor.getString(1)) + } + } + + /** A fresh install migrating the whole way still ends up with exactly the two filters. */ + @Test + fun migrates1To5SeedingExactlyTwoFilters() { + helper.createDatabase(name, 1).close() + + val db = helper.runMigrationsAndValidate( + name, 5, true, + AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, + AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, + ) + + db.query("SELECT COUNT(*) FROM rules").use { cursor -> + cursor.moveToFirst() + assertEquals(2, cursor.getInt(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 7897678..c33921b 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -25,7 +25,7 @@ class Converters { QueueEntity::class, RuleEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) @TypeConverters(Converters::class) @@ -98,6 +98,42 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Seeds the trailer and preview filters Peter asked for. + * + * Rules live in the database on the phone, so a migration is the only way to put them + * there. They are ordinary rules: visible in the Rules screen, and editable, disableable + * or deletable like any other. Appended after whatever is already there rather than + * taking position 0, so they cannot pre-empt rules already being relied on, and they + * only act on newly discovered episodes -- nothing already in the library changes. + * + * INSERT OR IGNORE on a fixed id, so deleting one does not bring it back on the next + * upgrade. + */ + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + seedFilter(db, "listen-default-trailer", "Trailers", "trailer") + seedFilter(db, "listen-default-preview", "Previews", "preview") + } + + private fun seedFilter( + db: SupportSQLiteDatabase, + id: String, + name: String, + word: String, + ) { + db.execSQL( + "INSERT OR IGNORE INTO rules " + + "(id, name, position, enabled, subscriptionId, titleContains, " + + "titleExcludes, minDurationMs, maxDurationMs, maxAgeMs, seasonNumber, " + + "action, autoDownload, speed) " + + "SELECT ?, ?, COALESCE(MAX(position), -1) + 1, 1, NULL, ?, NULL, " + + "NULL, NULL, NULL, NULL, 'MARK_PLAYED', 0, NULL FROM rules", + arrayOf(id, name, word), + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -106,7 +142,7 @@ abstract class AppDatabase : RoomDatabase() { Room.databaseBuilder(context, AppDatabase::class.java, "listen.db") // Listed one by one rather than spread from an array: every migration here is a // deliberate addition, and the list is the record of them. - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) // No fallbackToDestructiveMigration. A listener's playback positions and play // history are not reproducible from anywhere, so a missing migration must fail // loudly rather than silently wipe the library. 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 608cf77..abc7ea3 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -11,17 +11,22 @@ import kotlinx.coroutines.flow.Flow @Dao interface SubscriptionDao { /** - * Newest *unplayed* episode first. + * Newest episode *still waiting* first -- unplayed, and not already queued. * * Not newest episode: a show you are caught up on would sit at the top having nothing for * you, which is the opposite of useful. Sorting on what is still unheard puts whatever * actually wants listening to at the top, and drops a finished show down the list until it - * publishes again. Shows with nothing unplayed sort last, which is the COALESCE to 0. + * publishes again. Shows with nothing waiting sort last, which is the COALESCE to 0. + * + * Queued episodes are excluded for the same reason played ones are: they have been dealt + * with. Peter's words -- once they're on the queue they're noise in the list. */ @Query( """SELECT s.* FROM subscriptions s LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest - FROM episodes WHERE playedAtMs IS NULL GROUP BY subscriptionId) e + FROM episodes + WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue) + GROUP BY subscriptionId) e ON e.subscriptionId = s.id ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE""" ) |
