reconcile and notify downloads outside the active source

A source switched away from kept downloading on the platform while our poller
ignored it, so its rows froze as Running, the notification vanished and the only
Cancel went with it. The poll now reconciles every source's in-flight rows (the
engine reports status without a source) and the notification reads all of them,
which is also what cancelActive already acted on.

The grouped tab queries drop entities with nothing downloaded, since the UI
treats a missing entry and an all-zero one the same.

The indicator's two-value enum was used as a boolean, so it is one; and the
library screenshot now asserts the ring is drawn, rather than capturing an
image that would stay green if it disappeared.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 47064e82f0
commit 7db6d1365f
9 changed files with 81 additions and 34 deletions
@@ -15,6 +15,9 @@ interface DownloadDao {
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
fun downloads(sourceId: Long): Flow<List<SongDownload>>
@Query("SELECT * FROM song_downloads WHERE status IN ('Queued', 'Running')")
fun activeDownloads(): Flow<List<SongDownload>>
@Query("SELECT * FROM song_downloads ORDER BY rowid")
suspend fun all(): List<SongDownload>
@@ -67,7 +70,8 @@ interface DownloadDao {
"FROM songs " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.albumId IS NOT NULL " +
"GROUP BY songs.albumId",
"GROUP BY songs.albumId " +
"HAVING downloaded > 0 OR downloading > 0",
)
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -80,7 +84,8 @@ interface DownloadDao {
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"GROUP BY albums.artistId",
"GROUP BY albums.artistId " +
"HAVING downloaded > 0 OR downloading > 0",
)
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -93,7 +98,8 @@ interface DownloadDao {
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE playlist_songs.sourceId = :sourceId " +
"GROUP BY playlist_songs.playlistId",
"GROUP BY playlist_songs.playlistId " +
"HAVING downloaded > 0 OR downloading > 0",
)
fun playlistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -20,6 +20,8 @@ class DownloadCancelReceiver :
context: Context,
intent: Intent,
) {
// The broadcast budget is short, and cancelActive waits for the repository mutex, which a
// large downloadAll holds for the whole insert pass; a huge queue could outlast it.
val pending = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
@@ -31,8 +31,8 @@ class DownloadNotifier(
fun start() {
createChannel()
scope.launch {
repository.states().collect { rows ->
val content = downloadNotification(rows.values)
repository.activeDownloads().collect { rows ->
val content = downloadNotification(rows)
if (content == null) {
manager.cancel(ID)
} else if (manager.areNotificationsEnabled()) {
@@ -82,6 +82,8 @@ class DownloadRepository(
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
fun activeDownloads(): Flow<List<SongDownload>> = db.downloadDao().activeDownloads()
suspend fun awaitLoaded() {
if (started) loaded.await()
}
@@ -539,8 +541,10 @@ class DownloadRepository(
delay(POLL_MS)
val promoted =
mutex.withLock {
val sourceId = sourceRepository.activeSourceIdOnce()
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
// Reconcile every source's in-flight rows, not just the active one: the
// engine reports status without a source, and a source switched away
// from keeps downloading, so its rows must not freeze as Running.
val active = db.downloadDao().all().filter { it.status.isActive }
if (active.isEmpty()) return@launch
reconcileRows(active)
promote()
@@ -20,10 +20,7 @@ import com.subtracks.data.model.ListDownloadStatus
const val DOWNLOADING_INDICATOR_TAG = "Downloading"
enum class DownloadIndicator { Hidden, InProgress }
fun ListDownloadStatus.indicator(): DownloadIndicator =
if (total > 0 && downloading > 0) DownloadIndicator.InProgress else DownloadIndicator.Hidden
internal fun ListDownloadStatus.isDownloading(): Boolean = total > 0 && downloading > 0
@Composable
fun ListDownloadIndicator(
@@ -32,7 +29,7 @@ fun ListDownloadIndicator(
scrim: Boolean = false,
) {
val current = status ?: return
if (current.indicator() == DownloadIndicator.Hidden) return
if (!current.isDownloading()) return
val progress = { current.downloaded.toFloat() / current.total }
if (scrim) {
Surface(
@@ -126,14 +126,6 @@ fun MenuTarget.bulkRef(): Pair<DownloadList, String>? =
is MenuTarget.Song -> null
}
fun MenuTarget.listDownload(): ListDownloadStatus? =
when (this) {
is MenuTarget.Album -> download
is MenuTarget.Playlist -> download
is MenuTarget.Artist -> download
is MenuTarget.Song -> null
}
class ItemActions(
val playNext: ((QueueRef) -> Unit)? = null,
val addToQueue: ((QueueRef) -> Unit)? = null,
@@ -337,7 +329,14 @@ private fun BulkDownloadItem(
dismiss: (() -> Unit) -> Unit,
) {
val bulk = actions.bulkDownload ?: return
for (action in (target.listDownload() ?: ListDownloadStatus()).actions()) {
val status =
when (target) {
is MenuTarget.Album -> target.download
is MenuTarget.Playlist -> target.download
is MenuTarget.Artist -> target.download
is MenuTarget.Song -> null
}
for (action in (status ?: ListDownloadStatus()).actions()) {
when (action) {
BulkDownloadAction.Delete -> MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } }
BulkDownloadAction.Cancel -> MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } }