Mirror: медленный list(500) не роняет каталог и не дёргается в цикле

- отдельный defaultMirrorHttpClient (connect 15с / request 90с) + 1 повтор на list():
  тяжёлый GET /api/mirror?limit=N на сервере дольше общего 30с-таймаута и подёргивается (503);
- каталог: зеркала грузятся в отдельном try — сбой/таймаут не чистит уже загруженный каталог,
  просто временно без бейджей;
- детали: убрали тяжёлый list(limit=500) каждые 3 с (progress зеркала на сервере не живой, 0
  до конца) — в цикле опрашивается только дешёвый mirrorByItem.
This commit is contained in:
2026-09-02 23:02:56 +03:00
parent 40723fa584
commit 0b3f9ff975
4 changed files with 70 additions and 12 deletions
@@ -52,6 +52,7 @@ import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.WatchProgress import pw.binom.viewmate.phone.WatchProgress
import pw.binom.viewmate.phone.log
/** Вкладки каталога: Фильмы / Сериалы / Папки. */ /** Вкладки каталога: Фильмы / Сериалы / Папки. */
enum class CatalogTab { MOVIES, SERIES, FOLDERS } enum class CatalogTab { MOVIES, SERIES, FOLDERS }
@@ -205,7 +206,6 @@ class CatalogState(
} }
} }
items = loaded items = loaded
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -214,6 +214,15 @@ class CatalogState(
} finally { } finally {
loading = false loading = false
} }
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
// уже загруженный каталог, просто временно без бейджей.
try {
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
}
} }
} }
@@ -78,16 +78,16 @@ fun DetailsScreen(
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState() val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState() val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем // статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
LaunchedEffect(item.Id, refreshKey) { LaunchedEffect(item.Id, refreshKey) {
loading = true loading = true
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
loading = false loading = false
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") { while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
delay(3_000) delay(3_000)
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull() mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
} }
} }
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
} }
} }
/**
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
*/
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
)
}
install(HttpTimeout) {
connectTimeoutMillis = 15_000
requestTimeoutMillis = 90_000
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
}
}
level = LogLevel.INFO
}
}
/** Флаг debug-логирования HTTP (по умолчанию включён). */ /** Флаг debug-логирования HTTP (по умолчанию включён). */
object HttpClientDebugLogging { object HttpClientDebugLogging {
var enabled: Boolean = true var enabled: Boolean = true
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
class MirrorClient( class MirrorClient(
private val baseUrl: String, // https://mirror.binom.pw private val baseUrl: String, // https://mirror.binom.pw
private val apiKey: String, // X-API-Key private val apiKey: String, // X-API-Key
private val http: HttpClient = defaultHttpClient(), private val http: HttpClient = defaultMirrorHttpClient(),
) { ) {
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */ /** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
suspend fun createMirror(itemId: String): MirrorJob { suspend fun createMirror(itemId: String): MirrorJob {
@@ -42,8 +44,11 @@ class MirrorClient(
return response.body() return response.body()
} }
/** Список зеркал: статус (null — все) + лимит. */ /** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> { suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
var last: Exception? = null
repeat(LIST_ATTEMPTS) { attempt ->
try {
val response = http.get("$baseUrl/api/mirror") { val response = http.get("$baseUrl/api/mirror") {
header("X-API-Key", apiKey) header("X-API-Key", apiKey)
parameter("limit", limit) parameter("limit", limit)
@@ -51,6 +56,14 @@ class MirrorClient(
} }
response.ensureSuccess("mirror.list") response.ensureSuccess("mirror.list")
return response.body() return response.body()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
last = e
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
}
}
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
} }
/** Удаление зеркала (204). */ /** Удаление зеркала (204). */
@@ -69,4 +82,11 @@ class MirrorClient(
response.ensureSuccess("mirror.scan") response.ensureSuccess("mirror.scan")
return response.body() return response.body()
} }
companion object {
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
private const val LIST_ATTEMPTS = 2
private const val LIST_RETRY_DELAY_MS = 1_500L
}
} }