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
@@ -13,7 +13,7 @@ interface DownloadDao {
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId") @Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
fun downloads(sourceId: Long): Flow<List<SongDownload>> fun downloads(sourceId: Long): Flow<List<SongDownload>>
@Query("SELECT * FROM song_downloads") @Query("SELECT * FROM song_downloads ORDER BY rowid")
suspend fun all(): List<SongDownload> suspend fun all(): List<SongDownload>
@Query( @Query(
@@ -163,9 +163,10 @@ interface QueueDao {
): List<String> ): List<String>
@Query( @Query(
"SELECT id FROM songs WHERE sourceId = :sourceId " + "SELECT songs.id FROM songs " +
"AND albumId IN (SELECT id FROM albums WHERE sourceId = :sourceId AND artistId = :artistId) " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"ORDER BY albumId, disc, track, id", "WHERE songs.sourceId = :sourceId AND albums.artistId = :artistId " +
"ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id",
) )
suspend fun artistSongIds( suspend fun artistSongIds(
sourceId: Long, sourceId: Long,
@@ -103,29 +103,64 @@ class DownloadRepository(
showMessage(NO_ADDRESS) showMessage(NO_ADDRESS)
return@withContext return@withContext
} }
if (!enqueueIfAbsent(sourceId, songId)) return@withContext val promoted =
mutex.withLock {
if (!queueIfAbsentLocked(sourceId, songId)) return@withContext
promote()
}
// The media is already with the platform, so this only has to happen while we are still // The media is already with the platform, so this only has to happen while we are still
// in the foreground: artwork is fetched by us, and it must not hold up the download. // in the foreground: artwork is fetched by us, and it must not hold up the download.
fetchArtworkSafely(sourceId, listOf(songId)) fetchArtworkSafely(sourceId, promoted.map { it.songId })
} }
private suspend fun enqueueIfAbsent( private suspend fun queueIfAbsentLocked(
sourceId: Long,
songId: String,
): Boolean = mutex.withLock { enqueueIfAbsentLocked(sourceId, songId) }
private suspend fun enqueueIfAbsentLocked(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
): Boolean { ): Boolean {
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false
val existing = db.downloadDao().find(sourceId, songId) val existing = db.downloadDao().find(sourceId, songId)
existing?.engineId?.let(engine::cancel) existing?.engineId?.let(engine::cancel)
val url = sourceRepository.downloadUri(sourceId, songId) ?: return false dir(sourceId).mkdirs()
enqueue(sourceId, songId, url) file(sourceId, songId).delete()
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
ensurePolling()
return true return true
} }
/**
* Hands the platform as many waiting songs as it can run, oldest first, so the queue it holds
* mirrors the list on screen rather than the whole library. Everything else waits as a row
* without an engine id, and the next tick promotes it.
*/
private suspend fun promote(): List<SongDownload> {
val rows = db.downloadDao().all()
val free = IN_FLIGHT_LIMIT - rows.count { it.status.isActive && it.engineId != null }
if (free <= 0) return emptyList()
val promoted = ArrayList<SongDownload>()
rows
.filter { it.status == DownloadStatus.Queued && it.engineId == null }
.take(free)
.forEach { row ->
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return@forEach
val title = db.libraryDao().songOnce(row.sourceId, row.songId)?.title ?: row.songId
val engineId =
try {
engine.enqueue(
EngineRequest(uri = url, path = enginePath(row.sourceId, row.songId), title = title),
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Leave it failed rather than waiting, or the next tick would retry it forever.
markFailed(row, STOPPED)
return@forEach
}
db.downloadDao().upsert(row.copy(engineId = engineId))
promoted += row
}
return promoted
}
suspend fun remove( suspend fun remove(
sourceId: Long, sourceId: Long,
songId: String, songId: String,
@@ -167,20 +202,23 @@ class DownloadRepository(
} }
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and // Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and
// then removes all of it, rather than missing the songs enqueued after it looked. // then removes all of it, rather than missing the songs enqueued after it looked.
// ponytail: the lock is held for the whole loop, so cancel latency and the poll tick scale // ponytail: the lock is held for the whole insert pass, so cancel latency scales with the
// with the list length; a per-list cancel generation would lift that if it ever bites. // list length; the promote below hands over at most IN_FLIGHT_LIMIT, which is what keeps
mutex.withLock { // the platform's queue (and our poll writes) bounded.
songIds.forEach { songId -> val promoted =
try { mutex.withLock {
enqueueIfAbsentLocked(sourceId, songId) songIds.forEach { songId ->
} catch (e: CancellationException) { try {
throw e queueIfAbsentLocked(sourceId, songId)
} catch (_: Exception) { } catch (e: CancellationException) {
// One song failing to enqueue must not abandon the rest of the list. throw e
} catch (_: Exception) {
// One song failing to queue must not abandon the rest of the list.
}
} }
promote()
} }
} fetchArtworkSafely(sourceId, promoted.map { it.songId })
fetchArtworkSafely(sourceId, songIds)
} }
suspend fun applyAction( suspend fun applyAction(
@@ -194,6 +232,20 @@ class DownloadRepository(
BulkDownloadAction.Delete -> deleteAll(sourceId, list, refId) BulkDownloadAction.Delete -> deleteAll(sourceId, list, refId)
} }
suspend fun downloadedBytes(
sourceId: Long,
list: DownloadList,
refId: String,
): Long =
withContext(dispatcher) {
val songIds = songIds(sourceId, list, refId).toHashSet()
db
.downloadDao()
.all()
.filter { it.sourceId == sourceId && it.songId in songIds && it.status == DownloadStatus.Completed }
.sumOf { file(it.sourceId, it.songId).length() }
}
suspend fun cancelAll( suspend fun cancelAll(
sourceId: Long, sourceId: Long,
list: DownloadList, list: DownloadList,
@@ -234,15 +286,15 @@ class DownloadRepository(
suspend fun reconcile() = suspend fun reconcile() =
withContext(dispatcher) { withContext(dispatcher) {
val resumed = val promoted =
mutex.withLock { mutex.withLock {
val rows = db.downloadDao().all() val rows = db.downloadDao().all()
val resumed = reconcileRows(rows) reconcileRows(rows)
sweep(rows) sweep(rows)
ensurePolling() ensurePolling()
resumed promote()
} }
resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) } warmArtwork(promoted)
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -316,59 +368,38 @@ class DownloadRepository(
.toSet() .toSet()
} }
private suspend fun enqueue( private suspend fun recover(row: SongDownload) {
sourceId: Long,
songId: String,
url: String,
) {
dir(sourceId).mkdirs()
file(sourceId, songId).delete()
val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId
val engineId =
engine.enqueue(
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title),
)
db.downloadDao().upsert(
SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId),
)
ensurePolling()
}
private suspend fun recover(row: SongDownload): Boolean =
when (row.status) { when (row.status) {
DownloadStatus.Completed -> { DownloadStatus.Completed -> {
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE) if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
false
} }
// Waiting rows have no engine id by design; the next promote hands them over.
DownloadStatus.Queued -> { DownloadStatus.Queued -> {
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false if (row.engineId != null) db.downloadDao().upsert(row.copy(engineId = null))
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false
enqueue(row.sourceId, row.songId, url)
true
} }
DownloadStatus.Running -> { DownloadStatus.Running -> {
markFailed(row, STOPPED) markFailed(row, STOPPED)
false
} }
DownloadStatus.Failed -> { DownloadStatus.Failed -> {
false Unit
} }
} }
}
private suspend fun reconcileRows(rows: List<SongDownload>): List<SongDownload> { private suspend fun reconcileRows(rows: List<SongDownload>) {
val resumed = ArrayList<SongDownload>()
rows.forEach { row -> rows.forEach { row ->
val state = row.engineId?.let(engine::download) val state = row.engineId?.let(engine::download)
if (state == null) { if (state == null) recover(row) else apply(row, state)
if (recover(row)) resumed += row }
} else { }
apply(row, state)
} private suspend fun warmArtwork(promoted: List<SongDownload>) {
promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) ->
fetchArtworkSafely(sourceId, rows.map { it.songId })
} }
return resumed
} }
private suspend fun removeRow( private suspend fun removeRow(
@@ -458,16 +489,19 @@ class DownloadRepository(
scope.launch { scope.launch {
while (true) { while (true) {
delay(POLL_MS) delay(POLL_MS)
val resumed = val promoted =
mutex.withLock { mutex.withLock {
val sourceId = sourceRepository.activeSourceIdOnce() val sourceId = sourceRepository.activeSourceIdOnce()
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId } val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
if (active.isEmpty()) return@launch if (active.isEmpty()) return@launch
reconcileRows(active) reconcileRows(active)
promote()
} }
// Fetching artwork here would hold up the next tick, and with it the progress // Fetching artwork here would hold up the next tick, and with it the progress
// the queue is mirrored from. // the queue is mirrored from.
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, listOf(row.songId)) } } promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) ->
scope.launch { fetchArtworkSafely(sourceId, rows.map { it.songId }) }
}
} }
} }
} }
@@ -507,6 +541,7 @@ class DownloadRepository(
const val ENGINE_DIR = "downloads" const val ENGINE_DIR = "downloads"
const val NO_MEDIA = ".nomedia" const val NO_MEDIA = ".nomedia"
const val POLL_MS = 1_000L const val POLL_MS = 1_000L
const val IN_FLIGHT_LIMIT = 8
const val MISSING_FILE = "The downloaded file is missing" const val MISSING_FILE = "The downloaded file is missing"
const val NO_ADDRESS = "Can't download: the server address is unavailable" const val NO_ADDRESS = "Can't download: the server address is unavailable"
const val EMPTY_LIST = "Nothing left to download from this list" const val EMPTY_LIST = "Nothing left to download from this list"
@@ -0,0 +1,40 @@
package com.subtracks.ui.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
fun formatBytes(bytes: Long): String {
val units = listOf("B", "kB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = 0
while (value >= 1000 && unit < units.lastIndex) {
value /= 1000
unit++
}
return if (unit == 0) "$bytes ${units[unit]}" else "${"%.1f".format(value)} ${units[unit]}"
}
@Composable
fun DeleteDownloadsDialog(
name: String,
bytes: Long,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete downloads") },
text = { Text("Delete the downloaded songs of \"$name\"? This frees ${formatBytes(bytes)}.") },
confirmButton = {
TextButton(
onClick = {
onConfirm()
onDismiss()
},
) { Text("Delete") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -28,8 +28,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.Shuffle
@@ -291,7 +291,11 @@ fun HeroHeader(
) { ) {
when (downloadStatus.action()) { when (downloadStatus.action()) {
BulkDownloadAction.Delete -> { BulkDownloadAction.Delete -> {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download", modifier = Modifier.offset(y = 1.5.dp)) Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Delete download",
modifier = Modifier.size(28.dp).offset(y = 1.5.dp),
)
} }
BulkDownloadAction.Cancel -> { BulkDownloadAction.Cancel -> {
@@ -13,7 +13,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -31,12 +34,14 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -60,6 +65,8 @@ fun AlbumDetailRoute(
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -84,7 +91,13 @@ fun AlbumDetailRoute(
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads, downloads = downloads,
downloadStatus = downloadStatus, downloadStatus = downloadStatus,
onDownloadAction = viewModel::onDownloadAction, onDownloadAction = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
}
},
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) }, onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -98,6 +111,15 @@ fun AlbumDetailRoute(
?.let { id -> { onViewArtist(id) } }, ?.let { id -> { onViewArtist(id) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
) )
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = album?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
} }
@Composable @Composable
@@ -47,6 +47,8 @@ class AlbumDetailViewModel(
.flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) } .flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Album, albumId)
fun onDownloadAction(action: BulkDownloadAction) { fun onDownloadAction(action: BulkDownloadAction) {
viewModelScope.launch { viewModelScope.launch {
downloadRepository.applyAction(sourceId.first(), DownloadList.Album, albumId, action) downloadRepository.applyAction(sourceId.first(), DownloadList.Album, albumId, action)
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -21,12 +22,14 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -48,6 +51,8 @@ fun PlaylistDetailRoute(
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val actions = val actions =
@@ -70,7 +75,13 @@ fun PlaylistDetailRoute(
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads, downloads = downloads,
downloadStatus = downloadStatus, downloadStatus = downloadStatus,
onDownloadAction = viewModel::onDownloadAction, onDownloadAction = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
}
},
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) }, onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -79,6 +90,15 @@ fun PlaylistDetailRoute(
onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } }, onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } },
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId }, playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
) )
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = playlist?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
} }
@Composable @Composable
@@ -51,6 +51,8 @@ class PlaylistDetailViewModel(
.flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) } .flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Playlist, playlistId)
fun onDownloadAction(action: BulkDownloadAction) { fun onDownloadAction(action: BulkDownloadAction) {
viewModelScope.launch { viewModelScope.launch {
downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action) downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action)
@@ -560,11 +560,10 @@ class DownloadRepositoryTest {
fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() = fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() =
runTest { runTest {
seedLibrary() seedLibrary()
seedSongs(3..40) seedSongs(3..400)
engine.enqueueDelayMs = 1
val queuing = launch(Dispatchers.IO) { repository.downloadAll(1, DownloadList.Album, "al1") } 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") } val cancelling = launch(Dispatchers.IO) { repository.cancelAll(1, DownloadList.Album, "al1") }
queuing.join() queuing.join()
cancelling.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()) 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 @Test
fun oneSongFailingToQueueDoesNotAbandonTheRest() = fun oneSongFailingToQueueDoesNotAbandonTheRest() =
runTest { runTest {
@@ -613,6 +656,19 @@ class DownloadRepositoryTest {
assertNull(row(1, "s2")) 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 requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) { 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)
}
}
}
}