优化资源数量显示

This commit is contained in:
beyond committed 2026-09-06 12:34:42 +08:00
1 parent 7a06ec9a56
commit b49fbdca46
6 files changed
+224 -54

No files matched your search

@@ -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,
@@ -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<EmbyItem> = withContext(Dispatchers.IO) {
@@ -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,
)
}
@@ -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 ->
@@ -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<String>, values: List<Int>) {
private fun ResourceStatsGroup(title: String, labels: List<String>, values: List<Int?>) {
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<String>, 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(
@@ -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<Unit> {
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()
}