feat: app-host → телефон-эмулятор (Jellyfin + mirror + стейт + команды)
- Config.kt: env > application.properties > дефолты (креды не в коде) - PhoneState.kt: единый стейт — mode, currentItem, audioIndex, mirrorStatus, activeSessionId, сессии (эмуляция), downloaded - PhoneActions.kt: catalog (с бейджами зеркал), open, watch (PlayVideo на очки), makeMirror, mirrorStatus, download/delete, sessions/selectSession, status - Main.kt: новые консольные команды (catalog/open/watch/mirror/dl/rm/sessions/session/status) - GlassesHub: Welcome берёт mode/sessionId из стейта, glasses_off → пауза - JellyfinClient.item() — элемент по id (для open/watch) - 58 тестов, 130 всего, все зелёные; живые проверки: catalog (реальный Jellyfin), watch (PlayVideo с s3-url), status (стейт обновляется)
This commit is contained in:
@@ -22,6 +22,9 @@ dependencies {
|
|||||||
testImplementation("io.ktor:ktor-client-core:3.5.2")
|
testImplementation("io.ktor:ktor-client-core:3.5.2")
|
||||||
testImplementation("io.ktor:ktor-client-websockets: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-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") }
|
application { mainClass.set("pw.binom.viewmate.host.MainKt") }
|
||||||
|
|||||||
@@ -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<String, String> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,10 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* Менеджер подключённых очков. Потокобезопасен (ConcurrentHashMap).
|
* Менеджер подключённых очков. Потокобезопасен (ConcurrentHashMap).
|
||||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие.
|
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие.
|
||||||
*/
|
*/
|
||||||
class GlassesHub(private val json: Json) {
|
open class GlassesHub(
|
||||||
|
private val json: Json,
|
||||||
|
private val state: PhoneState = PhoneState(),
|
||||||
|
) {
|
||||||
|
|
||||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
||||||
|
|
||||||
@@ -34,9 +37,12 @@ class GlassesHub(private val json: Json) {
|
|||||||
private val _gesturesReceived = MutableStateFlow(0)
|
private val _gesturesReceived = MutableStateFlow(0)
|
||||||
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
||||||
|
|
||||||
/** Активная сессия чата (выставляется с «телефона»). */
|
/** Активная сессия чата (синхронизируется со стейтом «телефона»). */
|
||||||
@Volatile
|
var sessionId: String?
|
||||||
var sessionId: String? = null
|
get() = state.activeSessionId
|
||||||
|
set(value) {
|
||||||
|
state.activeSessionId = value
|
||||||
|
}
|
||||||
|
|
||||||
fun add(session: DefaultWebSocketServerSession) {
|
fun add(session: DefaultWebSocketServerSession) {
|
||||||
sessions[session] = Unit
|
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)
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
for (session in sessions.keys) {
|
for (session in sessions.keys) {
|
||||||
try {
|
try {
|
||||||
@@ -64,18 +70,19 @@ class GlassesHub(private val json: Json) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Сменить режим очков и разослать SetMode. */
|
/** Сменить режим очков и разослать SetMode. */
|
||||||
suspend fun setMode(mode: GlassesMode) {
|
open suspend fun setMode(mode: GlassesMode) {
|
||||||
_mode.value = mode
|
_mode.value = mode
|
||||||
|
state.mode = mode
|
||||||
broadcast(pw.binom.viewmate.core.protocol.SetMode(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)
|
val msg = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is Hello -> {
|
is Hello -> {
|
||||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
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 -> {
|
is Gesture -> {
|
||||||
@@ -94,6 +101,7 @@ class GlassesHub(private val json: Json) {
|
|||||||
|
|
||||||
is GlassesOff -> {
|
is GlassesOff -> {
|
||||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||||
|
state.playing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.GlassesMode
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
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.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||||
@@ -18,7 +20,19 @@ import pw.binom.viewmate.core.protocol.ShowText
|
|||||||
const val HOST_PORT = 8080
|
const val HOST_PORT = 8080
|
||||||
|
|
||||||
fun main() {
|
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") {
|
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
|
||||||
hostModule(hub)
|
hostModule(hub)
|
||||||
}
|
}
|
||||||
@@ -36,11 +50,11 @@ fun main() {
|
|||||||
log("hub", "подключено очков: $count")
|
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()
|
printHelp()
|
||||||
while (true) {
|
while (true) {
|
||||||
val line = readlnOrNull() ?: break
|
val line = readlnOrNull() ?: break
|
||||||
@@ -53,12 +67,12 @@ private suspend fun consoleLoop(hub: GlassesHub, server: EmbeddedServer<*, *>) {
|
|||||||
}
|
}
|
||||||
when (trimmed) {
|
when (trimmed) {
|
||||||
"help", "?" -> printHelp()
|
"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 parts = line.split(" ").filter { it.isNotBlank() }
|
||||||
val cmd = parts.firstOrNull() ?: return
|
val cmd = parts.firstOrNull() ?: return
|
||||||
when (cmd.lowercase()) {
|
when (cmd.lowercase()) {
|
||||||
@@ -89,10 +103,14 @@ private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) {
|
|||||||
|
|
||||||
"session" -> {
|
"session" -> {
|
||||||
val id = parts.getOrNull(1)
|
val id = parts.getOrNull(1)
|
||||||
|
if (id == null) return logUsage("session <id> [title]")
|
||||||
|
if (parts.size >= 3) {
|
||||||
val title = parts.drop(2).joinToString(" ")
|
val title = parts.drop(2).joinToString(" ")
|
||||||
if (id == null || title.isEmpty()) return logUsage("session <id> <title>")
|
|
||||||
hub.sessionId = id
|
hub.sessionId = id
|
||||||
hub.broadcast(ChatSessionMsg(sessionId = id, title = title))
|
hub.broadcast(ChatSessionMsg(sessionId = id, title = title))
|
||||||
|
} else {
|
||||||
|
out(actions.selectSession(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"cmd" -> {
|
"cmd" -> {
|
||||||
@@ -102,22 +120,83 @@ private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) {
|
|||||||
hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs))
|
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 — список команд)")
|
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? =
|
private inline fun <reified T : Enum<T>> parseEnum(value: String?): T? =
|
||||||
value?.let { runCatching { enumValueOf<T>(it.uppercase()) }.getOrNull() }
|
value?.let { runCatching { enumValueOf<T>(it.uppercase()) }.getOrNull() }
|
||||||
|
|
||||||
private fun logUsage(usage: String) = log("console", "usage: $usage")
|
private fun logUsage(usage: String) = log("console", "usage: $usage")
|
||||||
|
|
||||||
private fun printHelp() {
|
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", "Команды (шлют всем подключённым очкам):")
|
||||||
log("console", " play <url> — PlayVideo (грузить и играть фильм)")
|
log("console", " play <url> — PlayVideo (грузить и играть фильм)")
|
||||||
log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста")
|
log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста")
|
||||||
log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING")
|
log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING")
|
||||||
log("console", " text <текст> — ShowText (ответ ассистента)")
|
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", " cmd <CMD> [seekMs] — MediaCommand: PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE")
|
||||||
log("console", " help — этот список")
|
log("console", " help — этот список")
|
||||||
log("console", " exit — остановить сервер")
|
log("console", " exit — остановить сервер")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
jellyfin.url=https://jellyfin.binom.pw
|
||||||
|
jellyfin.apiKey=59e6380a96a94364ae25d2b7cf9f1359
|
||||||
|
mirror.url=https://mirror.binom.pw
|
||||||
|
mirror.apiKey=67KA48ScP0qxRIihmr1srlggIIkRUAG
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import io.ktor.client.call.body
|
|||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-клиент Jellyfin (каталог).
|
* HTTP-клиент Jellyfin (каталог).
|
||||||
@@ -37,6 +38,17 @@ class JellyfinClient(
|
|||||||
recursive = false,
|
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(
|
suspend fun search(userId: String, query: String): List<JellyfinItem> = requestItems(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
|
|||||||
@@ -109,6 +109,41 @@ class JellyfinClientTest {
|
|||||||
assertEquals("Movie", items[0].Type)
|
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
|
@Test
|
||||||
fun searchSendsSearchTerm() = runTest {
|
fun searchSendsSearchTerm() = runTest {
|
||||||
var capturedSearchTerm: String? = null
|
var capturedSearchTerm: String? = null
|
||||||
|
|||||||
Reference in New Issue
Block a user