feat: сетевая часть для app-phone (WS-сервер) и app-glasses (WS-клиент), PhoneState/PhoneActions → lib-core
lib-core: - core/phone/: PhoneState, PhoneActions, GlassesSender (интерфейс) — перенесены из app-host - core/net/: GlassesWsClient — KMP WS-клиент очков (реконнект, Hello, send GlassesToHost) - GlassesWsClientTest + PhoneActionsTest (перенесён) app-phone (Android): - GlassesServer: Ktor WS-сервер (CIO) 0.0.0.0:8080/ws/glasses, принимает очки, Welcome из стейта - PhoneApp (Application): клиенты, стейт, действия, сервер - PhoneConfig: креды Jellyfin/Mirror - MainActivity: статус сервера (порт, счётчик подключений) - GlassesServerTest: hello→welcome, gesture app-glasses (Android): - GlassesApp (Application), HostConnection: WS-клиент к телефону, Hello, реконнект, обработка HostToGlasses (PlayVideo/SetMode и т.д.), StateFlow для UI - MainActivity: статус подключения app-host: PhoneActions/PhoneState удалены (теперь lib-core), адаптирован под GlassesSender 171 тест, 0 провалов; app-host жив (catalog/watch/status работают через lib-core)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
package pw.binom.viewmate.core
|
||||
|
||||
/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
|
||||
fun log(tag: String, message: String) {
|
||||
println("[view-mate] $tag $message")
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror):
|
||||
@@ -45,6 +47,31 @@ object HttpClientDebugLogging {
|
||||
var enabled: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-клиент для WS-протокола очков (GlassesWsClient):
|
||||
* JSON-сериализация kotlinx с дискриминатором "type", таймауты, поддержка WS.
|
||||
*/
|
||||
fun defaultWsClient(): HttpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(protocolJson)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 5_000
|
||||
requestTimeoutMillis = 30_000
|
||||
}
|
||||
install(WebSockets) {
|
||||
pingIntervalMillis = 15_000
|
||||
}
|
||||
install(Logging) {
|
||||
logger = object : Logger {
|
||||
override fun log(message: String) {
|
||||
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
|
||||
}
|
||||
}
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
/** Ошибка HTTP-запроса с понятным текстом. */
|
||||
class ApiClientException(
|
||||
message: String,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package pw.binom.viewmate.core.net
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.media.defaultWsClient
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* WS-клиент очков: подключается к хосту (телефону), шлёт [GlassesToHost],
|
||||
* принимает [HostToGlasses]. [connect] — бесконечный цикл приёма с
|
||||
* реконнектом через 3с при обрыве.
|
||||
*/
|
||||
class GlassesWsClient(
|
||||
private val url: String, // ws://<host>:8080/ws/glasses
|
||||
private val http: HttpClient = defaultWsClient(),
|
||||
) {
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
@Volatile
|
||||
private var session: DefaultClientWebSocketSession? = null
|
||||
|
||||
/**
|
||||
* Подключиться и слушать. Возвращается при вызове [close].
|
||||
* [onHostMessage] — входящие от хоста; [onConnected]/[onDisconnected] —
|
||||
* события (пере)подключения (для Hello и смены статуса в UI).
|
||||
*/
|
||||
suspend fun connect(
|
||||
onHostMessage: suspend (HostToGlasses) -> Unit,
|
||||
onConnected: suspend () -> Unit = {},
|
||||
onDisconnected: suspend () -> Unit = {},
|
||||
) {
|
||||
while (!closed) {
|
||||
try {
|
||||
val ws = http.webSocketSession(url)
|
||||
session = ws
|
||||
onConnected()
|
||||
try {
|
||||
for (frame in ws.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
val msg = protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||
onHostMessage(msg)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
session = null
|
||||
onDisconnected()
|
||||
ws.close()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("ws-client", "соединение оборвано (${e.message}), реконнект через 3с")
|
||||
}
|
||||
if (closed) break
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Отправить сообщение хосту. Без активного соединения — no-op. */
|
||||
suspend fun send(msg: GlassesToHost) {
|
||||
val text = protocolJson.encodeToString(GlassesToHost.serializer(), msg)
|
||||
session?.send(text)
|
||||
}
|
||||
|
||||
/** Закрыть клиент: выйти из цикла [connect] и закрыть активное соединение. */
|
||||
fun close() {
|
||||
closed = true
|
||||
session?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pw.binom.viewmate.core.phone
|
||||
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
|
||||
/** Куда слать сообщения очкам (реализуется WS-хабом телефона/хоста). */
|
||||
interface GlassesSender {
|
||||
suspend fun send(msg: HostToGlasses)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package pw.binom.viewmate.core.phone
|
||||
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
|
||||
/**
|
||||
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
|
||||
* Каждый метод возвращает человекочитаемый текст для консоли/UI
|
||||
* и дёргает [GlassesSender] для реальных команд очкам.
|
||||
*/
|
||||
class PhoneActions(
|
||||
private val jellyfin: JellyfinClient,
|
||||
private val mirror: MirrorClient,
|
||||
private val sender: GlassesSender,
|
||||
private val state: PhoneState,
|
||||
private val userId: String, // первый пользователь Jellyfin
|
||||
) {
|
||||
|
||||
// --- каталог ---
|
||||
|
||||
/** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */
|
||||
suspend fun catalog(parentId: String? = null): String {
|
||||
val items = if (parentId == null) jellyfin.rootItems(userId) else jellyfin.items(userId, parentId)
|
||||
val mirrors = mirror.list(limit = 500).associateBy { it.itemId }
|
||||
|
||||
val rows = items.map { item -> catalogLine(item, mirrors[item.Id]) }
|
||||
val width = rows.maxOfOrNull { it.first.length } ?: 0
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine(if (parentId == null) "Каталог Jellyfin (корень):" else "Каталог Jellyfin (папка $parentId):")
|
||||
rows.forEach { (prefix, badge) ->
|
||||
sb.appendLine(" ${prefix.padEnd(width)} $badge")
|
||||
}
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
private fun catalogLine(item: JellyfinItem, job: MirrorJob?): Pair<String, String> {
|
||||
val prefix = "${typeLabel(item)} ${item.Name ?: item.Id} (${item.Id})"
|
||||
val badge = when {
|
||||
job == null -> "нет"
|
||||
job.status == "done" -> "✓"
|
||||
job.status == "processing" || job.status == "queued" -> "⟳ ${job.progress ?: 0}%"
|
||||
else -> job.status ?: "нет"
|
||||
}
|
||||
return prefix to badge
|
||||
}
|
||||
|
||||
private fun typeLabel(item: JellyfinItem): String = when (item.Type) {
|
||||
"Folder", "CollectionFolder" -> "[Папка]"
|
||||
"Movie" -> "[Фильм]"
|
||||
"Series" -> "[Сериал]"
|
||||
"Episode" -> "[Эпизод]"
|
||||
else -> "[${item.Type ?: "Элемент"}]"
|
||||
}
|
||||
|
||||
// --- открытие элемента ---
|
||||
|
||||
/** Инфо об элементе + статус зеркала + дорожки озвучки. */
|
||||
suspend fun open(itemId: String): String {
|
||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("${typeLabel(item)} ${item.Name ?: itemId} (${item.Id})")
|
||||
item.Overview?.let { sb.appendLine("Описание: ${it.take(200)}") }
|
||||
item.RunTimeTicks?.let { sb.appendLine("Длительность: ${ticksToMinutes(it)} мин") }
|
||||
sb.append(mirrorStatusText(status))
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
// --- включить на очках ---
|
||||
|
||||
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0): String {
|
||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
|
||||
}
|
||||
|
||||
val title = item.Name ?: itemId
|
||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = files.video.url, audioIndex = audioIndex))
|
||||
state.mode = GlassesMode.MOVIE
|
||||
state.currentItem = item
|
||||
state.currentAudioIndex = audioIndex
|
||||
state.mirrorStatus = status
|
||||
state.playing = true
|
||||
return "включил «$title» на очках (озвучка #$audioIndex): ${files.video.url}"
|
||||
}
|
||||
|
||||
// --- зеркала ---
|
||||
|
||||
/** Заказать конвертацию. */
|
||||
suspend fun makeMirror(itemId: String): String {
|
||||
val job = mirror.createMirror(itemId)
|
||||
return "заказана конвертация $itemId: id=${job.id}, статус=${job.status}"
|
||||
}
|
||||
|
||||
/** Статус зеркала по itemId. */
|
||||
suspend fun mirrorStatus(itemId: String): String {
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
return mirrorStatusText(status)
|
||||
}
|
||||
|
||||
private fun mirrorStatusText(status: MirrorStatus?): String {
|
||||
if (status == null) return "Зеркало: нет (закажите: mirror <id>)"
|
||||
val lines = mutableListOf<String>()
|
||||
lines += "Зеркало: ${status.status}${if (status.status == "done") " ✓" else ""}"
|
||||
val files = status.files
|
||||
if (files != null) {
|
||||
lines += " Видео: ${files.video.url}"
|
||||
if (files.audios.isEmpty()) {
|
||||
lines += " Дорожки: нет"
|
||||
} else {
|
||||
lines += " Дорожки:"
|
||||
files.audios.forEach { a ->
|
||||
lines += " [${a.index}] ${a.title ?: a.language ?: "без названия"} → ${a.url}"
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
// --- скачивание (эмуляция) ---
|
||||
|
||||
/** Скачать на очки/телефон (эмуляция: отметить в стейте + лог). */
|
||||
fun download(itemId: String, target: String): String {
|
||||
state.downloaded[itemId] = target
|
||||
log("phone", "скачано $itemId → $target (эмуляция)")
|
||||
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
|
||||
}
|
||||
|
||||
/** Удалить скачанное (эмуляция). */
|
||||
fun deleteDownloaded(itemId: String): String {
|
||||
val removed = state.downloaded.remove(itemId)
|
||||
return if (removed != null) "удалено скачанное: $itemId ($removed)"
|
||||
else "не было скачано: $itemId"
|
||||
}
|
||||
|
||||
// --- сессии ---
|
||||
|
||||
fun sessions(): String {
|
||||
val sb = StringBuilder("Сессии:")
|
||||
state.sessions.forEach { s ->
|
||||
val marker = if (s.id == state.activeSessionId) " ◄ активна" else ""
|
||||
sb.appendLine(" ${s.id} — ${s.title}$marker")
|
||||
}
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
/** Переключить сессию и разослать ChatSessionMsg очкам. */
|
||||
suspend fun selectSession(id: String): String {
|
||||
val session = state.sessions.firstOrNull { it.id == id }
|
||||
?: return "сессия «$id» не найдена. Доступны: ${state.sessions.joinToString(", ") { it.id }}"
|
||||
state.activeSessionId = id
|
||||
sender.send(ChatSessionMsg(sessionId = id, title = session.title))
|
||||
return "выбрана сессия: ${session.title} (${session.id})"
|
||||
}
|
||||
|
||||
// --- состояние ---
|
||||
|
||||
/** Что сейчас на очках/в телефоне. */
|
||||
fun status(): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("Режим: ${state.mode}")
|
||||
sb.appendLine("Играет: ${state.currentItem?.Name ?: state.currentItem?.Id ?: "ничего"} " +
|
||||
"(${state.currentItem?.Id ?: "-"})")
|
||||
sb.appendLine("Озвучка: #${state.currentAudioIndex}")
|
||||
sb.appendLine("Воспроизведение: ${if (state.playing) "играет" else "пауза"}")
|
||||
sb.appendLine("Зеркало: ${state.mirrorStatus?.status ?: "нет"}")
|
||||
sb.appendLine("Сессия: ${state.activeSessionId ?: "нет"}")
|
||||
val dl = state.downloaded.entries.joinToString("; ") { "${it.key} → ${it.value}" }
|
||||
sb.appendLine("Скачано: ${dl.ifEmpty { "ничего" }}")
|
||||
return sb.toString().trimEnd()
|
||||
}
|
||||
|
||||
// --- прямые команды очкам (шлются через GlassesSender) ---
|
||||
|
||||
/** Переключить режим очков: SetMode + обновить стейт. */
|
||||
suspend fun applyMode(mode: GlassesMode): String {
|
||||
state.mode = mode
|
||||
sender.send(SetMode(mode))
|
||||
return "режим → $mode"
|
||||
}
|
||||
|
||||
/** Состояние ассистента: AssistantStateMsg на очки. */
|
||||
suspend fun applyAssistantState(assistantState: AssistantState, sec: Int): String {
|
||||
sender.send(AssistantStateMsg(state = assistantState, recordingSeconds = sec))
|
||||
return "ассистент → $assistantState (${sec}с)"
|
||||
}
|
||||
|
||||
/** Показать текст на очках: ShowText. */
|
||||
suspend fun applyText(text: String): String {
|
||||
sender.send(ShowText(text = text))
|
||||
return "показан текст: $text"
|
||||
}
|
||||
|
||||
/** Команда медиа: MediaCommandMsg на очки. */
|
||||
suspend fun applyMediaCommand(cmd: MediaCommand, seekMs: Long = 0): String {
|
||||
sender.send(MediaCommandMsg(command = cmd, seekMs = seekMs))
|
||||
return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}"
|
||||
}
|
||||
|
||||
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pw.binom.viewmate.core.phone
|
||||
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
|
||||
/** Сессия чата (эмуляция) — простой DTO. */
|
||||
data class SessionInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Единый стейт «телефона» (как у будущего приложения).
|
||||
* Изменение любого поля логируется в консоль.
|
||||
*/
|
||||
class PhoneState {
|
||||
|
||||
@Volatile
|
||||
var mode: GlassesMode = GlassesMode.MOVIE
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "режим → $value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var currentItem: JellyfinItem? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "текущий → ${value?.Name ?: value?.Id ?: "ничего"}")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var currentAudioIndex: Int = 0
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "озвучка → #$value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var mirrorStatus: MirrorStatus? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "зеркало → ${value?.status ?: "нет"}")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var activeSessionId: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", "сессия → $value")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var playing: Boolean = false
|
||||
set(value) {
|
||||
field = value
|
||||
log("state", if (value) "воспроизведение" else "пауза")
|
||||
}
|
||||
|
||||
/** Эмуляция сессий чата: 3 фейковых + текущая. */
|
||||
val sessions: MutableList<SessionInfo> = mutableListOf(
|
||||
SessionInfo("glasses-movie", "Очки/кино"),
|
||||
SessionInfo("work", "Работа"),
|
||||
SessionInfo("general", "Общее"),
|
||||
)
|
||||
|
||||
/** Эмуляция скачанного: itemId → куда (очки|телефон). */
|
||||
val downloaded: MutableMap<String, String> = mutableMapOf()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Общий Json для WS-протокола: полиморфизм через дискриминатор "type"
|
||||
* (как описано в TASK.md и используется в app-host).
|
||||
*/
|
||||
val protocolJson: Json = Json {
|
||||
classDiscriminator = "type"
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package pw.binom.viewmate.core.phone
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.MockRequestHandleScope
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.client.request.HttpResponseData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.content.TextContent
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.media.CreateMirrorRequest
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PhoneActionsTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val rootItemsJson = """
|
||||
{"Items":[
|
||||
{"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"},
|
||||
{"Id":"m1","Name":"Интерстеллар","Type":"Movie"},
|
||||
{"Id":"m2","Name":"Дюна","Type":"Movie"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
private fun MockRequestHandleScope.jsonResponse(body: String) = respond(
|
||||
content = body,
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
|
||||
private fun mockHttp(
|
||||
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
|
||||
): HttpClient = HttpClient(MockEngine { request -> handler(request) }) {
|
||||
install(ContentNegotiation) { json(json) }
|
||||
}
|
||||
|
||||
private fun jellyfin(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): JellyfinClient =
|
||||
JellyfinClient(
|
||||
baseUrl = "https://jellyfin.binom.pw",
|
||||
apiKey = "tok",
|
||||
http = mockHttp(handler),
|
||||
)
|
||||
|
||||
private fun mirror(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): MirrorClient =
|
||||
MirrorClient(
|
||||
baseUrl = "https://mirror.binom.pw",
|
||||
apiKey = "secret",
|
||||
http = mockHttp(handler),
|
||||
)
|
||||
|
||||
private fun simpleJellyfin(): JellyfinClient = jellyfin { request ->
|
||||
when (request.url.encodedPath) {
|
||||
"/Users/u1/Items" -> jsonResponse(rootItemsJson)
|
||||
else -> jsonResponse("""{"Id":"m1","Name":"Интерстеллар","Type":"Movie","Overview":"Лучший фильм","RunTimeTicks":148800000000}""")
|
||||
}
|
||||
}
|
||||
|
||||
private fun doneMirror(): MirrorClient = mirror { request ->
|
||||
when (request.url.encodedPath) {
|
||||
"/api/mirror/by-item/m1" -> jsonResponse(
|
||||
"""{"itemId":"m1","status":"done",""" +
|
||||
""""files":{"video":{"key":"v1","url":"https://s3/media/m1/video.mp4"},""" +
|
||||
""""audios":[{"index":1,"key":"a1","title":"Русский","language":"ru","url":"https://s3/m1/a1.ogg"},{"index":2,"key":"a2","url":"https://s3/m1/a2.ogg"}]}}"""
|
||||
)
|
||||
else -> jsonResponse("""[]""")
|
||||
}
|
||||
}
|
||||
|
||||
private class SenderStub(val phoneState: PhoneState = PhoneState()) : GlassesSender {
|
||||
val sent = mutableListOf<HostToGlasses>()
|
||||
override suspend fun send(msg: HostToGlasses) {
|
||||
sent += msg
|
||||
}
|
||||
}
|
||||
|
||||
private fun actions(
|
||||
jf: JellyfinClient,
|
||||
mirror: MirrorClient,
|
||||
sender: SenderStub,
|
||||
): PhoneActions = PhoneActions(
|
||||
jellyfin = jf,
|
||||
mirror = mirror,
|
||||
sender = sender,
|
||||
state = sender.phoneState,
|
||||
userId = "u1",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun catalogShowsFoldersWithMirrorBadges() = runTest {
|
||||
val jf = simpleJellyfin()
|
||||
val mirrorClient = mirror { request ->
|
||||
jsonResponse(
|
||||
"""[{"id":"job1","itemId":"m1","status":"done","progress":100},""" +
|
||||
"""{"id":"job2","itemId":"m2","status":"processing","progress":45}]"""
|
||||
)
|
||||
}
|
||||
val out = actions(jf, mirrorClient, SenderStub()).catalog()
|
||||
|
||||
assertTrue(out.contains("Каталог Jellyfin (корень)"))
|
||||
assertTrue(out.contains("Фильмы (f1)"))
|
||||
assertTrue(out.contains("Интерстеллар (m1)"))
|
||||
assertTrue(out.contains("Дюна (m2)"))
|
||||
assertTrue(out.contains("✓"))
|
||||
assertTrue(out.contains("45%"))
|
||||
assertTrue(out.contains("нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun catalogParentShowsFolderContents() = runTest {
|
||||
val jf = jellyfin { request ->
|
||||
when (request.url.encodedPath) {
|
||||
"/Users/u1/Items" -> {
|
||||
assertEquals("f1", request.url.parameters["ParentId"])
|
||||
jsonResponse("""{"Items":[{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья"}]}""")
|
||||
}
|
||||
else -> jsonResponse("""{"Items":[]}""")
|
||||
}
|
||||
}
|
||||
val mirrorClient = mirror { request -> jsonResponse("""[]""") }
|
||||
val out = actions(jf, mirrorClient, SenderStub()).catalog("f1")
|
||||
|
||||
assertTrue(out.contains("папка f1"))
|
||||
assertTrue(out.contains("Пилот (e1)"))
|
||||
assertTrue(out.contains("нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openShowsInfoMirrorAndTracks() = runTest {
|
||||
val out = actions(simpleJellyfin(), doneMirror(), SenderStub()).open("m1")
|
||||
|
||||
assertTrue(out.contains("Интерстеллар (m1)"))
|
||||
assertTrue(out.contains("Лучший фильм"))
|
||||
assertTrue(out.contains("Зеркало: done ✓"))
|
||||
assertTrue(out.contains("https://s3/media/m1/video.mp4"))
|
||||
assertTrue(out.contains("[1] Русский"))
|
||||
assertTrue(out.contains("https://s3/m1/a1.ogg"))
|
||||
assertTrue(out.contains("[2]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
|
||||
|
||||
assertTrue(out.contains("включил «Интерстеллар»"))
|
||||
assertEquals(1, sender.sent.size)
|
||||
val msg = sender.sent.single()
|
||||
assertIs<PlayVideo>(msg)
|
||||
assertEquals("m1", msg.itemId)
|
||||
assertEquals("Интерстеллар", msg.title)
|
||||
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
|
||||
assertEquals(1, msg.audioIndex)
|
||||
|
||||
assertEquals("m1", sender.phoneState.currentItem?.Id)
|
||||
assertEquals(1, sender.phoneState.currentAudioIndex)
|
||||
assertTrue(sender.phoneState.playing)
|
||||
assertEquals("done", sender.phoneState.mirrorStatus?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
|
||||
val sender = SenderStub()
|
||||
val mirrorClient = mirror { request ->
|
||||
respond("", HttpStatusCode.NotFound)
|
||||
}
|
||||
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
|
||||
|
||||
assertTrue(out.contains("не готово"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchUnknownItemReturnsNotFound() = runTest {
|
||||
val sender = SenderStub()
|
||||
val jf = jellyfin { request ->
|
||||
when (request.url.encodedPath) {
|
||||
"/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound)
|
||||
else -> jsonResponse("""{"Items":[]}""")
|
||||
}
|
||||
}
|
||||
val out = actions(jf, doneMirror(), sender).watch("missing")
|
||||
assertTrue(out.contains("не найден"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun makeMirrorPostsCreateMirrorRequest() = runTest {
|
||||
var capturedPath: String? = null
|
||||
var capturedMethod: HttpMethod? = null
|
||||
var capturedBody: String? = null
|
||||
val mirrorClient = mirror { request ->
|
||||
capturedPath = request.url.encodedPath
|
||||
capturedMethod = request.method
|
||||
capturedBody = (request.body as TextContent).text
|
||||
respond(
|
||||
content = """{"id":"m-1","status":"processing"}""",
|
||||
status = HttpStatusCode.Created,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val out = actions(simpleJellyfin(), mirrorClient, SenderStub()).makeMirror("m1")
|
||||
|
||||
assertEquals("/api/mirror", capturedPath)
|
||||
assertEquals(HttpMethod.Post, capturedMethod)
|
||||
val sent = Json.decodeFromString<CreateMirrorRequest>(capturedBody!!)
|
||||
assertEquals("m1", sent.itemId)
|
||||
assertTrue(out.contains("m-1"))
|
||||
assertTrue(out.contains("processing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mirrorStatusReturnsText() = runTest {
|
||||
val done = actions(simpleJellyfin(), doneMirror(), SenderStub()).mirrorStatus("m1")
|
||||
assertTrue(done.contains("done"))
|
||||
assertTrue(done.contains("✓"))
|
||||
|
||||
val missing = mirror { request -> respond("", HttpStatusCode.NotFound) }
|
||||
val none = actions(simpleJellyfin(), missing, SenderStub()).mirrorStatus("m1")
|
||||
assertTrue(none.contains("Зеркало: нет"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectSessionBroadcastsChatSessionMsg() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("work")
|
||||
|
||||
assertTrue(out.contains("Работа"))
|
||||
assertEquals("work", sender.phoneState.activeSessionId)
|
||||
val msg = sender.sent.single()
|
||||
assertIs<ChatSessionMsg>(msg)
|
||||
assertEquals("work", msg.sessionId)
|
||||
assertEquals("Работа", msg.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectSessionUnknownReturnsError() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).selectSession("nope")
|
||||
assertTrue(out.contains("не найдена"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
assertNull(sender.phoneState.activeSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionsListsAllSessions() = runTest {
|
||||
val sender = SenderStub()
|
||||
sender.phoneState.activeSessionId = "work"
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).sessions()
|
||||
assertTrue(out.contains("glasses-movie — Очки/кино"))
|
||||
assertTrue(out.contains("work — Работа"))
|
||||
assertTrue(out.contains("general — Общее"))
|
||||
assertTrue(out.contains("◄ активна"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadAndDeleteDownloadedEmulate() {
|
||||
val sender = SenderStub()
|
||||
val actions = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
|
||||
val dl = actions.download("m1", "очки")
|
||||
assertTrue(dl.contains("скачано m1"))
|
||||
assertEquals("очки", sender.phoneState.downloaded["m1"])
|
||||
|
||||
val rm = actions.deleteDownloaded("m1")
|
||||
assertTrue(rm.contains("удалено скачанное"))
|
||||
assertFalse(sender.phoneState.downloaded.containsKey("m1"))
|
||||
|
||||
val rm2 = actions.deleteDownloaded("m1")
|
||||
assertTrue(rm2.contains("не было скачано"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusShowsCurrentState() = runTest {
|
||||
val sender = SenderStub()
|
||||
val actions = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
|
||||
val empty = actions.status()
|
||||
assertTrue(empty.contains("Режим: MOVIE"))
|
||||
assertTrue(empty.contains("ничего"))
|
||||
assertTrue(empty.contains("пауза"))
|
||||
|
||||
actions.watch("m1")
|
||||
val after = actions.status()
|
||||
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
|
||||
assertTrue(after.contains("играет"))
|
||||
assertTrue(after.contains("Озвучка: #0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyModeSendsSetModeAndUpdatesState() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).applyMode(pw.binom.viewmate.core.GlassesMode.CHAT)
|
||||
|
||||
assertTrue(out.contains("CHAT"))
|
||||
assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, sender.phoneState.mode)
|
||||
val msg = sender.sent.single()
|
||||
assertIs<pw.binom.viewmate.core.protocol.SetMode>(msg)
|
||||
assertEquals(pw.binom.viewmate.core.GlassesMode.CHAT, msg.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package pw.binom.viewmate.core.net
|
||||
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GlassesWsClientTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH = "/ws/glasses"
|
||||
}
|
||||
|
||||
/** Локальный WS-сервер: Hello → Welcome, всё входящее собирает в список. */
|
||||
private class TestServer(private val port: Int = 0) {
|
||||
val received = ConcurrentLinkedQueue<GlassesToHost>()
|
||||
private val sessions = ConcurrentLinkedQueue<DefaultWebSocketServerSession>()
|
||||
|
||||
private val server: EmbeddedServer<*, *> = embeddedServer(CIO, port = port, host = "127.0.0.1") {
|
||||
install(WebSockets) {
|
||||
pingPeriodMillis = 5_000
|
||||
timeoutMillis = 5_000
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
webSocket(PATH) {
|
||||
sessions.add(this)
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), frame.readText())
|
||||
received.add(msg)
|
||||
if (msg is Hello) {
|
||||
send(protocolJson.encodeToString(
|
||||
HostToGlasses.serializer(),
|
||||
Welcome(mode = GlassesMode.MOVIE),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sessions.remove(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
server.start(wait = false)
|
||||
}
|
||||
|
||||
suspend fun boundPort(): Int = server.engine.resolvedConnectors().first().port
|
||||
|
||||
suspend fun url(): String = "ws://127.0.0.1:${boundPort()}$PATH"
|
||||
|
||||
val sessionCount: Int
|
||||
get() = sessions.size
|
||||
|
||||
suspend fun broadcast(msg: HostToGlasses) {
|
||||
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
|
||||
for (s in sessions) {
|
||||
try {
|
||||
s.send(text)
|
||||
} catch (_: Exception) {
|
||||
// клиент мог отвалиться
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
server.stop(500, 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition()) {
|
||||
if (System.currentTimeMillis() > deadline) {
|
||||
error("условие не выполнено за ${timeoutMs}мс")
|
||||
}
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcomeRoundTrip() {
|
||||
val server = TestServer()
|
||||
try {
|
||||
runBlocking {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val messages = mutableListOf<HostToGlasses>()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = { messages.add(it) },
|
||||
onConnected = { connected.complete(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
withTimeout(10_000) { connected.await() }
|
||||
client.send(Hello(appVersion = "0.1.0"))
|
||||
|
||||
awaitTrue { messages.any { it is Welcome } }
|
||||
val welcome = messages.filterIsInstance<Welcome>().first()
|
||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||
|
||||
client.close()
|
||||
job.cancelAndJoin()
|
||||
}
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendGestureReachesServer() {
|
||||
val server = TestServer()
|
||||
try {
|
||||
runBlocking {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = {},
|
||||
onConnected = { connected.complete(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
withTimeout(10_000) { connected.await() }
|
||||
client.send(Gesture(gesture = "DoubleClick"))
|
||||
|
||||
awaitTrue { server.received.any { it is Gesture } }
|
||||
val gesture = server.received.filterIsInstance<Gesture>().first()
|
||||
assertEquals("DoubleClick", gesture.gesture)
|
||||
|
||||
client.close()
|
||||
job.cancelAndJoin()
|
||||
}
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receivesHostMessage() {
|
||||
val server = TestServer()
|
||||
try {
|
||||
runBlocking {
|
||||
val client = GlassesWsClient(server.url())
|
||||
val messages = mutableListOf<HostToGlasses>()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = { messages.add(it) },
|
||||
onConnected = { connected.complete(Unit) },
|
||||
)
|
||||
}
|
||||
|
||||
withTimeout(10_000) { connected.await() }
|
||||
awaitTrue { server.sessionCount == 1 }
|
||||
|
||||
server.broadcast(SetMode(GlassesMode.CHAT))
|
||||
|
||||
awaitTrue { messages.any { it is SetMode } }
|
||||
val setMode = messages.filterIsInstance<SetMode>().first()
|
||||
assertEquals(GlassesMode.CHAT, setMode.mode)
|
||||
|
||||
client.close()
|
||||
job.cancelAndJoin()
|
||||
}
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectsAfterServerDrop() {
|
||||
val server = TestServer()
|
||||
try {
|
||||
runBlocking {
|
||||
val port = server.boundPort()
|
||||
var connects = 0
|
||||
val client = GlassesWsClient(server.url())
|
||||
val job = launch {
|
||||
client.connect(
|
||||
onHostMessage = {},
|
||||
onConnected = { connects++ },
|
||||
)
|
||||
}
|
||||
|
||||
awaitTrue { connects >= 1 }
|
||||
server.stop()
|
||||
|
||||
// сервер упал → клиент должен продолжать цикл (не выходить) и реконнектиться
|
||||
delay(4_000)
|
||||
kotlin.test.assertTrue(job.isActive, "клиент должен пережить обрыв сервера")
|
||||
|
||||
// поднимаем сервер на том же порту → клиент подключается заново
|
||||
val server2 = TestServer(port)
|
||||
try {
|
||||
awaitTrue { connects >= 2 }
|
||||
client.close()
|
||||
job.cancelAndJoin()
|
||||
} finally {
|
||||
server2.stop()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user