sync: reset queues only for successfully changed origins

This commit is contained in:
austinried
2026-11-14 12:05:28 +00:00
parent acc820e263
commit 548bf39136
4 changed files with 942 additions and 73 deletions
@@ -5,18 +5,27 @@ import android.content.res.Resources
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.replace
import com.subtracks.data.repo.setShuffle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -28,6 +37,7 @@ import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -100,6 +110,39 @@ class SyncManagerTest {
assertTrue(messages.isEmpty())
}
@Test
fun aNoOpSyncPreservesTheQueueResolutionCache() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> MockResponse().setBody(TWO_SONGS)
request.path?.startsWith("/rest/getPlaylists.view") == true -> MockResponse().setBody(PLAYLISTS)
request.path?.startsWith("/rest/getPlaylist.view") == true -> MockResponse().setBody(PLAYLIST)
else -> healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
queueRepository.replace(listOf(queueRepository.playlistEntry(1, "p1")))
val snapshot = queueRepository.snapshot()
assertEquals(2L, snapshot.size)
assertEquals(listOf("s1"), queueRepository.range(snapshot, 0, 0).map { it.item.song.id })
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
val offsetSeeks = queueRepository.offsetSeeks
val keysetSeeks = queueRepository.keysetSeeks
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
assertEquals(offsetSeeks, queueRepository.offsetSeeks)
assertEquals(keysetSeeks + 1, queueRepository.keysetSeeks)
}
@Test
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
runBlocking {
@@ -112,6 +155,19 @@ class SyncManagerTest {
assertEquals(before, queueRepository.snapshot().version)
}
@Test
fun aFailedSyncDoesNotResetTheQueue() =
runBlocking {
db.sourcesDao().upsertSource(Source(1, "Local", "http://localhost", true, 0))
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
val before = queueRepository.snapshot().entries.map { it.entry }
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(before, queueRepository.snapshot().entries.map { it.entry })
}
@Test
fun aSuccessfulSyncMirrorsTheLibraryIntoRoom() =
runBlocking {
@@ -169,6 +225,162 @@ class SyncManagerTest {
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
}
@Test
fun aFailedSyncCarriesCommittedOriginsIntoTheNextSuccessfulReset() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.path?.startsWith("/rest/getPlaylists.view") == true) {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
} else {
healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
val edited = queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)
queueRepository.replace(listOf(edited))
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
0L,
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
manager =
SyncManager(
db,
sourceRepository,
queueRepository,
NetworkServerActionSink(sourceRepository),
preferences = preferences,
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
server.dispatcher = healthy
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
assertNull(
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
}
@Test
fun aFailedSyncPreservesResolvedQueueShuffle() =
runBlocking {
server.dispatcher = healthyDispatcher()
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1")))
queueRepository.setShuffle(42L)
assertTrue(queueRepository.snapshot().shuffled)
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(TWO_SONGS)
}
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
else -> {
healthy.dispatch(request)
}
}
}
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
val snapshot = queueRepository.snapshot()
assertTrue(snapshot.shuffled)
assertEquals(1L, snapshot.contextSize)
}
@Test
fun aFailedSongPruneDoesNotResetAnEditedDownloadedQueue() =
runBlocking {
val healthy = healthyDispatcher()
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
if (request.path?.startsWith("/rest/search3.view") == true) {
MockResponse().setBody(TWO_SONGS)
} else {
healthy.dispatch(request)
}
}
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
val context = ApplicationProvider.getApplicationContext<Context>()
val downloadsDir = File(context.cacheDir, "sync-downloads-${System.nanoTime()}")
val downloads =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
downloadsDir,
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
artworkFetcher = { ByteArray(0) },
scope = scope,
)
File(downloadsDir, "1").mkdirs()
File(downloadsDir, "1/s2").writeText("audio")
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 1))
downloads.start()
downloads.awaitLoaded()
awaitUntil("waiting for download baseline") { downloads.localUri("s2") != null }
queueRepository.replace(listOf(queueRepository.downloadedEntry(1).copy(rangeStart = 1)))
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when {
request.path?.startsWith("/rest/search3.view") == true -> {
MockResponse().setBody(SONG)
}
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
else -> {
healthy.dispatch(request)
}
}
}
manager.requestSync()
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(
1L,
queueRepository
.snapshot()
.entries
.single()
.entry.rangeStart,
)
}
@Test
fun requestsWhileRunningCoalesceIntoOneSync() =
runBlocking {
@@ -253,6 +465,24 @@ class SyncManagerTest {
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"</searchResult3></subsonic-response>"
const val TWO_SONGS =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"<song id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
"</searchResult3></subsonic-response>"
const val PLAYLISTS =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><playlists>" +
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\"/>" +
"</playlists></subsonic-response>"
const val PLAYLIST =
"<subsonic-response status=\"ok\" version=\"1.16.1\">" +
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\">" +
"<entry id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
"<entry id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
"</playlist></subsonic-response>"
}
@Test
@@ -18,6 +18,8 @@ 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.QueueOrigin
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
@@ -1868,6 +1870,278 @@ class SyncServiceTest {
}
}
@Test
fun playlistMembershipChangesReportOnlyTheirOwnOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1"), playlist("p2")),
playlistSongs =
listOf(
PlaylistSong(1, "p1", "s1", 0),
PlaylistSong(1, "p2", "s2", 0),
),
)
SyncService(db, source).sync()
source.playlistSongs =
listOf(
PlaylistSong(1, "p1", "s2", 0),
PlaylistSong(1, "p2", "s2", 0),
)
val summary = SyncService(db, source).sync()
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), summary.changedOrigins)
source.playlistSongs = listOf(PlaylistSong(1, "p2", "s2", 0))
val deletionSummary = SyncService(db, source).sync()
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), deletionSummary.changedOrigins)
}
@Test
fun albumAndSongChangesReportOnlyAffectedOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
albums =
listOf(
album("al1"),
album("al2").copy(artistId = "a2"),
album("al3").copy(artistId = "a3"),
),
songs =
listOf(
song("s1").copy(genres = listOf("Rock")),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
),
)
SyncService(db, source).sync()
source.albums =
listOf(
album("al1").copy(name = "Renamed"),
album("al2").copy(artistId = "a2"),
album("al3").copy(artistId = "a3"),
)
source.songs =
listOf(
song("s1").copy(
albumId = "al3",
artistId = "a3",
genres = listOf("Electronic"),
starred = 1234,
),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
)
val summary = SyncService(db, source).sync()
assertEquals(
setOf(
QueueOrigin(QueueKind.Album, 1, "al1"),
QueueOrigin(QueueKind.Album, 1, "al3"),
QueueOrigin(QueueKind.Artist, 1, "a1"),
QueueOrigin(QueueKind.Artist, 1, "a3"),
QueueOrigin(QueueKind.Genre, 1, "Rock"),
QueueOrigin(QueueKind.Genre, 1, "Electronic"),
QueueOrigin(QueueKind.Starred, 1, ""),
QueueOrigin(QueueKind.Song, 1, "s1"),
),
summary.changedOrigins,
)
}
@Test
fun deletedSongsReportOnlyTheirPreviousOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1"), album("al2").copy(artistId = "a2")),
songs =
listOf(
song("s1").copy(genres = listOf("Rock"), starred = 1234),
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
),
)
SyncService(db, source).sync()
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")))
val summary = SyncService(db, source).sync()
assertEquals(
setOf(
QueueOrigin(QueueKind.Album, 1, "al1"),
QueueOrigin(QueueKind.Artist, 1, "a1"),
QueueOrigin(QueueKind.Genre, 1, "Rock"),
QueueOrigin(QueueKind.Starred, 1, ""),
QueueOrigin(QueueKind.Song, 1, "s1"),
),
summary.changedOrigins,
)
}
@Test
fun albumCreditChangesReportBothArtistOrigins() =
runTest {
insertSource()
val plural = listOf(ArtistRef("a2", "Artist a2"))
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
albums = listOf(album("al1").copy(artists = plural)),
songs = listOf(song("s1")),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(artistId = "a3", artists = plural, year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a3")))
}
@Test
fun songPresenceChangesReportReferencingPlaylistOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1")),
albums = listOf(album("al1")),
songs = listOf(song("s1"), song("s2")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0), PlaylistSong(1, "p1", "s2", 1)),
)
SyncService(db, source).sync()
source.songs = listOf(song("s2"))
val deleted = SyncService(db, source).sync()
assertTrue(deleted.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
source.songs = listOf(song("s1"), song("s2"))
val restored = SyncService(db, source).sync()
assertTrue(restored.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
}
@Test
fun downloadedSongSortChangesReportDownloadedOrigins() =
runTest {
insertSource()
val source =
FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1"), song("s2")))
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
source.songs = listOf(song("s1").copy(title = "Zzz", sortName = "Zzz"), song("s2").copy(title = "Zulu"))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Downloaded, 1, "")))
}
@Test
fun albumChangesReportArtistsCreditedOnlyOnTracks() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = "a2")),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
}
@Test
fun albumPruningReportsArtistsCreditedOnlyOnRemainingTracks() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = "a2")),
)
SyncService(db, source).sync()
source.albums = emptyList()
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
}
@Test
fun albumChangesIgnoreSongsWithoutTrackArtists() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1")),
albums = listOf(album("al1")),
songs = listOf(song("s1").copy(artistId = null)),
)
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(year = 2024))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Album, 1, "al1")))
assertTrue(summary.changedOrigins.none { it.kind == QueueKind.Artist && it.refId.isBlank() })
}
@Test
fun committedOriginsArePersistedWithoutManagerRetention() =
runTest {
insertSource()
val source = FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1")))
SyncService(db, source).sync()
source.albums = listOf(album("al1").copy(name = "Changed"))
SyncService(db, source).sync()
val pending = db.queueDao().pendingSyncOrigins(1)
assertTrue(pending.any { it.kind == QueueKind.Album && it.refId == "al1" })
}
@Test
fun albumPruningReportsArtistsCreditedOnlyByTheDeletedAlbum() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1"), artist("a2")),
albums = listOf(album("al1"), album("al2")),
songs =
listOf(
song("s1").copy(artistId = "a2"),
song("s2").copy(albumId = "al2", artistId = "a2"),
),
)
SyncService(db, source).sync()
source.albums = listOf(album("al2"))
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2"))
val summary = SyncService(db, source).sync()
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->