download a whole album, playlist or artist at once

Third block of #65: bulk downloads and the list-level actions.

- The hero's download button on albums and playlists is real now. It starts the
  songs the list is missing, cancels what is in flight, or deletes what is
  already downloaded, chosen from the list's own status. The artist has no hero
  button, so its bulk actions live in the context menu, which now carries the
  same status.
- No new storage: a bulk job is the download rows for the list, and the status is
  counted from song_downloads joined to the list's songs, so cancel and delete
  stay well defined without anything to keep in sync.
- downloadAll fetches artwork for the whole list under one shared budget, once
  per unique cover, and skips songs that are already downloaded so re-tapping
  Download resumes rather than restarting.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent 9266fc5edc
commit b2b92a388e
14 changed files with 527 additions and 18 deletions
@@ -12,13 +12,18 @@ import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
@@ -418,6 +423,97 @@ class DownloadRepositoryTest {
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun downloadingAListQueuesEverySongAndStoresItsArtworkOnce() =
runTest {
seedLibrary()
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
assertEquals(2, engine.requests.size)
assertEquals(4, requestedArt.size)
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, true)))
}
@Test
fun downloadingAListSkipsTheSongsItAlreadyHas() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val before = engine.requests.size
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
assertEquals(before + 1, engine.requests.size)
}
@Test
fun cancelAllStopsTheRunningSongsAndKeepsTheCompletedOnes() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
runBlocking { repository.cancelAll(1, DownloadList.Album, "al1") }
assertNotNull(row(1, "s1"))
assertNull(row(1, "s2"))
assertTrue(engine.cancelled.contains(22L))
}
@Test
fun deleteAllRemovesTheCompletedSongsAndKeepsTheRunningOnes() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
runBlocking { repository.deleteAll(1, DownloadList.Album, "al1") }
assertNull(row(1, "s1"))
assertFalse(file(1, "s1").exists())
assertNotNull(row(1, "s2"))
}
@Test
fun theListStatusCountsTheSongsAndTracksProgress() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Album, "al1"))
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Artist, "ar1"))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued, engineId = 22))
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 1), statusOf(DownloadList.Album, "al1"))
}
@Test
fun thePlaylistStatusCountsASongThatAppearsTwiceOnce() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, 2, 0)))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "pl1", "s1", 0),
PlaylistSong(1, "pl1", "s1", 1),
),
)
}
assertEquals(ListDownloadStatus(total = 1, downloaded = 0, downloading = 0), statusOf(DownloadList.Playlist, "pl1"))
}
private fun statusOf(
list: DownloadList,
refId: String,
): ListDownloadStatus = runBlocking { repository.status(1, list, refId).first() }
private fun album(coverArt: String?) =
Album(
sourceId = 1,
@@ -0,0 +1,94 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ListDownloadStatus
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 HeroDownloadTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun anUndownloadedListOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3).action())
assertEquals(BulkDownloadAction.Download, ListDownloadStatus().action())
}
@Test
fun aPartiallyDownloadedListStillOffersDownload() {
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3, downloaded = 1).action())
}
@Test
fun aListWithDownloadsInFlightOffersCancel() {
assertEquals(BulkDownloadAction.Cancel, ListDownloadStatus(total = 3, downloaded = 1, downloading = 1).action())
}
@Test
fun aFullyDownloadedListOffersDelete() {
assertEquals(BulkDownloadAction.Delete, ListDownloadStatus(total = 3, downloaded = 3).action())
}
@Test
fun tappingTheButtonReportsTheActionItsStateOffers() {
val actions = mutableListOf<BulkDownloadAction>()
render(ListDownloadStatus(total = 3, downloaded = 1, downloading = 1)) { actions += it }
composeRule.onNodeWithContentDescription("Cancel download").performClick()
assertEquals(listOf(BulkDownloadAction.Cancel), actions)
}
@Test
fun aFullyDownloadedListTapsThroughToDelete() {
val actions = mutableListOf<BulkDownloadAction>()
render(ListDownloadStatus(total = 3, downloaded = 3)) { actions += it }
composeRule.onNodeWithContentDescription("Delete download").performClick()
assertEquals(listOf(BulkDownloadAction.Delete), actions)
}
@Test
fun theButtonIsDisabledWithoutSongs() {
render(ListDownloadStatus(), hasSongs = false) {}
composeRule.onNodeWithContentDescription("Download").assertIsNotEnabled()
}
private fun render(
status: ListDownloadStatus,
hasSongs: Boolean = true,
onAction: (BulkDownloadAction) -> Unit,
) {
composeRule.setContent {
SubtracksTheme {
HeroHeader(
art = null,
name = "Album",
subtitle = "Artist",
hasSongs = hasSongs,
onPlay = {},
onShuffle = {},
downloadStatus = status,
onDownloadAction = onAction,
onMore = {},
topInset = 0.dp,
)
}
}
}
}