From b49fbdca462b468916b71544e0201653b6fc356d Mon Sep 17 00:00:00 2001
From: beyond
Date: Sun, 6 Sep 2026 12:34:42 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B5=84=E6=BA=90=E6=95=B0?=
=?UTF-8?q?=E9=87=8F=E6=98=BE=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../myapplication/data/EmbyApiModels.kt | 11 +-
.../myapplication/data/EmbyRepository.kt | 47 +-----
.../myapplication/data/EmbyResourceStats.kt | 54 +++++++
.../myapplication/data/ServerRepository.kt | 2 +-
.../myapplication/screens/HomeScreen.kt | 22 ++-
.../data/EmbyResourceStatsTest.kt | 142 ++++++++++++++++++
6 files changed, 224 insertions(+), 54 deletions(-)
create mode 100644 app/src/main/kotlin/com/example/myapplication/data/EmbyResourceStats.kt
create mode 100644 app/src/test/kotlin/com/example/myapplication/data/EmbyResourceStatsTest.kt
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
index a4f32fc..6f14d00 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt
@@ -167,7 +167,16 @@ data class EmbyItemCounts(
data class EmbyResourceStats(
val withVersions: EmbyItemCounts = EmbyItemCounts(),
val withoutVersions: EmbyItemCounts = EmbyItemCounts(),
-)
+ val versionsAvailable: Boolean = false,
+ val groupedAvailable: Boolean = false,
+) {
+ fun updatedWith(latest: EmbyResourceStats): EmbyResourceStats = EmbyResourceStats(
+ withVersions = if (latest.versionsAvailable) latest.withVersions else withVersions,
+ withoutVersions = if (latest.groupedAvailable) latest.withoutVersions else withoutVersions,
+ versionsAvailable = versionsAvailable || latest.versionsAvailable,
+ groupedAvailable = groupedAvailable || latest.groupedAvailable,
+ )
+}
data class EmbyChapter(
@SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L,
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
index f7c54ba..f8c7595 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt
@@ -333,51 +333,8 @@ object EmbyRepository {
} catch (_: Exception) { emptyList() }
}
- /**
- * Counts the server's video resources.
- * Returns grouped counts (unique titles) from Emby's TotalRecordCount.
- * Emby API does not provide a separate "with versions" count efficiently.
- */
- suspend fun getResourceStats(server: EmbyServer): EmbyResourceStats = withContext(Dispatchers.IO) {
- fun groupedCount(itemType: String): Int? = try {
- val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon()
- .appendQueryParameter("Recursive", "true")
- .appendQueryParameter("IncludeItemTypes", itemType)
- .appendQueryParameter("Limit", "1")
- .appendQueryParameter("EnableImages", "false")
- .appendQueryParameter("EnableTotalRecordCount", "true")
- .build()
- val request = Request.Builder()
- .url(url.toString())
- .header("X-Emby-Token", server.apiKey)
- .get()
- .build()
- client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) null
- else {
- val body = response.body?.string().orEmpty()
- gson.fromJson(body, EmbyItemsResponse::class.java)?.totalRecordCount
- }
- }
- } catch (_: Exception) {
- null
- }
-
- val movieCount = async { groupedCount("Movie") }
- val seriesCount = async { groupedCount("Series") }
- val episodeCount = async { groupedCount("Episode") }
-
- val grouped = EmbyItemCounts(
- movieCount = movieCount.await() ?: 0,
- seriesCount = seriesCount.await() ?: 0,
- episodeCount = episodeCount.await() ?: 0,
- )
-
- EmbyResourceStats(
- withVersions = grouped,
- withoutVersions = grouped,
- )
- }
+ suspend fun getResourceStats(server: EmbyServer, includeVersions: Boolean = true): EmbyResourceStats =
+ fetchResourceStats(server, client, includeVersions)
/** Search movies and series in the authenticated Emby library. */
suspend fun searchItems(server: EmbyServer, query: String, limit: Int = 40): List = withContext(Dispatchers.IO) {
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyResourceStats.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyResourceStats.kt
new file mode 100644
index 0000000..32bf790
--- /dev/null
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyResourceStats.kt
@@ -0,0 +1,54 @@
+package com.example.myapplication.data
+
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+internal suspend fun fetchResourceStats(
+ server: EmbyServer,
+ client: OkHttpClient,
+ includeVersions: Boolean = true,
+): EmbyResourceStats = withContext(Dispatchers.IO) {
+ val gson = Gson()
+ fun counts(grouped: Boolean): EmbyItemCounts? = try {
+ // UserId makes Emby group versions. Omit it for the server's raw totals.
+ val url = "${server.fullUrl}/emby/Items/Counts".toHttpUrl().newBuilder()
+ .apply { if (grouped) addQueryParameter("UserId", server.userId) }
+ .build()
+ val request = Request.Builder()
+ .url(url)
+ .header("X-Emby-Token", server.apiKey)
+ .get()
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use null
+ val json = gson.fromJson(response.body?.string().orEmpty(), JsonObject::class.java)
+ ?: return@use null
+ if (listOf("MovieCount", "SeriesCount", "EpisodeCount").any {
+ !json.has(it) || json[it].isJsonNull
+ }) return@use null
+ gson.fromJson(json, EmbyItemCounts::class.java)
+ }
+ } catch (error: CancellationException) {
+ throw error
+ } catch (_: Exception) {
+ null
+ }
+
+ val rawDeferred = async { if (includeVersions) counts(grouped = false) else null }
+ val groupedDeferred = async { counts(grouped = true) }
+ val raw = rawDeferred.await()
+ val grouped = groupedDeferred.await()
+ EmbyResourceStats(
+ withVersions = raw ?: EmbyItemCounts(),
+ withoutVersions = grouped ?: EmbyItemCounts(),
+ versionsAvailable = raw != null,
+ groupedAvailable = grouped != null,
+ )
+}
diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
index 3223a3f..b879947 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
@@ -121,7 +121,7 @@ object ServerRepository {
apiKey = authResult.accessToken,
userId = authResult.userId,
)
- val stats = EmbyRepository.getResourceStats(authenticatedServer).withoutVersions
+ val stats = EmbyRepository.getResourceStats(authenticatedServer, includeVersions = false).withoutVersions
// P0-4.1: Single atomic update for all fields instead of 3 separate reload() calls
_servers.update { current ->
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
index c546df8..1748fb1 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
@@ -668,7 +668,8 @@ private fun ResourceStatsCard(
stringResource(Res.string.resource_shows),
stringResource(Res.string.resource_episodes),
)
- val values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) }
+ val values = (if (stats.versionsAvailable) stats.withVersions else stats.withoutVersions)
+ .run { listOf(movieCount, seriesCount, episodeCount) }
Row(
Modifier
.fillMaxWidth()
@@ -690,7 +691,7 @@ private fun ResourceStatsCard(
textAlign = TextAlign.Center,
)
RainbowNumber(
- text = String.format("%,d", value),
+ text = if (stats.versionsAvailable || stats.groupedAvailable) String.format("%,d", value) else "—",
fontSize = 18.sp,
)
}
@@ -731,16 +732,23 @@ private fun ResourceStatsDialog(
textAlign = TextAlign.Center,
)
ResourceStatsGroup(
- title = stringResource(Res.string.resource_total),
+ title = stringResource(Res.string.resource_with_versions),
labels = labels,
- values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) },
+ values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) }
+ .map { it.takeIf { stats.versionsAvailable } },
+ )
+ ResourceStatsGroup(
+ title = stringResource(Res.string.resource_without_versions),
+ labels = labels,
+ values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) }
+ .map { it.takeIf { stats.groupedAvailable } },
)
}
}
}
@Composable
-private fun ResourceStatsGroup(title: String, labels: List, values: List) {
+private fun ResourceStatsGroup(title: String, labels: List, values: List) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, color = GlassTokens.subtext, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth()) {
@@ -757,7 +765,7 @@ private fun ResourceStatsGroup(title: String, labels: List, values: List
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
- RainbowNumber(String.format("%,d", value), fontSize = 16.sp)
+ RainbowNumber(value?.let { String.format("%,d", it) } ?: "—", fontSize = 16.sp)
}
}
}
@@ -857,7 +865,7 @@ fun HomeScreen(
resumeItems = resumeDeferred.await()
loading = false
libraries = libsDeferred.await()
- resourceStats = statsDeferred.await()
+ resourceStats = resourceStats.updatedWith(statsDeferred.await())
HomeContentCache.putSnapshot(
cacheKey,
HomeSnapshot(
diff --git a/app/src/test/kotlin/com/example/myapplication/data/EmbyResourceStatsTest.kt b/app/src/test/kotlin/com/example/myapplication/data/EmbyResourceStatsTest.kt
new file mode 100644
index 0000000..8c258a7
--- /dev/null
+++ b/app/src/test/kotlin/com/example/myapplication/data/EmbyResourceStatsTest.kt
@@ -0,0 +1,142 @@
+package com.example.myapplication.data
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.atomic.AtomicInteger
+
+class EmbyResourceStatsTest {
+ private val server = EmbyServer(
+ id = "test-server", host = "example.test", basePath = "/media",
+ userId = "test-user", apiKey = "test-token",
+ )
+ private val raw = """{"MovieCount":7536,"SeriesCount":2584,"EpisodeCount":82434}"""
+ private val grouped = """{"MovieCount":7298,"SeriesCount":2474,"EpisodeCount":81694}"""
+
+ @Test
+ fun largeLibraryUsesTwoTotalsRequestsWithoutScanningItems() = runBlocking {
+ val requests = AtomicInteger()
+ val stats = fetchResourceStats(server, client {
+ requests.incrementAndGet()
+ if (it) grouped else raw
+ })
+
+ assertEquals(2, requests.get())
+ assertEquals(EmbyItemCounts(7536, 2584, 82434), stats.withVersions)
+ assertEquals(EmbyItemCounts(7298, 2474, 81694), stats.withoutVersions)
+ assertTrue(stats.versionsAvailable)
+ assertTrue(stats.groupedAvailable)
+ }
+
+ @Test
+ fun versionFailureKeepsGroupedCountsAvailable() = runBlocking {
+ val stats = fetchResourceStats(server, client { if (it) grouped else null })
+
+ assertFalse(stats.versionsAvailable)
+ assertTrue(stats.groupedAvailable)
+ assertEquals(EmbyItemCounts(7298, 2474, 81694), stats.withoutVersions)
+ }
+
+ @Test
+ fun groupedFailureKeepsVersionCountsAvailable() = runBlocking {
+ val stats = fetchResourceStats(server, client { if (it) null else raw })
+
+ assertTrue(stats.versionsAvailable)
+ assertFalse(stats.groupedAvailable)
+ assertEquals(EmbyItemCounts(7536, 2584, 82434), stats.withVersions)
+ }
+
+ @Test
+ fun failedRefreshKeepsLastSuccessfulNumbers() = runBlocking {
+ val previous = fetchResourceStats(server, client { if (it) grouped else raw })
+ val failure = fetchResourceStats(server, client { null })
+
+ assertEquals(previous, previous.updatedWith(failure))
+ }
+
+ @Test
+ fun partialRefreshUpdatesOnlySuccessfulGroup() = runBlocking {
+ val previous = fetchResourceStats(server, client { if (it) grouped else raw })
+ val next = fetchResourceStats(server, client {
+ if (it) """{"MovieCount":7300,"SeriesCount":2474,"EpisodeCount":81694}""" else null
+ })
+ val updated = previous.updatedWith(next)
+
+ assertEquals(previous.withVersions, updated.withVersions)
+ assertEquals(7300, updated.withoutVersions.movieCount)
+ assertTrue(updated.versionsAvailable)
+ assertTrue(updated.groupedAvailable)
+ }
+
+ @Test
+ fun actualEmptyLibraryCanReplaceOldCountsWithZero() = runBlocking {
+ val previous = fetchResourceStats(server, client { if (it) grouped else raw })
+ val empty = fetchResourceStats(server, client {
+ """{"MovieCount":0,"SeriesCount":0,"EpisodeCount":0}"""
+ })
+ val updated = previous.updatedWith(empty)
+
+ assertEquals(EmbyItemCounts(), updated.withVersions)
+ assertEquals(EmbyItemCounts(), updated.withoutVersions)
+ assertTrue(updated.versionsAvailable)
+ assertTrue(updated.groupedAvailable)
+ }
+
+ @Test
+ fun incompletePayloadIsNotTreatedAsAnEmptyLibrary() = runBlocking {
+ val stats = fetchResourceStats(server, client { if (it) "{}" else "null" })
+
+ assertFalse(stats.versionsAvailable)
+ assertFalse(stats.groupedAvailable)
+ }
+
+ @Test
+ fun serverStatusOnlyRequestsUserTotals() = runBlocking {
+ val requests = AtomicInteger()
+ val stats = fetchResourceStats(server, client {
+ assertTrue(it)
+ requests.incrementAndGet()
+ grouped
+ }, includeVersions = false)
+
+ assertEquals(1, requests.get())
+ assertEquals(EmbyItemCounts(7298, 2474, 81694), stats.withoutVersions)
+ }
+
+ @Test(expected = CancellationException::class)
+ fun cancellationIsNotConvertedToEmptyStatistics() = runBlocking {
+ fetchResourceStats(server, client { throw CancellationException("Cancelled") })
+ }
+
+ private fun client(responseBody: (Boolean) -> String?): OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val request = chain.request()
+ val url = request.url
+ assertEquals("test-token", request.header("X-Emby-Token"))
+ assertEquals("/media/emby/Items/Counts", url.encodedPath)
+ val isGrouped = url.queryParameter("UserId") != null
+ if (isGrouped) {
+ assertEquals("test-user", url.queryParameter("UserId"))
+ assertEquals(setOf("UserId"), url.queryParameterNames)
+ } else {
+ // Sending UserId here reproduces the original equal-count regression.
+ assertTrue(url.queryParameterNames.isEmpty())
+ }
+ val body = responseBody(isGrouped)
+ Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(if (body == null) 500 else 200)
+ .message("Test response")
+ .body(body.orEmpty().toResponseBody())
+ .build()
+ }
+ .build()
+}