优化资源数量显示
This commit is contained in:
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()
|
||||
}
|
||||
Reference in new issue
Block a user