library: reconcile queues after starred and download changes

This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent 481f12c892
commit ed19ca2ece
11 changed files with 371 additions and 17 deletions
@@ -21,6 +21,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
@@ -61,6 +62,7 @@ class DownloadRepositoryTest {
private lateinit var sources: SourceRepository
private lateinit var engine: FakeDownloadEngine
private lateinit var repository: DownloadRepository
private lateinit var queues: QueueRepository
private lateinit var artwork: ArtworkStore
private lateinit var dir: File
private lateinit var scope: CoroutineScope
@@ -89,6 +91,7 @@ class DownloadRepositoryTest {
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
queues = QueueRepository(db)
repository =
DownloadRepository(
db,
@@ -97,6 +100,7 @@ class DownloadRepositoryTest {
dir,
artworkStore = artwork,
artworkFetcher = fetcher,
queueRepository = queues,
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -361,6 +365,34 @@ class DownloadRepositoryTest {
)
}
@Test
fun deleteAllCapturesTheCurrentQueuePositionBeforeRemovingDownloads() =
runTest {
seedLibrary()
seedSongs(3..3)
runBlocking {
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
queues.replace(listOf(queues.downloadedEntry(1), queues.albumEntry(1, "al1")))
}
writeFile(1, "s1", "audio")
writeFile(1, "s2", "audio")
repository.start()
await { repository.localUri("s1") != null && repository.localUri("s2") != null }
runBlocking { repository.awaitLoaded() }
runBlocking {
queues.setCursor(4)
queues.setPosition(42_000L)
}
val beforeReset = runBlocking { queues.snapshot().resetVersion }
runBlocking { repository.removeSource(1) }
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
assertEquals(2L, runBlocking { queues.cursor() })
assertEquals(42_000L, runBlocking { queues.cursorPositionMs() })
}
@Test
fun reconcileDeletesFilesThatBelongToNoDownload() =
runTest {
@@ -613,6 +645,19 @@ class DownloadRepositoryTest {
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun artworkSurvivesTheDownloadedSongGoingMissing() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
}
@Test
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
runTest {
@@ -1354,6 +1399,213 @@ class DownloadRepositoryTest {
}
}
@Test
fun switchingSourcesDoesNotResetAnEditedDownloadedQueue() =
runTest {
seedLibrary()
runBlocking {
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 1))
db.libraryDao().upsertSongs(
listOf(
Song(
2,
"o1",
"other-album",
"other-artist",
"Other",
"Other Album",
"Other Artist",
100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
db.downloadDao().upsert(SongDownload(2, "o1", DownloadStatus.Completed, downloadedAt = 1))
}
writeFile(1, "s1", "one")
writeFile(1, "s2", "two")
writeFile(2, "o1", "other")
repository.start()
await { repository.localUri("s1") != null }
runBlocking {
queues.replace(listOf(queues.downloadedEntry(1)))
queues.removeAt(queues.snapshot(), 1)
}
val edited = runBlocking { queues.snapshot().entries.map { it.entry } }
assertEquals(1, edited.size)
assertNotNull(edited.single().rangeStart)
runBlocking { sources.selectSource(2) }
await { repository.localUri("o1") != null }
assertEquals(edited, runBlocking { queues.snapshot().entries.map { it.entry } })
}
@Test
fun aDownloadCompletedWhileItsSourceIsInactiveResetsOnReturn() =
runTest {
seedLibrary()
runBlocking {
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 1))
db.libraryDao().upsertSongs(
listOf(
Song(1, "s3", "al1", "ar1", "Song 3", "Album", "Artist", 100, track = 3, disc = 1, starred = null, genre = null),
Song(
2,
"o1",
"other-album",
"other-artist",
"Other",
"Other Album",
"Other Artist",
100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
db.downloadDao().upsert(SongDownload(2, "o1", DownloadStatus.Completed, downloadedAt = 1))
}
writeFile(1, "s1", "one")
writeFile(1, "s2", "two")
writeFile(2, "o1", "other")
repository.start()
await { repository.localUri("s1") != null }
runBlocking {
queues.replace(listOf(queues.downloadedEntry(1)))
queues.removeAt(queues.snapshot(), 1)
sources.selectSource(2)
}
await { repository.localUri("o1") != null }
assertNotNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
runBlocking {
repository.captureForReset(1)
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed, downloadedAt = 3))
writeFile(1, "s3", "three")
repository.reconcile()
sources.selectSource(1)
}
await { repository.localUri("s3") != null }
assertNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
}
@Test
fun backfillingDownloadTimeResetsAnEditedDownloadedQueue() =
runTest {
seedLibrary()
runBlocking {
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 42, downloadedAt = 0))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 1))
}
writeFile(1, "s1", "one")
writeFile(1, "s2", "two")
repository.start()
await { repository.localUri("s1") != null && repository.localUri("s2") != null }
runBlocking {
queues.replace(listOf(queues.downloadedEntry(1)))
queues.removeAt(queues.snapshot(), 0)
}
assertNotNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
engine.complete(42, total = 10)
runBlocking { repository.reconcile() }
await { runBlocking { (row(1, "s1")?.downloadedAt ?: 0L) > 0L } }
Thread.sleep(200)
assertNull(
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart
},
)
}
@Test
fun aMultiRowRecoveryKeepsTheResetPointFromBeforeTheWholeBatch() =
runTest {
seedLibrary()
seedSongs(3..3)
runBlocking {
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed, downloadedAt = 1))
queues.replace(listOf(queues.downloadedEntry(1)))
queues.setCursor(0)
repository.captureForReset(1)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 2))
repository.captureForReset(1)
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 3))
repository.captureForReset(1)
}
val field = DownloadRepository::class.java.getDeclaredField("pendingResetPoints")
field.isAccessible = true
val pending = field.get(repository) as Map<*, *>
val point = pending[1L] as QueueResetPoint
assertEquals("s3", point.current?.songId)
}
@Test
fun startupRecoveryResetsAnEditedDownloadedQueueWhenAFileIsMissing() =
runTest {
seedLibrary()
runBlocking {
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 1)))
}
writeFile(1, "s1", "one")
repository.start()
runBlocking { repository.reconcile() }
await {
runBlocking {
queues
.snapshot()
.entries
.single()
.entry.rangeStart == null
}
}
}
private fun seedPlaylist(songIds: List<String>) {
runBlocking {
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, songIds.size.toLong(), 0)))
@@ -72,6 +72,7 @@ class LibraryRepositoryTest {
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
queueRepository = QueueRepository(db),
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -538,6 +538,7 @@ class SyncManagerTest {
downloadsDir,
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) },
queueRepository = queueRepository,
scope = scope,
)
File(downloadsDir, "1").mkdirs()
@@ -119,6 +119,7 @@ class PlaybackControllerTest {
downloadsDir,
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) },
queueRepository = queues,
scope = repoScope,
).also { it.start() }
handle = FakePlayerHandle()
@@ -3928,6 +3929,34 @@ class PlaybackControllerTest {
assertEquals(2L, controller.state.value.position)
}
@Test
fun aDownloadInsertedBeforeCurrentKeepsTheActualCurrentSong() {
seedAlbum(3, sourceId = 1)
seedSong("s0", "al1")
markDownloaded(1, "s1", downloadedAt = 200)
markDownloaded(1, "s2", downloadedAt = 100)
controller.playDownloaded(1, 1)
await { handle.currentItem?.id == "s2" }
markDownloaded(1, "s0", downloadedAt = 300)
await { controller.state.value.position == 2L && handle.currentItem?.id == "s2" }
}
@Test
fun deletingTheCurrentDownloadDetachesThatSong() {
seedAlbum(3, sourceId = 1)
markDownloaded(1, "s1", downloadedAt = 200)
markDownloaded(1, "s2", downloadedAt = 100)
controller.playDownloaded(1, 1)
await { handle.currentItem?.id == "s2" }
runBlocking { downloads.remove(1, "s2") }
await { handle.items.take(2).map { it.id } == listOf("s2", "s1") }
assertEquals("s2", handle.currentItem?.id)
}
@Test
fun aDetachedTrackSurvivesMediaRefresh() {
seedPlaylist(5)
@@ -4369,6 +4398,7 @@ class PlaybackControllerTest {
val deadline = System.nanoTime() + TEST_TIMEOUT_MS * 1_000_000
while (System.nanoTime() < deadline) {
runBlocking {
downloads.captureForReset(sourceId)
db.downloadDao().upsert(SongDownload(sourceId, songId, DownloadStatus.Completed, downloadedAt = downloadedAt))
file.parentFile?.mkdirs()
file.writeBytes(byteArrayOf(1))
@@ -42,6 +42,7 @@ class ViewModelRepositoryFixture {
val downloadEngine = FakeDownloadEngine()
val downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
val sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artworkStore, scope = repoScope)
val queueRepository = QueueRepository(db)
val downloadRepository =
DownloadRepository(
db,
@@ -50,9 +51,9 @@ class ViewModelRepositoryFixture {
downloadsDir,
artworkStore = artworkStore,
artworkFetcher = { ByteArray(0) },
queueRepository = queueRepository,
scope = repoScope,
)
val queueRepository = QueueRepository(db)
val controller =
PlaybackController(
sourceRepository,
@@ -68,6 +69,7 @@ class ViewModelRepositoryFixture {
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
queueRepository = queueRepository,
scope = repoScope,
)
@@ -91,9 +91,10 @@ class NowPlayingRouteTest {
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artworkStore,
artworkFetcher = { ByteArray(0) },
queueRepository = queues,
scope = repoScope,
).also { it.start() }
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
library = LibraryRepository(db, sources, serverActions, prefs, queueRepository = queues, scope = repoScope)
handle = FakePlayerHandle()
controller =
PlaybackController(
@@ -83,6 +83,7 @@ class QueueViewModelTest {
downloadsDir,
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
queueRepository = queues,
scope = repoScope,
).also { it.start() }
controller =
@@ -67,6 +67,7 @@ class AddSourceViewModelTest {
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
queueRepository = QueueRepository(db),
scope = repoScope,
)
server = MockWebServer()