mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -13,7 +13,7 @@ interface DownloadDao {
|
||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||
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>
|
||||
|
||||
@Query(
|
||||
|
||||
@@ -163,9 +163,10 @@ interface QueueDao {
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT id FROM songs WHERE sourceId = :sourceId " +
|
||||
"AND albumId IN (SELECT id FROM albums WHERE sourceId = :sourceId AND artistId = :artistId) " +
|
||||
"ORDER BY albumId, disc, track, id",
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"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(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -103,29 +103,64 @@ class DownloadRepository(
|
||||
showMessage(NO_ADDRESS)
|
||||
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
|
||||
// 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(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): Boolean = mutex.withLock { enqueueIfAbsentLocked(sourceId, songId) }
|
||||
|
||||
private suspend fun enqueueIfAbsentLocked(
|
||||
private suspend fun queueIfAbsentLocked(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): Boolean {
|
||||
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false
|
||||
val existing = db.downloadDao().find(sourceId, songId)
|
||||
existing?.engineId?.let(engine::cancel)
|
||||
val url = sourceRepository.downloadUri(sourceId, songId) ?: return false
|
||||
enqueue(sourceId, songId, url)
|
||||
dir(sourceId).mkdirs()
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||
ensurePolling()
|
||||
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(
|
||||
sourceId: Long,
|
||||
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
|
||||
// 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
|
||||
// with the list length; a per-list cancel generation would lift that if it ever bites.
|
||||
mutex.withLock {
|
||||
songIds.forEach { songId ->
|
||||
try {
|
||||
enqueueIfAbsentLocked(sourceId, songId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// One song failing to enqueue must not abandon the rest of the list.
|
||||
// ponytail: the lock is held for the whole insert pass, so cancel latency scales with the
|
||||
// list length; the promote below hands over at most IN_FLIGHT_LIMIT, which is what keeps
|
||||
// the platform's queue (and our poll writes) bounded.
|
||||
val promoted =
|
||||
mutex.withLock {
|
||||
songIds.forEach { songId ->
|
||||
try {
|
||||
queueIfAbsentLocked(sourceId, songId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// One song failing to queue must not abandon the rest of the list.
|
||||
}
|
||||
}
|
||||
promote()
|
||||
}
|
||||
}
|
||||
fetchArtworkSafely(sourceId, songIds)
|
||||
fetchArtworkSafely(sourceId, promoted.map { it.songId })
|
||||
}
|
||||
|
||||
suspend fun applyAction(
|
||||
@@ -194,6 +232,20 @@ class DownloadRepository(
|
||||
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(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
@@ -234,15 +286,15 @@ class DownloadRepository(
|
||||
|
||||
suspend fun reconcile() =
|
||||
withContext(dispatcher) {
|
||||
val resumed =
|
||||
val promoted =
|
||||
mutex.withLock {
|
||||
val rows = db.downloadDao().all()
|
||||
val resumed = reconcileRows(rows)
|
||||
reconcileRows(rows)
|
||||
sweep(rows)
|
||||
ensurePolling()
|
||||
resumed
|
||||
promote()
|
||||
}
|
||||
resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) }
|
||||
warmArtwork(promoted)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -316,59 +368,38 @@ class DownloadRepository(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private suspend fun enqueue(
|
||||
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 =
|
||||
private suspend fun recover(row: SongDownload) {
|
||||
when (row.status) {
|
||||
DownloadStatus.Completed -> {
|
||||
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 -> {
|
||||
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false
|
||||
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false
|
||||
enqueue(row.sourceId, row.songId, url)
|
||||
true
|
||||
if (row.engineId != null) db.downloadDao().upsert(row.copy(engineId = null))
|
||||
}
|
||||
|
||||
DownloadStatus.Running -> {
|
||||
markFailed(row, STOPPED)
|
||||
false
|
||||
}
|
||||
|
||||
DownloadStatus.Failed -> {
|
||||
false
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reconcileRows(rows: List<SongDownload>): List<SongDownload> {
|
||||
val resumed = ArrayList<SongDownload>()
|
||||
private suspend fun reconcileRows(rows: List<SongDownload>) {
|
||||
rows.forEach { row ->
|
||||
val state = row.engineId?.let(engine::download)
|
||||
if (state == null) {
|
||||
if (recover(row)) resumed += row
|
||||
} else {
|
||||
apply(row, state)
|
||||
}
|
||||
if (state == null) recover(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(
|
||||
@@ -458,16 +489,19 @@ class DownloadRepository(
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(POLL_MS)
|
||||
val resumed =
|
||||
val promoted =
|
||||
mutex.withLock {
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()
|
||||
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
|
||||
if (active.isEmpty()) return@launch
|
||||
reconcileRows(active)
|
||||
promote()
|
||||
}
|
||||
// Fetching artwork here would hold up the next tick, and with it the progress
|
||||
// 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 NO_MEDIA = ".nomedia"
|
||||
const val POLL_MS = 1_000L
|
||||
const val IN_FLIGHT_LIMIT = 8
|
||||
const val MISSING_FILE = "The downloaded file is missing"
|
||||
const val NO_ADDRESS = "Can't download: the server address is unavailable"
|
||||
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.automirrored.rounded.ArrowBack
|
||||
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.DownloadDone
|
||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
@@ -291,7 +291,11 @@ fun HeroHeader(
|
||||
) {
|
||||
when (downloadStatus.action()) {
|
||||
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 -> {
|
||||
|
||||
@@ -13,7 +13,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -60,6 +65,8 @@ fun AlbumDetailRoute(
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
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),
|
||||
downloads = downloads,
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = viewModel::onDownloadAction,
|
||||
onDownloadAction = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||
@@ -98,6 +111,15 @@ fun AlbumDetailRoute(
|
||||
?.let { id -> { onViewArtist(id) } },
|
||||
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
|
||||
|
||||
@@ -47,6 +47,8 @@ class AlbumDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||
|
||||
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Album, albumId)
|
||||
|
||||
fun onDownloadAction(action: BulkDownloadAction) {
|
||||
viewModelScope.launch {
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -21,12 +22,14 @@ import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -48,6 +51,8 @@ fun PlaylistDetailRoute(
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val actions =
|
||||
@@ -70,7 +75,13 @@ fun PlaylistDetailRoute(
|
||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||
downloads = downloads,
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = viewModel::onDownloadAction,
|
||||
onDownloadAction = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
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) } },
|
||||
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
|
||||
|
||||
@@ -51,6 +51,8 @@ class PlaylistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||
|
||||
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Playlist, playlistId)
|
||||
|
||||
fun onDownloadAction(action: BulkDownloadAction) {
|
||||
viewModelScope.launch {
|
||||
downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user