summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt184
-rw-r--r--app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt41
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt2
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt84
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt5
-rw-r--r--app/src/main/java/org/terst/listen/playback/CarLists.kt57
-rw-r--r--app/src/main/java/org/terst/listen/playback/LibraryTree.kt24
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt46
-rw-r--r--app/src/test/java/org/terst/listen/playback/CarListsTest.kt53
10 files changed, 492 insertions, 6 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 8b8ff10..c20f199 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.19
+94.21
diff --git a/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt
new file mode 100644
index 0000000..76e1177
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/CarListsDbTest.kt
@@ -0,0 +1,184 @@
+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.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.terst.listen.playback.CarLists
+
+/**
+ * Android Auto's three lists against the real tables.
+ *
+ * The conditions that decide what a driver is offered live in SQL -- they have to, because
+ * "unplayed across every show" is thousands of rows on a real library -- so this is where they
+ * are checked. The unit tests cover which lists appear; these cover what is in them.
+ */
+@RunWith(AndroidJUnit4::class)
+class CarListsDbTest {
+
+ private lateinit var db: AppDatabase
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ AppDatabase::class.java,
+ ).build()
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ private suspend fun show(
+ id: String,
+ bedtime: Boolean = false,
+ drip: Boolean = false,
+ ) = db.subscriptions().insert(
+ SubscriptionEntity(
+ id = id, feedUrl = "https://x/$id", title = "Show $id", addedAtMs = 0,
+ bedtime = bedtime, dripEnabled = drip,
+ )
+ )
+
+ private suspend fun episode(
+ id: String,
+ showId: String = "s1",
+ positionMs: Long = 0,
+ playedAtMs: Long? = null,
+ durationMs: Long? = MINUTES_10,
+ actualDurationMs: Long? = null,
+ publishedAtMs: Long? = 1_000,
+ dripReleasedAtMs: Long? = null,
+ ) = db.episodes().insertAll(
+ listOf(
+ EpisodeEntity(
+ id = id, subscriptionId = showId, guid = id, title = "Episode $id",
+ mediaUrl = "https://x/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0,
+ positionMs = positionMs, playedAtMs = playedAtMs, durationMs = durationMs,
+ actualDurationMs = actualDurationMs, publishedAtMs = publishedAtMs,
+ dripReleasedAtMs = dripReleasedAtMs,
+ )
+ )
+ )
+
+ private suspend fun ids(of: List<EpisodeEntity>) = of.map { it.id }
+
+ @Test
+ fun continueHoldsWhatWasStartedAndNotFinished() = runBlocking {
+ show("s1")
+ episode("started", positionMs = 30_000)
+ episode("untouched")
+ episode("finished", positionMs = 30_000, playedAtMs = 5_000)
+
+ assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST)))
+ }
+
+ /**
+ * The episode you are in the middle of is normally the head of the queue, so the exclusion
+ * the other two lists apply would empty this one in the ordinary case.
+ */
+ @Test
+ fun continueStillOffersAnEpisodeThatIsAlsoQueued() = runBlocking {
+ show("s1")
+ episode("started", positionMs = 30_000)
+ db.queue().upsertAll(listOf(QueueEntity("started", 0)))
+
+ assertEquals(listOf("started"), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST)))
+ }
+
+ /**
+ * The case the two duration columns exist for: Radiolab declares 22:05 and serves 28:18, so
+ * trusting the feed would put a half-hour episode in a list promising twenty minutes.
+ */
+ @Test
+ fun shortBelievesTheMeasuredLengthOverTheFeedsClaim() = runBlocking {
+ show("s1")
+ episode("overrun", durationMs = MINUTES_19, actualDurationMs = MINUTES_28)
+ episode("honest", durationMs = MINUTES_19, actualDurationMs = MINUTES_19)
+
+ val short = db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)
+
+ assertEquals(listOf("honest"), ids(short))
+ }
+
+ @Test
+ fun shortLeavesOutAnEpisodeOfUnknownLength() = runBlocking {
+ show("s1")
+ episode("nameless", durationMs = null)
+ episode("known", durationMs = MINUTES_10)
+
+ assertEquals(
+ listOf("known"),
+ ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)),
+ )
+ }
+
+ @Test
+ fun shortAndNewLeaveOutAnythingAlreadyStarted() = runBlocking {
+ show("s1")
+ episode("started", positionMs = 30_000)
+ episode("fresh")
+
+ assertEquals(
+ listOf("fresh"),
+ ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)),
+ )
+ assertEquals(listOf("fresh"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST)))
+ }
+
+ @Test
+ fun newLeavesOutWhatIsAlreadyQueued() = runBlocking {
+ show("s1")
+ episode("queued")
+ episode("loose")
+ db.queue().upsertAll(listOf(QueueEntity("queued", 0)))
+
+ assertEquals(listOf("loose"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST)))
+ }
+
+ /** Driving is the opposite of falling asleep, which is why the car hides bedtime shows. */
+ @Test
+ fun noListOffersABedtimeShow() = runBlocking {
+ show("bed", bedtime = true)
+ episode("sleepy", showId = "bed", positionMs = 30_000)
+ episode("alsoSleepy", showId = "bed")
+
+ assertEquals(emptyList<String>(), ids(db.carLists().getContinuing(CarLists.MAX_PER_LIST)))
+ assertEquals(
+ emptyList<String>(),
+ ids(db.carLists().getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)),
+ )
+ assertEquals(emptyList<String>(), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST)))
+ }
+
+ @Test
+ fun aDrippingShowOffersOnlyWhatDripHasReleased() = runBlocking {
+ show("drip", drip = true)
+ episode("released", showId = "drip", dripReleasedAtMs = 500)
+ episode("held", showId = "drip", dripReleasedAtMs = null)
+
+ assertEquals(listOf("released"), ids(db.carLists().getNewest(CarLists.MAX_PER_LIST)))
+ }
+
+ @Test
+ fun newIsNewestFirstAndCapped() = runBlocking {
+ show("s1")
+ episode("old", publishedAtMs = 1_000)
+ episode("newest", publishedAtMs = 3_000)
+ episode("middle", publishedAtMs = 2_000)
+
+ assertEquals(listOf("newest", "middle", "old"), ids(db.carLists().getNewest(10)))
+ assertEquals(listOf("newest"), ids(db.carLists().getNewest(1)))
+ }
+
+ private companion object {
+ const val MINUTES_10 = 10 * 60 * 1000L
+ const val MINUTES_19 = 19 * 60 * 1000L
+ const val MINUTES_28 = 28 * 60 * 1000L
+ }
+}
diff --git a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
index f452c9f..61ffe6a 100644
--- a/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/playback/MediaBrowseTest.kt
@@ -172,6 +172,47 @@ class MediaBrowseTest {
}
}
+ /**
+ * The root offers "Continue", and it holds the part-heard episode.
+ *
+ * Peter's report was that discovery in the car was bad: browsing by show means remembering
+ * what you were doing and then finding it, which is not a thing to do at a traffic light.
+ * This walks the real protocol rather than calling the callback, because the root is
+ * assembled from three queries and a browse id has to survive the round trip -- the same
+ * seam that made "Up Next" show something other than the queue.
+ */
+ @Test
+ fun theRootOffersContinueAndItHoldsThePartHeardEpisode() = runBlocking {
+ val graph = Graph.get(context)
+ graph.subscriptions.insert(seedShow("browse-cont", "Continue Hour"))
+ graph.episodes.insertAll(
+ listOf(seedEpisode("browse-cont-ep", "browse-cont", 1_000).copy(positionMs = 42_000))
+ )
+
+ val browser = connect()
+ try {
+ withContext(Dispatchers.Main) {
+ val continueId = LibraryTree.carListId(CarLists.Node.CONTINUE)
+ val topIds = browser.getChildren(LibraryTree.ROOT, 0, 20, null)
+ .await().value!!.map { it.mediaId }
+ assertTrue(
+ "root should offer Continue, got $topIds",
+ topIds.contains(continueId),
+ )
+
+ val held = browser.getChildren(continueId, 0, CarLists.MAX_PER_LIST, null)
+ .await().value!!.map { it.mediaId }
+ assertTrue(
+ "Continue should hold the part-heard episode, got $held",
+ held.contains(LibraryTree.episode(seedEpisode("browse-cont-ep", "browse-cont", 1_000)).mediaId),
+ )
+ }
+ } finally {
+ withContext(Dispatchers.Main) { browser.release() }
+ graph.subscriptions.delete("browse-cont")
+ }
+ }
+
private fun seedShow(id: String, title: String) = SubscriptionEntity(
id = id,
feedUrl = "https://example.invalid/$id",
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 0ba2e82..5c6f460 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -41,6 +41,8 @@ class Converters {
abstract class AppDatabase : RoomDatabase() {
abstract fun subscriptions(): SubscriptionDao
abstract fun episodes(): EpisodeDao
+ abstract fun carLists(): CarListDao
+
abstract fun playbackState(): PlaybackStateDao
abstract fun queue(): QueueDao
abstract fun rules(): RuleDao
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 2dda8de..b6c82bd 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -478,6 +478,90 @@ interface MidrollDao {
* collaborator, [org.terst.listen.playback.PositionWriter], on a timer, and they are the only
* writes in the app whose loss is not recoverable from anywhere else.
*/
+/**
+ * The cross-show reads that build Android Auto's three lists, and nothing else.
+ *
+ * Its own interface rather than three more methods on [EpisodeDao], which is already the widest
+ * one in the app: these are read-only, they all cross every show rather than belonging to one,
+ * and they exist for a single surface. Narrow by the same reasoning as [PlaybackStateDao].
+ *
+ * All three share the conditions the phone's library calls "waiting" -- unplayed, released if
+ * the show drips, and never a bedtime show, because driving is the opposite of falling asleep.
+ */
+@Dao
+interface CarListDao {
+
+ /**
+ * Part-heard episodes, for the car's "Continue".
+ *
+ * `positionMs > 0` is the whole condition: the app has no last-played timestamp, so what
+ * makes an episode interesting here is that it was started and not finished, and the order
+ * has to come from the publish date like everything else. That is a real gap rather than a
+ * choice -- the ideal order is most-recently-listened -- but it costs a column and a
+ * migration, and with a handful of part-heard episodes the order barely matters.
+ *
+ * Queued episodes are deliberately *not* excluded here, unlike the three lists below and
+ * the phone's library: the episode you are in the middle of is normally the head of the
+ * queue, so excluding it would empty this list in the ordinary case.
+ */
+ @Query(
+ """SELECT ep.* FROM episodes ep
+ JOIN subscriptions owner ON owner.id = ep.subscriptionId
+ WHERE ep.playedAtMs IS NULL
+ AND ep.positionMs > 0
+ AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL)
+ AND owner.bedtime = 0
+ ORDER BY ep.publishedAtMs DESC
+ LIMIT :limit"""
+ )
+ suspend fun getContinuing(limit: Int): List<EpisodeEntity>
+
+ /**
+ * Unstarted episodes short enough for a short drive, for the car's "Short".
+ *
+ * `COALESCE(actualDurationMs, durationMs)` is `playbackDurationMs` in SQL, and the
+ * preference matters more here than almost anywhere: a feed declaring 22:05 for a file that
+ * runs 28:18 would put a half-hour episode in a list promising twenty minutes. An episode
+ * whose length is unknown is left out -- unknown is not short, and guessing wrong is the
+ * failure this list exists to avoid.
+ */
+ @Query(
+ """SELECT ep.* FROM episodes ep
+ JOIN subscriptions owner ON owner.id = ep.subscriptionId
+ WHERE ep.playedAtMs IS NULL
+ AND ep.positionMs = 0
+ AND ep.id NOT IN (SELECT episodeId FROM queue)
+ AND COALESCE(ep.actualDurationMs, ep.durationMs) IS NOT NULL
+ AND COALESCE(ep.actualDurationMs, ep.durationMs) <= :maxDurationMs
+ AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL)
+ AND owner.bedtime = 0
+ ORDER BY ep.publishedAtMs DESC
+ LIMIT :limit"""
+ )
+ suspend fun getShort(maxDurationMs: Long, limit: Int): List<EpisodeEntity>
+
+ /**
+ * The newest unstarted episodes across every show, for the car's "New".
+ *
+ * The same set the phone's library counts as waiting -- unplayed, unqueued, released if the
+ * show drips -- flattened across shows instead of grouped by one. Anything started is left
+ * to `getContinuing`, which is what keeps the two lists from offering the same episode
+ * under two names.
+ */
+ @Query(
+ """SELECT ep.* FROM episodes ep
+ JOIN subscriptions owner ON owner.id = ep.subscriptionId
+ WHERE ep.playedAtMs IS NULL
+ AND ep.positionMs = 0
+ AND ep.id NOT IN (SELECT episodeId FROM queue)
+ AND (owner.dripEnabled = 0 OR ep.dripReleasedAtMs IS NOT NULL)
+ AND owner.bedtime = 0
+ ORDER BY ep.publishedAtMs DESC
+ LIMIT :limit"""
+ )
+ suspend fun getNewest(limit: Int): List<EpisodeEntity>
+}
+
@Dao
interface PlaybackStateDao {
@Query("UPDATE episodes SET positionMs = :positionMs WHERE id = :id")
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 e4a35f0..f08c484 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -104,6 +104,11 @@ class AppGraph(private val context: Context) : UiGraph {
override val subscriptions: SubscriptionDao get() = db.subscriptions()
override val episodes: EpisodeDao get() = db.episodes()
+ /**
+ * Deliberately not on [UiGraph]: the three cross-show lists exist for Android Auto, and no
+ * view model has any use for them.
+ */
+ val carLists: CarListDao get() = db.carLists()
override val playbackState: PlaybackStateDao get() = db.playbackState()
val queueDao: QueueDao get() = db.queue()
override val ruleDao: RuleDao get() = db.rules()
diff --git a/app/src/main/java/org/terst/listen/playback/CarLists.kt b/app/src/main/java/org/terst/listen/playback/CarLists.kt
new file mode 100644
index 0000000..6e01be4
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/CarLists.kt
@@ -0,0 +1,57 @@
+package org.terst.listen.playback
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * The three lists the car offers above the shows.
+ *
+ * Browsing by show is wrong for driving: Peter's report was "discovery sucks" and "scrolling
+ * the lists in the car is bad experience", and the cause is that a show list makes you remember
+ * what you were doing and then find it. These three answer the question actually being asked at
+ * a traffic light -- what was I in the middle of, what fits this drive, what is new -- from data
+ * the app already keeps, with no new column and no migration.
+ *
+ * They are deliberately disjoint from each other on one axis: anything started appears in
+ * [CONTINUE] and nowhere else, so finishing something is never buried under a newer arrival.
+ */
+object CarLists {
+
+ /**
+ * The longest episode [SHORT] will offer, 20 minutes.
+ *
+ * Peter's call, asked rather than guessed: it catches the daily news and the shorter
+ * curiosity episodes and leaves out conversation and deep-dive shows. The number is a
+ * judgement about his driving, not about the data, which is why it is pinned by a test --
+ * changing it silently changes what the car offers.
+ */
+ val SHORT_MAX_MS: Long = TimeUnit.MINUTES.toMillis(20)
+
+ /**
+ * How many episodes any one of these lists will hold.
+ *
+ * Smaller than the per-show cap by an order of magnitude, and for the opposite reason: that
+ * one exists so the browser protocol is not handed 2,753 rows, this one exists because a
+ * list you have to scroll in a car has already failed. Peter measured the screen at 6-8
+ * rows with icons, so twenty is about three flicks -- enough that the list is not obviously
+ * truncated, few enough that the answer is near the top.
+ */
+ const val MAX_PER_LIST = 20
+
+ /** One of the three lists, in the order the car shows them. */
+ enum class Node(val wire: String, val label: String) {
+ CONTINUE("continue", "Continue"),
+ SHORT("short", "Short"),
+ NEW("new", "New"),
+ }
+
+ /**
+ * Which of the lists to put on the root, given how many episodes each holds.
+ *
+ * An empty list is left off entirely rather than shown and found empty. Tapping a folder in
+ * a car costs a glance away from the road, and spending that to be told "nothing here" is
+ * the same dead end as a show row with nothing waiting -- which the phone's library already
+ * dims for the same reason.
+ */
+ fun offered(counts: Map<Node, Int>): List<Node> =
+ Node.entries.filter { (counts[it] ?: 0) > 0 }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
index 371dd1c..d4acdf2 100644
--- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
+++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
@@ -22,11 +22,19 @@ object LibraryTree {
const val QUEUE = "listen:queue"
const val SUBSCRIPTIONS = "listen:subscriptions"
private const val SUBSCRIPTION_PREFIX = "listen:subscription:"
+ private const val LIST_PREFIX = "listen:list:"
private const val EPISODE_PREFIX = "listen:episode:"
fun subscriptionId(mediaId: String): String? =
mediaId.removePrefix(SUBSCRIPTION_PREFIX).takeIf { mediaId.startsWith(SUBSCRIPTION_PREFIX) }
+ /** The browse id for one of the car's lists. */
+ fun carListId(node: CarLists.Node): String = LIST_PREFIX + node.wire
+
+ /** The car list a browse id names, or null if it names something else. */
+ fun carList(mediaId: String): CarLists.Node? = CarLists.Node.entries
+ .firstOrNull { mediaId == carListId(it) }
+
fun episodeId(mediaId: String): String? =
mediaId.removePrefix(EPISODE_PREFIX).takeIf { mediaId.startsWith(EPISODE_PREFIX) }
@@ -42,10 +50,18 @@ object LibraryTree {
fun episodeRef(episodeId: String): MediaItem =
MediaItem.Builder().setMediaId(EPISODE_PREFIX + episodeId).build()
- fun topLevel(): List<MediaItem> = listOf(
- browsable(QUEUE, "Up Next"),
- browsable(SUBSCRIPTIONS, "Podcasts"),
- )
+ /**
+ * The root, with whichever of the car lists currently hold something.
+ *
+ * Up Next stays first -- in a car it is nearly always what you want, and it is the one list
+ * the listener curated rather than the app inferred. [CarLists.offered] decides which of
+ * Continue, Short and New appear at all, so the root never offers a folder that turns out
+ * to be empty.
+ */
+ fun topLevel(lists: List<CarLists.Node> = emptyList()): List<MediaItem> =
+ listOf(browsable(QUEUE, "Up Next")) +
+ lists.map { browsable(carListId(it), it.label) } +
+ browsable(SUBSCRIPTIONS, "Podcasts")
fun subscription(entity: SubscriptionEntity): MediaItem =
browsable(SUBSCRIPTION_PREFIX + entity.id, entity.title, entity.author, entity.imageUrl)
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
index 7a02ba6..e9e8ca7 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
+import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.server.ServerAnswer
@@ -112,7 +113,7 @@ class ListenSessionCallback(
params: MediaLibraryService.LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
val children = when {
- parentId == LibraryTree.ROOT -> LibraryTree.topLevel()
+ parentId == LibraryTree.ROOT -> LibraryTree.topLevel(offeredCarLists())
parentId == LibraryTree.QUEUE ->
graph.queueDao.getEntries().mapNotNull { entry ->
@@ -131,6 +132,9 @@ class ListenSessionCallback(
graph.subscriptions.observeAll(bedtime = false).first()
.map { LibraryTree.subscription(it.subscription) }
+ LibraryTree.carList(parentId) != null ->
+ withShows(carListEpisodes(LibraryTree.carList(parentId)!!))
+
LibraryTree.subscriptionId(parentId) != null -> {
val id = LibraryTree.subscriptionId(parentId)!!
val show = graph.subscriptions.getById(id)
@@ -313,6 +317,46 @@ class ListenSessionCallback(
return (showHits.map(LibraryTree::subscription) + episodeHits).take(MAX_SEARCH_RESULTS)
}
+ /**
+ * The episodes one of the car's lists holds, newest first and capped.
+ *
+ * Each list is its own query rather than one broad fetch filtered here: unplayed across
+ * every show is thousands of rows on a real library -- one feed in testing produced 2,753
+ * on a single refresh -- and all three of these want a different slice of it.
+ */
+ private suspend fun carListEpisodes(node: CarLists.Node) = when (node) {
+ CarLists.Node.CONTINUE -> graph.carLists.getContinuing(CarLists.MAX_PER_LIST)
+ CarLists.Node.SHORT -> graph.carLists.getShort(CarLists.SHORT_MAX_MS, CarLists.MAX_PER_LIST)
+ CarLists.Node.NEW -> graph.carLists.getNewest(CarLists.MAX_PER_LIST)
+ }
+
+ /**
+ * Which car lists the root should offer, by asking each one what it holds.
+ *
+ * The counts come from the lists themselves rather than from three `COUNT` queries, so the
+ * root can never advertise a list that turns out to be empty when opened -- the two would
+ * otherwise be free to drift apart as the conditions change. Each is capped at twenty rows,
+ * so this is three small queries on a browse of the root.
+ */
+ private suspend fun offeredCarLists(): List<CarLists.Node> = CarLists.offered(
+ CarLists.Node.entries.associateWith { carListEpisodes(it).size }
+ )
+
+ /**
+ * Episodes as browsable items, each carrying its show's name and artwork.
+ *
+ * One pass over the subscriptions rather than a lookup per episode: these lists cross every
+ * show by definition, so the per-episode query the queue gets away with would be twenty
+ * round trips here.
+ */
+ private suspend fun withShows(episodes: List<EpisodeEntity>): List<MediaItem> {
+ val shows = graph.subscriptions.getAll().associateBy { it.id }
+ return episodes.map { episode ->
+ val show = shows[episode.subscriptionId]
+ LibraryTree.episode(episode, show?.title, show?.imageUrl)
+ }
+ }
+
private companion object {
const val MAX_EPISODES = 100
const val MAX_EPISODE_HITS_PER_SHOW = 5
diff --git a/app/src/test/java/org/terst/listen/playback/CarListsTest.kt b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt
new file mode 100644
index 0000000..f64b334
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/CarListsTest.kt
@@ -0,0 +1,53 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.listen.playback.CarLists.Node
+
+class CarListsTest {
+
+ @Test
+ fun offersEveryListThatHasSomethingInIt() {
+ val offered = CarLists.offered(mapOf(Node.CONTINUE to 2, Node.SHORT to 7, Node.NEW to 31))
+
+ assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered)
+ }
+
+ @Test
+ fun leavesOutAnEmptyList() {
+ val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 3, Node.NEW to 5))
+
+ assertEquals(listOf(Node.SHORT, Node.NEW), offered)
+ }
+
+ @Test
+ fun treatsAnAbsentCountAsEmpty() {
+ val offered = CarLists.offered(mapOf(Node.NEW to 4))
+
+ assertEquals(listOf(Node.NEW), offered)
+ }
+
+ @Test
+ fun offersNothingWhenEveryListIsEmpty() {
+ val offered = CarLists.offered(mapOf(Node.CONTINUE to 0, Node.SHORT to 0, Node.NEW to 0))
+
+ assertEquals(emptyList<Node>(), offered)
+ }
+
+ @Test
+ fun keepsTheOrderTheCarShowsRatherThanTheCallersOrder() {
+ val offered = CarLists.offered(mapOf(Node.NEW to 1, Node.CONTINUE to 1, Node.SHORT to 1))
+
+ assertEquals(listOf(Node.CONTINUE, Node.SHORT, Node.NEW), offered)
+ }
+
+ /**
+ * Pins the cutoff Peter chose. Not a tautology: it is the one number here that is a
+ * judgement about his driving rather than about the data, and moving it changes what the
+ * car offers on a short trip without changing anything that would otherwise fail.
+ */
+ @Test
+ fun offersShortEpisodesUpToTwentyMinutes() {
+ assertEquals(20 * 60 * 1000L, CarLists.SHORT_MAX_MS)
+ }
+}