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, + ) +}