address final review: coalesce sync requests, fix page-cap edge

- SyncManager now uses a conflated request channel instead of tryLock, so a
  request that arrives during a sync (e.g. add-source switching the active
  server) is queued and runs afterwards against the then-active source
  instead of being dropped.
- Paged loops count raw server rows, not accepted entities, so an empty-id
  row on an otherwise full page no longer ends the scan early and prunes
  the unseen tail.
- The cap now fails when the page just past it is non-empty, not only when
  it is full, matching the bound the error message reports.
- fetchRank rethrows the cap instead of downgrading an over-cap album scan
  to empty ranks.
- Docs: the mutex lives in SyncManager, and the prune still holds the
  existing-id set.
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent 7b06b23860
commit 8b43de25f7
5 changed files with 134 additions and 61 deletions
@@ -224,6 +224,52 @@ class SubsonicSourceTest {
assertEquals(1000, albums.size)
}
@Test
fun albumsAbortWhenThePageJustOverTheCapIsNotEmpty() =
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)
if (url.queryParameter("type") != "newest") return MockResponse().setBody(emptyAlbumList())
return when (url.queryParameter("offset")) {
"0", "500" -> MockResponse().setBody(albumPage(url.queryParameter("offset")!!.toInt(), 500))
else -> MockResponse().setBody(albumPage(1000, 1))
}
}
}
val failure =
runCatching {
SubsonicSource(1, client(), maxPages = 2).albums().toList()
}
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
}
@Test
fun emptyIdRowsDoNotEndPaginationEarly() =
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)
if (url.queryParameter("type") != "newest") return MockResponse().setBody(emptyAlbumList())
return when (url.queryParameter("offset")) {
"0" -> MockResponse().setBody(albumPageWithEmptyId(0, 500))
else -> MockResponse().setBody(albumPage(500, 3))
}
}
}
val albums = SubsonicSource(1, client()).albums().toList().flatten()
assertEquals(502, albums.size)
assertEquals("al-502", albums.last().id)
}
@Test
fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking {
@@ -293,6 +339,16 @@ class SubsonicSourceTest {
} +
"</albumList2></subsonic-response>"
private fun albumPageWithEmptyId(
offset: Int,
count: Int,
) = "<subsonic-response status=\"ok\"><albumList2>" +
"<album id=\"\" name=\"No id\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
(offset until offset + count - 1).joinToString("") {
"<album id=\"al-$it\" name=\"Album $it\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>"
} +
"</albumList2></subsonic-response>"
private fun songPage(
prefix: String,
offset: Int,