bound the downloads handed to the platform, and confirm bulk deletes

Two things a 3k-song playlist turned up, plus the hero's downloaded state.

- The platform runs about eight downloads at a time, but a bulk action handed it
  every song at once. Three thousand pending requests meant a notification flood,
  and a poll tick that rewrote every row's bytes/total, so the whole list churned
  ten times a second. Now a bulk action only records the songs and hands over at
  most IN_FLIGHT_LIMIT, oldest first, promoting the next as each finishes, so our
  row state mirrors what is actually running and a cancel only has to reach what
  is in flight.
- Cancelling was therefore unusable at that size. It now touches at most the
  in-flight requests instead of thousands, and the rows wait behind it.
- A queued row with no engine id is waiting its turn, not lost, so reconcile no
  longer re-enqueues the whole list on every pass; a row whose engine entry
  vanished goes back to waiting and the next promote picks it up. A song that
  cannot be handed over fails instead of being retried every tick.
- Requests follow the order the screen shows: artist songs resolve through the
  same albums ordering the artist page uses, so they were the one list that did
  not.
- The hero's fully-downloaded state shows the downloaded glyph rather than a bin,
  and tapping it asks before deleting, naming the album or playlist and the space
  it frees.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent 17c1ea40a4
commit 4652430f73
11 changed files with 324 additions and 73 deletions
@@ -560,11 +560,10 @@ class DownloadRepositoryTest {
fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() =
runTest {
seedLibrary()
seedSongs(3..40)
engine.enqueueDelayMs = 1
seedSongs(3..400)
val queuing = launch(Dispatchers.IO) { repository.downloadAll(1, DownloadList.Album, "al1") }
await { engine.requests.size >= 3 }
await { allRows().size >= 50 }
val cancelling = launch(Dispatchers.IO) { repository.cancelAll(1, DownloadList.Album, "al1") }
queuing.join()
cancelling.join()
@@ -572,6 +571,50 @@ class DownloadRepositoryTest {
assertTrue("expected the whole list cancelled, ${db.downloadDao().all().size} rows left", db.downloadDao().all().isEmpty())
}
@Test
fun aLongListHandsThePlatformOnlyTheInFlightLimit() =
runTest {
seedLibrary()
seedSongs(3..40)
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
assertEquals(8, engine.requests.size)
assertEquals(32, db.downloadDao().all().count { it.status == DownloadStatus.Queued && it.engineId == null })
}
@Test
fun finishingADownloadHandsOverTheNextSongInListOrder() =
runTest {
seedLibrary()
seedSongs(3..12)
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
val first = engine.requests.first()
engine.complete(first.first, total = 10)
writeFile(1, first.second.path.substringAfterLast('/'), "audio")
runBlocking { repository.reconcile() }
assertEquals(9, engine.requests.size)
assertEquals((1..9).map { "s$it" }, requestedSongIds())
}
@Test
fun reconcilingAWaitingListDoesNotHandItOverWholesale() =
runTest {
seedLibrary()
seedSongs(3..40)
runBlocking {
(1..40).forEach { track ->
db.downloadDao().upsert(SongDownload(1, "s$track", DownloadStatus.Queued))
}
}
runBlocking { repository.reconcile() }
assertEquals(8, engine.requests.size)
}
@Test
fun oneSongFailingToQueueDoesNotAbandonTheRest() =
runTest {
@@ -613,6 +656,19 @@ class DownloadRepositoryTest {
assertNull(row(1, "s2"))
}
@Test
fun downloadedBytesCountsTheCompletedFilesOfTheList() =
runTest {
seedLibrary()
writeFile(1, "s1", "12345")
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
writeFile(1, "s2", "1234")
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
writeFile(1, "s3", "123")
assertEquals(5L, repository.downloadedBytes(1, DownloadList.Album, "al1"))
}
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -0,0 +1,69 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DeleteDownloadsDialogTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun theDialogNamesTheThingAndTheSpaceItFrees() {
render(bytes = 1_500_000)
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
composeRule.onNodeWithText("1.5 MB", substring = true).assertIsDisplayed()
}
@Test
fun deleteConfirms() {
var confirms = 0
render(onConfirm = { confirms++ })
composeRule.onNodeWithText("Delete").performClick()
assertEquals(1, confirms)
}
@Test
fun cancelDismisses() {
var dismisses = 0
render(onDismiss = { dismisses++ })
composeRule.onNodeWithText("Cancel").performClick()
assertEquals(1, dismisses)
}
@Test
fun bytesReadAsUnits() {
assertEquals("512 B", formatBytes(512))
assertEquals("1.0 kB", formatBytes(1_024))
assertEquals("1.5 MB", formatBytes(1_500_000))
assertEquals("2.0 GB", formatBytes(2_000_000_000))
}
private fun render(
bytes: Long = 0,
onConfirm: () -> Unit = {},
onDismiss: () -> Unit = {},
) {
composeRule.setContent {
SubtracksTheme {
DeleteDownloadsDialog(name = "Kid A", bytes = bytes, onConfirm = onConfirm, onDismiss = onDismiss)
}
}
}
}