diff --git a/app-host/build.gradle.kts b/app-host/build.gradle.kts index 56a52b7..24756c2 100644 --- a/app-host/build.gradle.kts +++ b/app-host/build.gradle.kts @@ -22,6 +22,9 @@ dependencies { testImplementation("io.ktor:ktor-client-core:3.5.2") testImplementation("io.ktor:ktor-client-websockets:3.5.2") testImplementation("io.ktor:ktor-client-cio:3.5.2") + testImplementation("io.ktor:ktor-client-mock:3.5.2") + testImplementation("io.ktor:ktor-client-content-negotiation:3.5.2") + testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.5.2") } application { mainClass.set("pw.binom.viewmate.host.MainKt") } diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Config.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Config.kt new file mode 100644 index 0000000..9afce2d --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Config.kt @@ -0,0 +1,51 @@ +package pw.binom.viewmate.host + +import java.io.InputStream +import java.util.Properties + +/** + * Конфигурация хоста: читается env-переменные > properties-файл > дефолты. + * Креды живут в application.properties (resources), не в коде. + */ +data class Config( + val jellyfinUrl: String, + val jellyfinApiKey: String, + val mirrorUrl: String, + val mirrorApiKey: String, +) { + companion object { + const val DEFAULT_JELLYFIN_URL = "https://jellyfin.binom.pw" + const val DEFAULT_JELLYFIN_API_KEY = "59e6380a96a94364ae25d2b7cf9f1359" + const val DEFAULT_MIRROR_URL = "https://mirror.binom.pw" + const val DEFAULT_MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG" + + /** Приоритет: System.getenv > properties-файл > дефолты. */ + fun load( + env: Map = System.getenv(), + properties: Properties = loadProperties(), + ): Config = Config( + jellyfinUrl = env["JELLYFIN_URL"] + ?: properties.getProperty("jellyfin.url") + ?: DEFAULT_JELLYFIN_URL, + jellyfinApiKey = env["JELLYFIN_API_KEY"] + ?: properties.getProperty("jellyfin.apiKey") + ?: DEFAULT_JELLYFIN_API_KEY, + mirrorUrl = env["MIRROR_URL"] + ?: properties.getProperty("mirror.url") + ?: DEFAULT_MIRROR_URL, + mirrorApiKey = env["MIRROR_API_KEY"] + ?: properties.getProperty("mirror.apiKey") + ?: DEFAULT_MIRROR_API_KEY, + ) + + /** Прочитать application.properties с classpath (если есть). */ + internal fun loadProperties(): Properties { + val props = Properties() + val stream: InputStream? = Config::class.java.getResourceAsStream("/application.properties") + if (stream != null) { + stream.use { props.load(it) } + } + return props + } + } +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt index c9d1902..e838010 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt @@ -21,7 +21,10 @@ import java.util.concurrent.ConcurrentHashMap * Менеджер подключённых очков. Потокобезопасен (ConcurrentHashMap). * Раздаёт исходящие сообщения всем очкам и обрабатывает входящие. */ -class GlassesHub(private val json: Json) { +open class GlassesHub( + private val json: Json, + private val state: PhoneState = PhoneState(), +) { private val sessions = ConcurrentHashMap() @@ -34,9 +37,12 @@ class GlassesHub(private val json: Json) { private val _gesturesReceived = MutableStateFlow(0) val gesturesReceived: StateFlow = _gesturesReceived.asStateFlow() - /** Активная сессия чата (выставляется с «телефона»). */ - @Volatile - var sessionId: String? = null + /** Активная сессия чата (синхронизируется со стейтом «телефона»). */ + var sessionId: String? + get() = state.activeSessionId + set(value) { + state.activeSessionId = value + } fun add(session: DefaultWebSocketServerSession) { sessions[session] = Unit @@ -51,7 +57,7 @@ class GlassesHub(private val json: Json) { } /** Отправить сообщение всем подключённым очкам. */ - suspend fun broadcast(msg: HostToGlasses) { + open suspend fun broadcast(msg: HostToGlasses) { val text = json.encodeToString(HostToGlasses.serializer(), msg) for (session in sessions.keys) { try { @@ -64,18 +70,19 @@ class GlassesHub(private val json: Json) { } /** Сменить режим очков и разослать SetMode. */ - suspend fun setMode(mode: GlassesMode) { + open suspend fun setMode(mode: GlassesMode) { _mode.value = mode + state.mode = mode broadcast(pw.binom.viewmate.core.protocol.SetMode(mode)) } /** Обработать входящее текстовое сообщение от очков. */ - suspend fun handle(session: DefaultWebSocketServerSession, text: String) { + open suspend fun handle(session: DefaultWebSocketServerSession, text: String) { val msg = json.decodeFromString(GlassesToHost.serializer(), text) when (msg) { is Hello -> { log("glasses", "hello (app=${msg.appVersion}) → welcome") - send(session, Welcome(mode = _mode.value, sessionId = sessionId)) + send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId)) } is Gesture -> { @@ -94,6 +101,7 @@ class GlassesHub(private val json: Json) { is GlassesOff -> { log("glasses", "очки выключились → пауза (${msg.reason})") + state.playing = false } } } diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt index a3c5657..9b19d27 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.MediaCommand +import pw.binom.viewmate.core.media.JellyfinClient +import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.protocol.AssistantState import pw.binom.viewmate.core.protocol.AssistantStateMsg import pw.binom.viewmate.core.protocol.ChatSessionMsg @@ -18,7 +20,19 @@ import pw.binom.viewmate.core.protocol.ShowText const val HOST_PORT = 8080 fun main() { - val hub = GlassesHub(hostJson) + val config = Config.load() + log("config", "Jellyfin: ${config.jellyfinUrl}, Mirror: ${config.mirrorUrl}") + + val state = PhoneState() + val hub = GlassesHub(hostJson, state) + val actions = PhoneActions( + jellyfin = JellyfinClient(config.jellyfinUrl, config.jellyfinApiKey), + mirror = MirrorClient(config.mirrorUrl, config.mirrorApiKey), + hub = hub, + state = state, + json = hostJson, + ) + val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") { hostModule(hub) } @@ -36,11 +50,11 @@ fun main() { log("hub", "подключено очков: $count") } } - consoleLoop(hub, server) + consoleLoop(actions, hub, server) } } -private suspend fun consoleLoop(hub: GlassesHub, server: EmbeddedServer<*, *>) { +private suspend fun consoleLoop(actions: PhoneActions, hub: GlassesHub, server: EmbeddedServer<*, *>) { printHelp() while (true) { val line = readlnOrNull() ?: break @@ -53,12 +67,12 @@ private suspend fun consoleLoop(hub: GlassesHub, server: EmbeddedServer<*, *>) { } when (trimmed) { "help", "?" -> printHelp() - else -> handleConsoleCommand(hub, trimmed) + else -> handleConsoleCommand(actions, hub, trimmed) } } } -private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) { +private suspend fun handleConsoleCommand(actions: PhoneActions, hub: GlassesHub, line: String) { val parts = line.split(" ").filter { it.isNotBlank() } val cmd = parts.firstOrNull() ?: return when (cmd.lowercase()) { @@ -89,10 +103,14 @@ private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) { "session" -> { val id = parts.getOrNull(1) - val title = parts.drop(2).joinToString(" ") - if (id == null || title.isEmpty()) return logUsage("session ") - hub.sessionId = id - hub.broadcast(ChatSessionMsg(sessionId = id, title = title)) + if (id == null) return logUsage("session <id> [title]") + if (parts.size >= 3) { + val title = parts.drop(2).joinToString(" ") + hub.sessionId = id + hub.broadcast(ChatSessionMsg(sessionId = id, title = title)) + } else { + out(actions.selectSession(id)) + } } "cmd" -> { @@ -102,23 +120,84 @@ private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) { hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs)) } + "catalog" -> out(actions.catalog(parts.getOrNull(1))) + + "open" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("open <itemId>") + out(actions.open(id)) + } + + "watch" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("watch <itemId> [audioN]") + val audio = parts.getOrNull(2)?.toIntOrNull() ?: 0 + out(actions.watch(id, audio)) + } + + "mirror" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("mirror <itemId>") + out(actions.makeMirror(id)) + } + + "mirror-status" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("mirror-status <itemId>") + out(actions.mirrorStatus(id)) + } + + "dl" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("dl <itemId> <очки|телефон>") + val target = parts.getOrNull(2) ?: "телефон" + out(actions.download(id, target)) + } + + "rm" -> { + val id = parts.getOrNull(1) + if (id == null) return logUsage("rm <itemId>") + out(actions.deleteDownloaded(id)) + } + + "sessions" -> out(actions.sessions()) + + "status" -> out(actions.status()) + else -> log("console", "неизвестная команда «$cmd» (help — список команд)") } } +/** Вывести текст действия построчно в консоль. */ +private fun out(text: String) { + if (text.isNotBlank()) { + text.lines().forEach { log("console", it) } + } +} + private inline fun <reified T : Enum<T>> parseEnum(value: String?): T? = value?.let { runCatching { enumValueOf<T>(it.uppercase()) }.getOrNull() } private fun logUsage(usage: String) = log("console", "usage: $usage") private fun printHelp() { + log("console", "Команды (новые работают с Jellyfin/media-mirror):") + log("console", " catalog [parentId] — корень Jellyfin или содержимое папки (с бейджами зеркал)") + log("console", " open <itemId> — инфо + статус зеркала + дорожки озвучки") + log("console", " watch <itemId> [audioN] — включить на очках PlayVideo (зеркало должно быть done)") + log("console", " mirror <itemId> — заказать конвертацию в media-mirror") + log("console", " mirror-status <itemId> — статус зеркала") + log("console", " dl <itemId> <очки|телефон>— эмуляция скачивания") + log("console", " rm <itemId> — удалить скачанное") + log("console", " sessions — список сессий чата") + log("console", " session <id> [title] — переключить сессию") + log("console", " status — текущее состояние телефона/очков") log("console", "Команды (шлют всем подключённым очкам):") - log("console", " play <url> — PlayVideo (грузить и играть фильм)") - log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста") - log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING") - log("console", " text <текст> — ShowText (ответ ассистента)") - log("console", " session <id> <title> — ChatSessionMsg (переключить сессию)") - log("console", " cmd <CMD> [seekMs] — MediaCommand: PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE") - log("console", " help — этот список") - log("console", " exit — остановить сервер") + log("console", " play <url> — PlayVideo (грузить и играть фильм)") + log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста") + log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING") + log("console", " text <текст> — ShowText (ответ ассистента)") + log("console", " cmd <CMD> [seekMs] — MediaCommand: PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE") + log("console", " help — этот список") + log("console", " exit — остановить сервер") } diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt new file mode 100644 index 0000000..3dc91f4 --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneActions.kt @@ -0,0 +1,201 @@ +package pw.binom.viewmate.host + +import kotlinx.serialization.json.Json +import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.media.ApiClientException +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.ChatSessionMsg +import pw.binom.viewmate.core.protocol.PlayVideo + +/** + * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. + * Каждый метод возвращает человекочитаемый текст для консоли + * и дёргает hub для реальных команд очкам. + */ +class PhoneActions( + private val jellyfin: JellyfinClient, + private val mirror: MirrorClient, + private val hub: GlassesHub, + private val state: PhoneState, + private val json: Json, +) { + + private var cachedUserId: String? = null + + private suspend fun userId(): String { + cachedUserId?.let { return it } + val users = jellyfin.users() + val id = users.firstOrNull()?.Id + ?: throw ApiClientException("нет пользователей в Jellyfin") + cachedUserId = id + return id + } + + // --- каталог --- + + /** Корень Jellyfin или содержимое папки, с бейджами статусов зеркал. */ + suspend fun catalog(parentId: String? = null): String { + val uid = userId() + val items = if (parentId == null) jellyfin.rootItems(uid) else jellyfin.items(uid, 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 uid = userId() + val item = jellyfin.item(uid, 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 uid = userId() + val item = jellyfin.item(uid, 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 + hub.broadcast(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 + hub.broadcast(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() + } + + private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60 +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt new file mode 100644 index 0000000..48a304d --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/PhoneState.kt @@ -0,0 +1,72 @@ +package pw.binom.viewmate.host + +import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.media.JellyfinItem +import pw.binom.viewmate.core.media.MirrorStatus +import java.util.concurrent.ConcurrentHashMap + +/** Сессия чата (эмуляция) — простой 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: List<SessionInfo> = listOf( + SessionInfo("glasses-movie", "Очки/кино"), + SessionInfo("work", "Работа"), + SessionInfo("general", "Общее"), + SessionInfo("current", "Текущая"), + ) + + /** Эмуляция скачанного: itemId → куда (очки|телефон). */ + val downloaded: ConcurrentHashMap<String, String> = ConcurrentHashMap() +} diff --git a/app-host/src/main/resources/application.properties b/app-host/src/main/resources/application.properties new file mode 100644 index 0000000..896e69f --- /dev/null +++ b/app-host/src/main/resources/application.properties @@ -0,0 +1,4 @@ +jellyfin.url=https://jellyfin.binom.pw +jellyfin.apiKey=59e6380a96a94364ae25d2b7cf9f1359 +mirror.url=https://mirror.binom.pw +mirror.apiKey=67KA48ScP0qxRIihmr1srlggIIkRUAG diff --git a/app-host/src/test/kotlin/pw/binom/viewmate/host/ConfigTest.kt b/app-host/src/test/kotlin/pw/binom/viewmate/host/ConfigTest.kt new file mode 100644 index 0000000..abaf22b --- /dev/null +++ b/app-host/src/test/kotlin/pw/binom/viewmate/host/ConfigTest.kt @@ -0,0 +1,77 @@ +package pw.binom.viewmate.host + +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigTest { + + @Test + fun defaultsUsedWhenEnvAndPropertiesEmpty() { + val config = Config.load(env = emptyMap(), properties = Properties()) + assertEquals(Config.DEFAULT_JELLYFIN_URL, config.jellyfinUrl) + assertEquals(Config.DEFAULT_JELLYFIN_API_KEY, config.jellyfinApiKey) + assertEquals(Config.DEFAULT_MIRROR_URL, config.mirrorUrl) + assertEquals(Config.DEFAULT_MIRROR_API_KEY, config.mirrorApiKey) + } + + @Test + fun propertiesOverrideDefaults() { + val props = Properties().apply { + setProperty("jellyfin.url", "http://props.jellyfin") + setProperty("jellyfin.apiKey", "props-jf-key") + setProperty("mirror.url", "http://props.mirror") + setProperty("mirror.apiKey", "props-mirror-key") + } + val config = Config.load(env = emptyMap(), properties = props) + assertEquals("http://props.jellyfin", config.jellyfinUrl) + assertEquals("props-jf-key", config.jellyfinApiKey) + assertEquals("http://props.mirror", config.mirrorUrl) + assertEquals("props-mirror-key", config.mirrorApiKey) + } + + @Test + fun envOverridesProperties() { + val props = Properties().apply { + setProperty("jellyfin.url", "http://props.jellyfin") + setProperty("jellyfin.apiKey", "props-jf-key") + setProperty("mirror.url", "http://props.mirror") + setProperty("mirror.apiKey", "props-mirror-key") + } + val config = Config.load( + env = mapOf( + "JELLYFIN_URL" to "http://env.jellyfin", + "JELLYFIN_API_KEY" to "env-jf-key", + "MIRROR_URL" to "http://env.mirror", + "MIRROR_API_KEY" to "env-mirror-key", + ), + properties = props, + ) + assertEquals("http://env.jellyfin", config.jellyfinUrl) + assertEquals("env-jf-key", config.jellyfinApiKey) + assertEquals("http://env.mirror", config.mirrorUrl) + assertEquals("env-mirror-key", config.mirrorApiKey) + } + + @Test + fun partialEnvLeavesOthersFromProperties() { + val props = Properties().apply { + setProperty("jellyfin.url", "http://props.jellyfin") + setProperty("jellyfin.apiKey", "props-jf-key") + setProperty("mirror.url", "http://props.mirror") + setProperty("mirror.apiKey", "props-mirror-key") + } + val config = Config.load(env = mapOf("MIRROR_API_KEY" to "env-mirror-key"), properties = props) + assertEquals("http://props.jellyfin", config.jellyfinUrl) + assertEquals("env-mirror-key", config.mirrorApiKey) + } + + @Test + fun propertiesFileOnClasspathContainsDefaults() { + val config = Config.load(env = emptyMap(), properties = Config.loadProperties()) + assertEquals("https://jellyfin.binom.pw", config.jellyfinUrl) + assertEquals("https://mirror.binom.pw", config.mirrorUrl) + assertEquals("59e6380a96a94364ae25d2b7cf9f1359", config.jellyfinApiKey) + assertEquals("67KA48ScP0qxRIihmr1srlggIIkRUAG", config.mirrorApiKey) + } +} diff --git a/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt b/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt new file mode 100644 index 0000000..b66f2fb --- /dev/null +++ b/app-host/src/test/kotlin/pw/binom/viewmate/host/PhoneActionsTest.kt @@ -0,0 +1,312 @@ +package pw.binom.viewmate.host + +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 usersJson = """[{"Id":"u1","Name":"Admin"}]""" + 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" -> jsonResponse(usersJson) + "/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 HubStub(val phoneState: PhoneState = PhoneState()) : + GlassesHub(Json { ignoreUnknownKeys = true }, phoneState) { + val sent = mutableListOf<HostToGlasses>() + override suspend fun broadcast(msg: HostToGlasses) { + sent += msg + } + } + + private fun actions( + jf: JellyfinClient, + mirror: MirrorClient, + hub: HubStub, + ): PhoneActions = PhoneActions( + jellyfin = jf, + mirror = mirror, + hub = hub, + state = hub.phoneState, + json = json, + ) + + @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, HubStub()).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" -> jsonResponse(usersJson) + "/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, HubStub()).catalog("f1") + + assertTrue(out.contains("папка f1")) + assertTrue(out.contains("Пилот (e1)")) + assertTrue(out.contains("нет")) + } + + @Test + fun openShowsInfoMirrorAndTracks() = runTest { + val out = actions(simpleJellyfin(), doneMirror(), HubStub()).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 hub = HubStub() + val out = actions(simpleJellyfin(), doneMirror(), hub).watch("m1", audioIndex = 1) + + assertTrue(out.contains("включил «Интерстеллар»")) + assertEquals(1, hub.sent.size) + val msg = hub.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", hub.phoneState.currentItem?.Id) + assertEquals(1, hub.phoneState.currentAudioIndex) + assertTrue(hub.phoneState.playing) + assertEquals("done", hub.phoneState.mirrorStatus?.status) + } + + @Test + fun watchWhenMirrorNotReadyReturnsHint() = runTest { + val hub = HubStub() + val mirrorClient = mirror { request -> + respond("", HttpStatusCode.NotFound) + } + val out = actions(simpleJellyfin(), mirrorClient, hub).watch("m1") + + assertTrue(out.contains("не готово")) + assertTrue(hub.sent.isEmpty()) + } + + @Test + fun watchUnknownItemReturnsNotFound() = runTest { + val hub = HubStub() + val jf = jellyfin { request -> + when (request.url.encodedPath) { + "/Users" -> jsonResponse(usersJson) + "/Users/u1/Items/missing" -> respond("", HttpStatusCode.NotFound) + else -> jsonResponse("""{"Items":[]}""") + } + } + val out = actions(jf, doneMirror(), hub).watch("missing") + assertTrue(out.contains("не найден")) + assertTrue(hub.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, HubStub()).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(), HubStub()).mirrorStatus("m1") + assertTrue(done.contains("done")) + assertTrue(done.contains("✓")) + + val missing = mirror { request -> respond("", HttpStatusCode.NotFound) } + val none = actions(simpleJellyfin(), missing, HubStub()).mirrorStatus("m1") + assertTrue(none.contains("Зеркало: нет")) + } + + @Test + fun selectSessionBroadcastsChatSessionMsg() = runTest { + val hub = HubStub() + val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("work") + + assertTrue(out.contains("Работа")) + assertEquals("work", hub.phoneState.activeSessionId) + val msg = hub.sent.single() + assertIs<ChatSessionMsg>(msg) + assertEquals("work", msg.sessionId) + assertEquals("Работа", msg.title) + } + + @Test + fun selectSessionUnknownReturnsError() = runTest { + val hub = HubStub() + val out = actions(simpleJellyfin(), doneMirror(), hub).selectSession("nope") + assertTrue(out.contains("не найдена")) + assertTrue(hub.sent.isEmpty()) + assertNull(hub.phoneState.activeSessionId) + } + + @Test + fun sessionsListsAllSessions() = runTest { + val hub = HubStub() + hub.phoneState.activeSessionId = "work" + val out = actions(simpleJellyfin(), doneMirror(), hub).sessions() + assertTrue(out.contains("glasses-movie — Очки/кино")) + assertTrue(out.contains("work — Работа")) + assertTrue(out.contains("general — Общее")) + assertTrue(out.contains("◄ активна")) + } + + @Test + fun downloadAndDeleteDownloadedEmulate() { + val hub = HubStub() + val actions = actions(simpleJellyfin(), doneMirror(), hub) + + val dl = actions.download("m1", "очки") + assertTrue(dl.contains("скачано m1")) + assertEquals("очки", hub.phoneState.downloaded["m1"]) + + val rm = actions.deleteDownloaded("m1") + assertTrue(rm.contains("удалено скачанное")) + assertFalse(hub.phoneState.downloaded.containsKey("m1")) + + val rm2 = actions.deleteDownloaded("m1") + assertTrue(rm2.contains("не было скачано")) + } + + @Test + fun statusShowsCurrentState() = runTest { + val hub = HubStub() + val actions = actions(simpleJellyfin(), doneMirror(), hub) + + 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")) + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt index 51c1f7c..17c3a95 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt @@ -5,6 +5,7 @@ import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter +import io.ktor.http.HttpStatusCode /** * HTTP-клиент Jellyfin (каталог). @@ -37,6 +38,17 @@ class JellyfinClient( recursive = false, ) + /** Один элемент по id; null если не найден (404). */ + suspend fun item(userId: String, itemId: String): JellyfinItem? { + val response = http.get("$baseUrl/Users/$userId/Items/$itemId") { + header("X-Emby-Token", apiKey) + parameter("Fields", "PrimaryImageAspectRatio,Overview") + } + if (response.status == HttpStatusCode.NotFound) return null + response.ensureSuccess("jellyfin.item") + return response.body() + } + /** Поиск по всей библиотеке. */ suspend fun search(userId: String, query: String): List<JellyfinItem> = requestItems( userId = userId, diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt index 32aa1f8..97dc2e2 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt @@ -109,6 +109,41 @@ class JellyfinClientTest { assertEquals("Movie", items[0].Type) } + @Test + fun itemParsesSingleItemAndSendsToken() = runTest { + var capturedPath: String? = null + var capturedToken: String? = null + val jf = JellyfinClient( + baseUrl = "https://jellyfin.binom.pw", + apiKey = apiKey, + http = client { request -> + capturedPath = request.url.encodedPath + capturedToken = request.headers["X-Emby-Token"] + jsonResponse("""{"Id":"m1","Name":"Interstellar","Type":"Movie","Overview":"Описание"}""") + }, + ) + + val item = jf.item(userId = "u1", itemId = "m1") + + assertEquals("/Users/u1/Items/m1", capturedPath) + assertEquals(apiKey, capturedToken) + kotlin.test.assertNotNull(item) + assertEquals("m1", item.Id) + assertEquals("Interstellar", item.Name) + assertEquals("Описание", item.Overview) + } + + @Test + fun itemReturnsNullOnNotFound() = runTest { + val jf = JellyfinClient( + baseUrl = "https://jellyfin.binom.pw", + apiKey = apiKey, + http = client { respond("", HttpStatusCode.NotFound) }, + ) + + kotlin.test.assertNull(jf.item(userId = "u1", itemId = "missing")) + } + @Test fun searchSendsSearchTerm() = runTest { var capturedSearchTerm: String? = null