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() +}