diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 08:56:20 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 08:56:20 +0000 |
| commit | d4b00e59086e1afc69464164a907bcfddd560a50 (patch) | |
| tree | d06e2a142e266b41b8b50f4c26bb7f370747ea43 | |
| parent | d0cc4dbdcbe2b339384f7915c45bc80abf8373a8 (diff) | |
Rank shows by what is still waiting, and seed the trailer filters
Two of Peter's asks.
Ordering. A show ranked by its newest unplayed episode, so queueing something
left the show sitting at the top with nothing actually waiting -- his words,
once they're on the queue they're noise in the list. Queued episodes are now
excluded from the ranking for the same reason played ones already were: they
have been dealt with.
That is one clause of SQL, and the fake DAOs return insertion order, so it is
exactly the kind of rule that cannot be pinned off-device. LibraryOrderTest
runs against a real in-memory Room database and covers the change, an older
unqueued episode still counting, played episodes still not counting, and a show
with nothing waiting sorting last rather than vanishing. Confirmed it fails
without the fix before keeping it.
Filters. Peter chose the existing rule engine over anything new, with the rules
set up for him -- and rules live in the database on the phone, so a migration is
the only way to put them there. MIGRATION_4_5 seeds "trailer" and "preview",
both mark-played, as ordinary rules: they show up in the Rules screen and can be
edited, disabled or deleted. Appended after whatever is already there rather
than taking position 0, so they cannot pre-empt rules already relied on, and
INSERT OR IGNORE on a fixed id means deleting one does not bring it back on the
next upgrade. They act only on newly discovered episodes, so nothing already in
the library changes.
One judgement call worth flagging: "preview" will catch a sports show's
legitimate Season preview. It is two taps to delete if it bites.
Schema 4 -> 5, no shape change -- the migration only inserts rows. Covered by
two instrumented tests: seeding after an existing rule, and a 1 -> 5 chain
landing exactly two.
No permission changes. 403 unit tests green, 10 instrumented, detekt and lint
clean. Coverage 65.20 -> 64.90, exception recorded: the new code is a migration
tested on a device, and the coverage report counts unit tests only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
| -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""" ) |
