From fedc502c3c809880260ac8e41b764279d320318c Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:31:00 +0000 Subject: [PATCH] add the downloads management screen A Downloads screen reachable from Settings shows the active source's downloads as an artist > album > song tree with per-node sizes and song counts, each node collapsible. Artist and album nodes delete with a confirmation showing what they free; song rows delete straight away, and a summary row offers Delete all. Sizes come from the files on disk rather than the engine's reported total, so they agree with the byte count the delete dialog already quotes. The tree is built from one query joining the download rows to song, album and artist names. --- .../com/subtracks/data/db/DownloadDao.kt | 12 + .../com/subtracks/data/model/Download.kt | 11 + .../subtracks/data/repo/DownloadRepository.kt | 17 ++ .../main/kotlin/com/subtracks/di/AppModule.kt | 2 + .../main/kotlin/com/subtracks/ui/AppRoot.kt | 6 + .../subtracks/ui/downloads/DownloadsScreen.kt | 271 ++++++++++++++++++ .../ui/downloads/DownloadsViewModel.kt | 104 +++++++ .../subtracks/ui/settings/SettingsScreen.kt | 12 + .../data/repo/DownloadRepositoryTest.kt | 25 ++ .../com/subtracks/ui/AppScreenshotTest.kt | 1 + .../ui/downloads/DownloadTreeTest.kt | 65 +++++ 11 files changed, 526 insertions(+) create mode 100644 app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt create mode 100644 app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index 5305866b..22623d26 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -4,6 +4,7 @@ import androidx.room3.Dao import androidx.room3.Query import androidx.room3.Upsert import com.subtracks.data.model.DownloadArtwork +import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.EntityDownloadStatus import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.SongDownload @@ -96,6 +97,17 @@ interface DownloadDao { ) fun playlistStatuses(sourceId: Long): Flow> + @Query( + "SELECT d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " + + "s.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " + + "FROM song_downloads d " + + "JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + + "LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " + + "LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " + + "WHERE d.sourceId = :sourceId", + ) + fun downloadedSongs(sourceId: Long): Flow> + @Query( "SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + "al.id AS albumRow, al.coverArt AS albumCoverArt, " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index 83a5c8cb..4d98bc58 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -61,6 +61,17 @@ data class EntityDownloadStatus( fun toListStatus(): ListDownloadStatus = ListDownloadStatus(total, downloaded, downloading) } +data class DownloadedSong( + val songId: String, + val title: String, + val albumId: String?, + val albumName: String?, + val artistId: String?, + val artistName: String?, + val status: DownloadStatus, + val size: Long = 0, +) + data class DownloadArtwork( val sourceId: Long, val albumId: String?, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index e957b0f6..be7132ae 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineStatus import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.SongDownload import com.subtracks.data.model.coverArtKey @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -195,6 +197,13 @@ class DownloadRepository( DownloadList.Artist -> db.downloadDao().artistStatuses(sourceId) }.map { rows -> rows.associate { it.id to it.toListStatus() } } + fun downloadedSongs(sourceId: Long): Flow> = + db + .downloadDao() + .downloadedSongs(sourceId) + .map { rows -> rows.map { it.copy(size = file(sourceId, it.songId).length()) } } + .flowOn(dispatcher) + suspend fun downloadAll( sourceId: Long, list: DownloadList, @@ -293,6 +302,14 @@ class DownloadRepository( sweep(db.downloadDao().all()) } + suspend fun removeList( + sourceId: Long, + list: DownloadList, + refId: String, + ) = withContext(dispatcher) { + mutex.withLock { removeListRows(sourceId, list, refId) { true } } + } + suspend fun removeSource(sourceId: Long) = withContext(dispatcher) { mutex.withLock { diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 3c200f0e..d7a64ebf 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -24,6 +24,7 @@ import com.subtracks.playback.MediaSessionConnection import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlayerConnection import com.subtracks.ui.RootViewModel +import com.subtracks.ui.downloads.DownloadsViewModel import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel @@ -71,6 +72,7 @@ fun appModule( viewModel { RootViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } + viewModel { DownloadsViewModel(get(), get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 3f5d5106..4daa16d6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -65,6 +65,7 @@ import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ItemContextMenu import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.statusBarScrim +import com.subtracks.ui.downloads.DownloadsRoute import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.LibraryRoute @@ -105,6 +106,7 @@ private const val SCRIM_FADE_START = 0.85f private object Routes { const val LIBRARY = "library" const val SETTINGS = "settings" + const val DOWNLOADS = "downloads" const val ADD_SERVER = "add-server" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}" @@ -305,9 +307,13 @@ private fun MainNavigation() { composable(Routes.SETTINGS) { SettingsRoute( onAddServer = { navController.navigate(Routes.ADD_SERVER) }, + onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) }, onBack = { navController.popBackStack() }, ) } + composable(Routes.DOWNLOADS) { + DownloadsRoute(onBack = { navController.popBackStack() }) + } composable( route = Routes.ALBUM_DETAIL, arguments = diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt new file mode 100644 index 00000000..6feb4bc3 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -0,0 +1,271 @@ +package com.subtracks.ui.downloads + +import android.text.format.Formatter +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.DownloadedSong +import com.subtracks.ui.components.DeleteDownloadsDialog +import com.subtracks.ui.components.EmptyState +import org.koin.compose.viewmodel.koinViewModel + +sealed interface DownloadDeleteTarget { + data object All : DownloadDeleteTarget + + data class Node( + val list: DownloadList, + val refId: String, + val name: String, + val bytes: Long, + ) : DownloadDeleteTarget + + data class Songs( + val songIds: List, + ) : DownloadDeleteTarget +} + +@Composable +fun DownloadsRoute( + onBack: () -> Unit, + viewModel: DownloadsViewModel = koinViewModel(), +) { + val tree by viewModel.tree.collectAsStateWithLifecycle() + var pending by remember { mutableStateOf(null) } + DownloadsScreen( + tree = tree, + onBack = onBack, + onDelete = { target -> + when (target) { + is DownloadDeleteTarget.Songs -> viewModel.deleteSongs(target.songIds) + is DownloadDeleteTarget.All, is DownloadDeleteTarget.Node -> pending = target + } + }, + ) + when (val dialog = pending) { + is DownloadDeleteTarget.All -> { + DeleteDownloadsDialog( + name = "all downloads", + bytes = tree.bytes, + onConfirm = viewModel::deleteAll, + onDismiss = { pending = null }, + ) + } + + is DownloadDeleteTarget.Node -> { + DeleteDownloadsDialog( + name = dialog.name, + bytes = dialog.bytes, + onConfirm = { viewModel.delete(dialog.list, dialog.refId) }, + onDismiss = { pending = null }, + ) + } + + else -> { + Unit + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadsScreen( + tree: DownloadTree, + onBack: () -> Unit, + onDelete: (DownloadDeleteTarget) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(emptySet()) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Downloads") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + if (tree.songs == 0) { + EmptyState("No downloads yet.", Modifier.padding(padding)) + return@Scaffold + } + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + item { + SummaryRow(tree, onDelete) + } + tree.artists.forEach { artist -> + val artistKey = "artist:${artist.id}:${artist.name}" + item(key = artistKey) { + NodeRow( + name = artist.name, + subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}", + expanded = artistKey in expanded, + indent = 0, + onToggle = { expanded = expanded.toggle(artistKey) }, + onDelete = { + onDelete( + if (artist.id.isBlank()) { + DownloadDeleteTarget.Songs(artist.albums.flatMap { album -> album.songs.map { it.songId } }) + } else { + DownloadDeleteTarget.Node(DownloadList.Artist, artist.id, artist.name, artist.bytes) + }, + ) + }, + ) + } + if (artistKey in expanded) { + artist.albums.forEach { album -> + val albumKey = "album:$artistKey:${album.id}:${album.name}" + item(key = albumKey) { + NodeRow( + name = album.name, + subtitle = "${album.songs.size} songs · ${size(album.bytes)}", + expanded = albumKey in expanded, + indent = 1, + onToggle = { expanded = expanded.toggle(albumKey) }, + onDelete = { + onDelete( + if (album.id.isBlank()) { + DownloadDeleteTarget.Songs(album.songs.map { it.songId }) + } else { + DownloadDeleteTarget.Node(DownloadList.Album, album.id, album.name, album.bytes) + }, + ) + }, + ) + } + if (albumKey in expanded) { + items(album.songs, key = { "$albumKey:${it.songId}" }) { song -> + SongRow(song) { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) } + } + } + } + } + } + } + } +} + +@Composable +private fun SummaryRow( + tree: DownloadTree, + onDelete: (DownloadDeleteTarget) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${tree.songs} songs · ${size(tree.bytes)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text("Delete all") } + } +} + +@Composable +private fun NodeRow( + name: String, + subtitle: String, + expanded: Boolean, + indent: Int, + onToggle: () -> Unit, + onDelete: () -> Unit, +) { + ListItem( + headlineContent = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { Text(subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + leadingContent = { + IconButton(onClick = onToggle) { + Icon( + imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + ) + } + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads") + } + }, + modifier = Modifier.clickable(onClick = onToggle).padding(start = 16.dp * indent), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +@Composable +private fun SongRow( + song: DownloadedSong, + onDelete: () -> Unit, +) { + ListItem( + headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { Text(statusLabel(song), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon(Icons.Rounded.Delete, contentDescription = "Delete download") + } + }, + modifier = Modifier.padding(start = 32.dp), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) +} + +@Composable +private fun statusLabel(song: DownloadedSong): String { + val size = size(song.size) + return when (song.status) { + DownloadStatus.Completed -> size + DownloadStatus.Queued -> "Queued · $size" + DownloadStatus.Running -> "Downloading · $size" + DownloadStatus.Failed -> "Failed · $size" + } +} + +@Composable +private fun size(bytes: Long): String = Formatter.formatFileSize(LocalContext.current, bytes) + +private fun Set.toggle(key: String): Set = if (key in this) this - key else this + key diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt new file mode 100644 index 00000000..224fca11 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt @@ -0,0 +1,104 @@ +package com.subtracks.ui.downloads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.DownloadedSong +import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.playback.PlaybackController +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class DownloadTree( + val artists: List = emptyList(), +) { + val songs: Int get() = artists.sumOf { it.albums.sumOf { album -> album.songs.size } } + val bytes: Long get() = artists.sumOf { it.bytes } +} + +data class DownloadArtistNode( + val id: String, + val name: String, + val bytes: Long, + val albums: List, +) + +data class DownloadAlbumNode( + val id: String, + val name: String, + val bytes: Long, + val songs: List, +) + +@OptIn(ExperimentalCoroutinesApi::class) +class DownloadsViewModel( + private val libraryRepository: LibraryRepository, + private val downloadRepository: DownloadRepository, + private val playbackController: PlaybackController, +) : ViewModel() { + private val sourceId = libraryRepository.activeSourceId.filterNotNull() + + val tree: StateFlow = + sourceId + .flatMapLatest { downloadRepository.downloadedSongs(it) } + .map(::buildTree) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree()) + + fun deleteAll() { + viewModelScope.launch { + downloadRepository.removeSource(sourceId.first()) + playbackController.refreshMediaItems() + } + } + + fun delete( + list: DownloadList, + refId: String, + ) { + viewModelScope.launch { + downloadRepository.removeList(sourceId.first(), list, refId) + playbackController.refreshMediaItems() + } + } + + fun deleteSongs(songIds: List) { + viewModelScope.launch { + val source = sourceId.first() + songIds.forEach { downloadRepository.remove(source, it) } + playbackController.refreshMediaItems() + } + } +} + +internal fun buildTree(songs: List): DownloadTree = + DownloadTree( + artists = + songs + .groupBy { it.artistId.orEmpty() to it.artistName.orEmpty() } + .map { (artistKey, artistSongs) -> + DownloadArtistNode( + id = artistKey.first, + name = artistKey.second.ifBlank { "Unknown artist" }, + bytes = artistSongs.sumOf { it.size }, + albums = + artistSongs + .groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() } + .map { (albumKey, albumSongs) -> + DownloadAlbumNode( + id = albumKey.first, + name = albumKey.second.ifBlank { "Unknown album" }, + bytes = albumSongs.sumOf { it.size }, + songs = albumSongs.sortedBy { it.title.lowercase() }, + ) + }.sortedBy { it.name.lowercase() }, + ) + }.sortedBy { it.name.lowercase() }, + ) diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 4f23e226..5c72e970 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -59,6 +59,7 @@ private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency @Composable fun SettingsRoute( onAddServer: () -> Unit, + onOpenDownloads: () -> Unit, onBack: () -> Unit, viewModel: SettingsViewModel = koinViewModel(), ) { @@ -79,6 +80,7 @@ fun SettingsRoute( onMobileQualityChange = viewModel::setMobileQuality, onSyncConcurrencyChange = viewModel::setSyncConcurrency, onAddServer = onAddServer, + onOpenDownloads = onOpenDownloads, onBack = onBack, ) } @@ -97,6 +99,7 @@ fun SettingsScreen( onMobileQualityChange: (StreamQuality) -> Unit, onSyncConcurrencyChange: (Int) -> Unit, onAddServer: () -> Unit, + onOpenDownloads: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -180,6 +183,15 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { SectionHeader("Downloads") } + item { + ListItem( + headlineContent = { Text("Manage downloads") }, + supportingContent = { Text("Sizes and deletion") }, + modifier = Modifier.clickable(onClick = onOpenDownloads), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { Spacer(Modifier.height(fill)) } } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index 45a0cdf8..8995e69f 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -791,6 +791,31 @@ class DownloadRepositoryTest { assertEquals(ListDownloadStatus(total = 2, downloaded = 1), playlists["pl1"]) } + @Test + fun theManagementViewReportsFileSizesAndDeletesAWholeList() = + runTest { + seedLibrary() + runBlocking { + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, bytes = 10, total = 10)) + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued)) + } + writeFile(1, "s1", "audio") + writeFile(1, "s2", "partial") + + val songs = runBlocking { repository.downloadedSongs(1).first() } + assertEquals(setOf("s1", "s2"), songs.map { it.songId }.toSet()) + val completed = songs.first { it.songId == "s1" } + assertEquals("Album", completed.albumName) + assertEquals("Artist", completed.artistName) + assertEquals(5L, completed.size) + + runBlocking { repository.removeList(1, DownloadList.Album, "al1") } + + assertTrue(allRows().isEmpty()) + assertFalse(file(1, "s1").exists()) + assertFalse(file(1, "s2").exists()) + } + private fun requestedSongIds(): List = engine.requests.map { it.second.path.substringAfterLast('/') } private fun seedSongs(tracks: IntRange) { diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index a6125e84..48033aa7 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -313,6 +313,7 @@ class AppScreenshotTest { onMobileQualityChange = {}, onSyncConcurrencyChange = {}, onAddServer = {}, + onOpenDownloads = {}, onBack = {}, ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt new file mode 100644 index 00000000..f4bc5023 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt @@ -0,0 +1,65 @@ +package com.subtracks.ui.downloads + +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.DownloadedSong +import org.junit.Assert.assertEquals +import org.junit.Test + +class DownloadTreeTest { + @Test + fun songsAreGroupedByArtistAndAlbumWithSizesSummed() { + val tree = + buildTree( + listOf( + song("s1", "One", "ar1", "Artist", "al1", "First", size = 10), + song("s2", "Two", "ar1", "Artist", "al1", "First", size = 20), + song("s3", "Three", "ar1", "Artist", "al2", "Second", size = 5), + song("s4", "Four", "ar2", "Other", "al3", "Third", size = 1), + ), + ) + + assertEquals(listOf("Artist", "Other"), tree.artists.map { it.name }) + val artist = tree.artists.first() + assertEquals(35L, artist.bytes) + assertEquals(listOf("First", "Second"), artist.albums.map { it.name }) + assertEquals(30L, artist.albums.first().bytes) + assertEquals( + listOf("One", "Two"), + artist.albums + .first() + .songs + .map { it.title }, + ) + assertEquals(4, tree.songs) + assertEquals(36L, tree.bytes) + } + + @Test + fun aSongWithNoArtistOrAlbumIsStillShown() { + val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3))) + + val artist = tree.artists.single() + assertEquals("Unknown artist", artist.name) + assertEquals("", artist.id) + assertEquals("Unknown album", artist.albums.single().name) + } + + private fun song( + id: String, + title: String, + artistId: String?, + artistName: String?, + albumId: String?, + albumName: String?, + size: Long, + ) = DownloadedSong( + songId = id, + title = title, + albumId = albumId, + albumName = albumName, + artistId = artistId, + artistName = artistName, + status = DownloadStatus.Completed, + size = size, + ) +}