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.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 7a0dd9aa5b
commit fedc502c3c
11 changed files with 526 additions and 0 deletions
@@ -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<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -313,6 +313,7 @@ class AppScreenshotTest {
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onAddServer = {},
onOpenDownloads = {},
onBack = {},
)
}
@@ -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,
)
}