address arch review: guard the page cap, coalesce syncs, tighten buffers

- Paged loops now throw when they hit maxPages with a full last page, so a
  truncated enumeration aborts the entity's collect and skips its prune
  instead of deleting rows the sync never saw. The cap is injectable for
  tests.
- Move sync serialization into SyncManager (tryLock coalesces duplicate
  requests) and construct SyncService there; SourceRepository only exposes
  the active MusicSource. Removes the repo <-> sync import cycle.
- Re-throw CancellationException instead of reporting a cancelled sync as
  failed.
- Add buffer(1) to the outer albums/songs/playlistSongs flows so their
  flowOn channel cannot hold 64 batches.
- Rank prefetch is best-effort (missing frequent/recent no longer aborts
  the album sync).
- Skip playlist entries without an id, keeping positions contiguous.
- Chunk stale-disc deletes like the other prunes.
- Update the implementation plan line that still described the old
  single-transaction sync.
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 3c941ef0f2
commit c48ec670d8
8 changed files with 130 additions and 55 deletions
@@ -179,6 +179,49 @@ class SubsonicSourceTest {
assertEquals(500, firstBatch.size)
}
@Test
fun albumsAbortWhenThePageCapIsHit() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/getAlbumList2.view") return MockResponse().setResponseCode(404)
return if (url.queryParameter("type") == "newest") {
MockResponse().setBody(albumPage(0, 500))
} else {
MockResponse().setBody(emptyAlbumList())
}
}
}
val failure =
runCatching {
SubsonicSource(1, client(), maxPages = 2).albums().toList()
}
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
}
@Test
fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking {
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><playlist id=\"p1\">" +
"<entry id=\"s1\" title=\"One\"/>" +
"<entry title=\"No id\"/>" +
"<entry id=\"s2\" title=\"Two\"/>" +
"</playlist></subsonic-response>",
),
)
val entries = SubsonicSource(1, client()).playlistSongs(listOf("p1")).toList().flatten()
assertEquals(listOf("s1", "s2"), entries.map { it.songId })
assertEquals(listOf(0L, 1L), entries.map { it.position })
}
@Test
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
@@ -37,7 +37,7 @@ class SyncManagerTest {
.build()
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queueRepository = QueueRepository(db)
manager = SyncManager(sourceRepository, queueRepository)
manager = SyncManager(db, sourceRepository, queueRepository)
}
@After