diff --git a/app-phone/skills/media.md b/app-phone/skills/media.md new file mode 100644 index 0000000..94a57c2 --- /dev/null +++ b/app-phone/skills/media.md @@ -0,0 +1,28 @@ +--- +name: media +description: поиск фильмов и сериалов, детали, запуск и управление просмотром на очках. +--- +Скилл: медиа (Jellyfin-каталог, просмотр на очках). + +Шаги: +1. Для работы с медиа сначала активируй набор: TOOL_CALL: enable_toolset|media + — дождись ответа: в нём реестр тул (media_search, media_info, media_play) и пометка, + что сам поиск ещё НЕ выполнялся. +2. Поиск: TOOL_CALL: media_search|<название> [limit=<кол-во, по умолчанию 5>] + — результат: строки «id: Название (Год) — Тип». Покажи пользователю найденное + и спроси, что включить (или включи прямо, если выбор ясный). +3. Детали: TOOL_CALL: media_info| — название, тип, год, описание, длительность, постер. +4. Запуск: TOOL_CALL: media_play| [seek_ms=<позиция в мс>] + — видео должно быть скачано на очки, звук — на телефон (это проверка исполнителя, + а не твоя: если не готово — в ответе будет точная причина и команда заказа). + +Питфоллы: +- media_* вызывается ТОЛЬКО когда тулсет активен: если набор не активен, сначала + enable_toolset|media — иначе система подменит вызов на активацию и скажет, + что поиск ещё не выполнялся. +- enable_toolset|media повторять не нужно: повторный вызов — продление таймаута, + не ошибка. Таймаут активности 10 минут с последнего вызова. +- Если media_play вернул «видео не скачано / звук не готов / очки не подключены» + — сообщи пользователю как есть (это фактическое состояние, не сбой нейросети) + и предложи дождаться или скачать вручную. +- Ответы тул фактические: не додумывай фильмов, которых нет в результате media_search. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index f6d7162..be5528f 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -28,7 +28,10 @@ import pw.binom.viewmate.phone.agent.AgentToolkit import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.ChatDao import pw.binom.viewmate.phone.agent.ChatDb +import pw.binom.viewmate.phone.agent.JellyfinMediaSource import pw.binom.viewmate.phone.agent.LlmClient +import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET +import pw.binom.viewmate.phone.agent.MediaToolExecutor import pw.binom.viewmate.phone.agent.SkillRegistry import pw.binom.viewmate.phone.agent.SkillRepository import pw.binom.viewmate.phone.agent.ToolsetRegistry @@ -102,9 +105,11 @@ class PhoneApp : Application() { assistant?.let { return it } if (BuildConfig.LLM_API_KEY.isBlank()) return null val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL) + val toolsets = ToolsetRegistry() + toolsets.register(MEDIA_TOOLSET, mediaToolExecutor()) val tk = toolkit ?: AgentToolkit( skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()), - toolsets = ToolsetRegistry(), + toolsets = toolsets, showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } }, ).also { toolkit = it } return Assistant( @@ -115,6 +120,49 @@ class PhoneApp : Application() { } } + /** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */ + private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor( + media = JellyfinMediaSource(jellyfin) { userId.value }, + player = { id, seekMs -> agentPlayMedia(id, seekMs) }, + ) + + /** + * Тул ассистента media_play: проверки (очки на связи, видео скачано на очки, + * звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen) + * + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели. + */ + private suspend fun agentPlayMedia(id: String, seekMs: Long): String { + val hub = server.hub + if (hub.connected.value == 0) return "Очки не подключены." + val mediaDir = hub.mediaDir.value + if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже." + if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон." + if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон." + val acts = actions ?: return "Телефон ещё не готов." + val audioIndex = state.currentAudioIndex + log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс") + + val result = acts.watch( + itemId = id, + audioIndex = audioIndex, + localVideoPath = "file://$mediaDir/$id/video.mkv", + phoneAudioReady = true, + startPositionMs = seekMs, + ) + if (!result.contains("включил")) return result + + // Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим. + if (audioSync.isActive) audioSync.stop() + val audio = resolveAudioSourceDetailed( + mirrorFiles = mirror.mirrorByItem(id)?.files, + audioIndex = audioIndex, + localAudioFile = { downloadManager.audioFile(id, it) }, + ) + if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}" + audioSync.play(audio.source, seekMs.coerceAtLeast(0L)) + return "Включено: «${state.currentItem?.Name ?: id}»" + } + /** * Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки. * Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/JellyfinMediaSource.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/JellyfinMediaSource.kt new file mode 100644 index 0000000..c7eec2f --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/JellyfinMediaSource.kt @@ -0,0 +1,40 @@ +package pw.binom.viewmate.phone.agent + +import pw.binom.viewmate.core.media.JellyfinClient +import pw.binom.viewmate.core.media.JellyfinItem + +/** + * [MediaSource] поверх Jellyfin: search/item через [JellyfinClient]. + * [userId] — кэш приложения (PhoneApp.userId, подтягивается при старте), + * передан провайдером: если к моменту вызова ещё не готов — [MediaSourceException] + * (исполнитель покажет модели «Поиск недоступен: ...», а не пахнет пустым результатом). + */ +class JellyfinMediaSource( + private val jellyfin: JellyfinClient, + private val userId: () -> String, +) : MediaSource { + + override suspend fun search(query: String, limit: Int): List { + val user = requireUserAvailable() + return jellyfin.search(user, query).take(limit).map { jellyfinToMediaItem(it) } + } + + override suspend fun item(itemId: String): MediaItem? { + val user = requireUserAvailable() + return jellyfin.item(user, itemId)?.let { jellyfinToMediaItem(it) } + } + + private fun requireUserAvailable(): String = + userId().trim().ifBlank { throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") } +} + +/** Модель Jellyfin → тул-элемент (JVM, тестируемо: поля, «постер есть», длительность в секундах). */ +fun jellyfinToMediaItem(item: JellyfinItem): MediaItem = MediaItem( + id = item.Id, + name = item.Name?.trim()?.takeIf { it.isNotEmpty() } ?: item.Id, + type = item.Type, + year = item.ProductionYear, + overview = item.Overview, + durationSeconds = item.RunTimeTicks?.let { it / 10_000_000 }, + hasPoster = item.ImageTags?.containsKey("Primary") == true, +) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/MediaTools.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/MediaTools.kt new file mode 100644 index 0000000..2fcfede --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/agent/MediaTools.kt @@ -0,0 +1,142 @@ +package pw.binom.viewmate.phone.agent + +import kotlinx.coroutines.runBlocking + +/** + * Медиа-элемент для тул: минимум, что нужно модели. + * Не зависит от моделей lib-core — JVM, тестируемо фейками. + */ +data class MediaItem( + val id: String, + val name: String, + val type: String?, + val year: Int?, + val overview: String?, + val durationSeconds: Long?, + val hasPoster: Boolean, +) + +/** Источник медиа для тулсета: поиск + детали элемента. */ +interface MediaSource { + /** Поиск по [query]: до [limit] результатов. */ + suspend fun search(query: String, limit: Int): List + /** Детали элемента по [itemId]; null — не найден. */ + suspend fun item(itemId: String): MediaItem? +} + +/** Ошибка источника: нет пользователя Jellyfin, сеть недоступна и т.п. */ +class MediaSourceException(message: String) : Exception(message) + +/** + * Выполнить «включить на очках» (тул media_play): проверки + [pw.binom.viewmate.core.phone.PhoneActions.watch] + * + звук на телефоне — уровень приложения. Возвращает человекочитаемый текст. + */ +fun interface MediaPlayer { + suspend fun play(itemId: String, seekMs: Long): String +} + +/** Лимит поиска по умолчанию, когда модель его не указала. */ +const val MEDIA_SEARCH_DEFAULT_LIMIT = 5 + +/** + * Определение тулсета media: сигнатуры БЕЗ префикса (механика добавляет media_). + * Описания злые и короткие — маленькая модель (TASK-agent-tools, пункт 10). + */ +val MEDIA_TOOLSET = Toolset( + name = "media", + description = "медиа и воспроизведение: поиск, детали, запуск на очках (search/info/play)", + tools = listOf( + ToolSignature("search", "Поиск фильмов/сериалов по названию. Возвращает: id, название, год, тип.", args = listOf("query", "limit?")), + ToolSignature("info", "Детали элемента по id: название, тип, год, описание, длительность, постер.", args = listOf("id")), + ToolSignature("play", "Включить просмотр на очках: видео скачано на очки, звук — на телефон.", args = listOf("id", "seek_ms?")), + ), +) + +/** + * Исполнитель тул media: валидация аргументов + форматирование текста. + * Источники: [media] (Jellyfin) и [player] («включить на очках» — уровень приложения). + * Механика вызывает [execute] синхронно — а тулы suspend: мост через runBlocking + * (вызов и так на Dispatchers.IO, из ассистентского цикла). + */ +class MediaToolExecutor( + private val media: MediaSource, + private val player: MediaPlayer, +) : ToolsetExecutor { + + private companion object { + const val OVERVIEW_MAX_CHARS = 300 + + fun exceptionMessage(e: Throwable): String = e.message?.trim()?.takeIf { it.isNotEmpty() } ?: e.toString() + } + + override fun execute(toolset: String, tool: String, args: Map): String = when (tool) { + "media_search" -> search(args["query"], args["limit"]) + "media_info" -> info(args["id"]) + "media_play" -> play(args["id"], args["seek_ms"]) + else -> "Неизвестный тул \"$tool\". Доступны: media_search, media_info, media_play." + } + + /** media_search: строки «id: Название (Год) — Тип», до limit (дефолт [MEDIA_SEARCH_DEFAULT_LIMIT]). */ + private fun search(rawQuery: String?, rawLimit: String?): String { + val query = rawQuery?.trim().orEmpty() + if (query.isEmpty()) { + return "media_search: нет аргумента \"query\". Схема: media_search| [limit=]" + } + val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT + val items: List = runCatching { runBlocking { media.search(query, limit) } } + .getOrElse { return "Поиск недоступен: ${exceptionMessage(it)}" } + if (items.isEmpty()) return "Ничего не найдено по запросу «$query»." + return items.joinToString("\n") { row -> + val year = row.year?.let { " ($it)" }.orEmpty() + val type = row.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент" + "${row.id}: ${row.name}$year — $type" + } + } + + /** media_info: «Название» — Тип (Год), описание (≤ [OVERVIEW_MAX_CHARS]), длительность, постер. */ + private fun info(rawId: String?): String { + val id = rawId?.trim().orEmpty() + if (id.isEmpty()) { + return "media_info: нет аргумента \"id\". Схема: media_info|" + } + val item: MediaItem? = runCatching { runBlocking { media.item(id) } } + .getOrElse { return "Детали недоступны: ${exceptionMessage(it)}" } + if (item == null) return "Элемент \"$id\" не найден." + return infoText(item) + } + + /** media_play: id обязателен, seek_ms — опционально (целые миллисекунды); выполняем через [player]. */ + private fun play(rawId: String?, rawSeek: String?): String { + val id = rawId?.trim().orEmpty() + if (id.isEmpty()) { + return "media_play: нет аргумента \"id\". Схема: media_play| [seek_ms=]" + } + val seekMs = when (val s = rawSeek?.trim().orEmpty()) { + "" -> 0L + else -> s.toLongOrNull()?.coerceAtLeast(0L) + ?: return "media_play: аргумент \"seek_ms\" должен быть числом (миллисекунды)." + } + return runCatching { runBlocking { player.play(id, seekMs) } } + .getOrElse { "Ошибка воспроизведения: ${exceptionMessage(it)}" } + } + + private fun infoText(item: MediaItem): String { + val year = item.year?.let { " ($it)" }.orEmpty() + val type = item.type?.trim()?.takeIf { it.isNotEmpty() } ?: "Элемент" + return buildList { + add("«${item.name}» — ${type}$year") + val overview = item.overview?.trim() + if (!overview.isNullOrBlank()) { + add("Описание: " + if (overview.length > OVERVIEW_MAX_CHARS) overview.take(OVERVIEW_MAX_CHARS).trimEnd() + "…" else overview) + } + item.durationSeconds?.takeIf { it > 0 }?.let { add("Длительность: ${durationText(it)}") } + add("Постер: " + if (item.hasPoster) "есть" else "нет") + }.joinToString("\n") + } + + /** Секунды в человекочитаемом виде: ≥ 60 сек — минуты (с секундами в скобках). */ + private fun durationText(seconds: Long): String { + val minutes = seconds / 60 + return if (minutes > 0) "${minutes} мин. (${seconds} сек.)" else "$seconds сек." + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/MediaToolsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/MediaToolsTest.kt new file mode 100644 index 0000000..02e9f42 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/agent/MediaToolsTest.kt @@ -0,0 +1,267 @@ +package pw.binom.viewmate.phone.agent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import pw.binom.viewmate.core.media.JellyfinClient +import pw.binom.viewmate.core.media.JellyfinItem + +/** + * Исполнитель тулсета media: валидация аргументов + форматирование текста + * (search/info/play). Источник и игрок иньекшены фейками — JVM, без Android. + */ +class MediaToolsTest { + + private class FakeSource : MediaSource { + val items = mutableListOf( + MediaItem("i1", "Lara Croft", "Movie", 2018, "Tomb Raider reboot", 8100L, true), + MediaItem("i2", "The Witcher", "Series", 2019, null, 3300L, false), + MediaItem("i3", "Stranger Things", "Series", 2016, " ", 3540L, true), + MediaItem("i4", "Blade Runner", "Movie", 1982, "репликанты", 8340L, true), + MediaItem("i5", "Dune", "Series", 2021, null, 4200L, false), + ) + var searchFails = false + var itemFails = false + /** Фильтр поиска (по умолчанию все — тестируем лимит). */ + var match: (MediaItem) -> Boolean = { true } + + override suspend fun search(query: String, limit: Int): List { + if (searchFails) throw MediaSourceException("нет пользователя Jellyfin (userId пуст)") + return items.filter(match).take(limit) + } + + override suspend fun item(itemId: String): MediaItem? { + if (itemFails) throw MediaSourceException("сеть недоступна") + return items.firstOrNull { it.id == itemId } + } + } + + private class FakePlayer( + private val responses: Map = emptyMap(), + private var failure: Throwable? = null, + ) : MediaPlayer { + val calls = mutableListOf>() + + override suspend fun play(itemId: String, seekMs: Long): String { + calls.add(itemId to seekMs) + failure?.let { throw it } + return responses[itemId] ?: "включил: $itemId" + } + } + + private fun executor(source: FakeSource = FakeSource(), player: FakePlayer = FakePlayer()) = + MediaToolExecutor(source, player) + + // ---------- определение тулсета ---------- + + @Test + fun `сигнатуры тулсета и префиксная маршрутизация`() { + assertEquals(listOf("search", "info", "play"), MEDIA_TOOLSET.tools.map { it.name }) + assertEquals("media_search", MEDIA_TOOLSET.toolFull(MEDIA_TOOLSET.tools.first())) + assertEquals(listOf("id"), MEDIA_TOOLSET.signatureOf("media_play")?.requiredArgs()) + assertEquals(listOf("query", "limit"), MEDIA_TOOLSET.signatureOf("media_search")?.argNames()) + } + + // ---------- media_search ---------- + + @Test + fun `search строки id название год тип`() { + val source = FakeSource() + source.match = { it.id == "i1" } + val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара")) + assertEquals("i1: Lara Croft (2018) — Movie", r) + } + + @Test + fun `поиск без года не показывает скобок`() { + val source = FakeSource() + source.items[0] = source.items[0].copy(year = null) + source.match = { it.id == "i1" } + val r = executor(source).execute("media", "media_search", mapOf("query" to "Лара")) + assertEquals("i1: Lara Croft — Movie", r) + } + + @Test + fun `limit по умолчанию 5, явного, кривой и ноль`() { + val r = executor() + assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x")).lines().size) + assertEquals(2, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "2")).lines().size) + assertEquals(5, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "ab")).lines().size) + // ноль и отрицательное — сжимаются в 1 + assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "0")).lines().size) + assertEquals(1, r.execute("media", "media_search", mapOf("query" to "x", "limit" to "-4")).lines().size) + } + + @Test + fun `поиск без query — прощающая схема`() { + for (args in listOf(emptyMap(), mapOf("query" to ""))) { + val r = executor().execute("media", "media_search", args) + assertTrue(r.contains("нет аргумента \"query\""), r) + assertTrue(r.contains("media_search|"), r) + } + } + + @Test + fun `поиск пустая — ничего не найдено`() { + val source = FakeSource() + source.match = { false } + val r = executor(source).execute("media", "media_search", mapOf("query" to "такого нет")) + assertTrue(r.contains("Ничего не найдено"), r) + } + + @Test + fun `поиск источник недоступен — сообщение с причиной`() { + val source = FakeSource() + source.searchFails = true + val r = executor(source).execute("media", "media_search", mapOf("query" to "x")) + assertTrue(r.contains("Поиск недоступен"), r) + assertTrue(r.contains("userId пуст"), r) + } + + // ---------- media_info ---------- + + @Test + fun `info полный набор полей`() { + val r = executor().execute("media", "media_info", mapOf("id" to "i1")) + assertTrue(r.contains("«Lara Croft» — Movie (2018)"), r) + assertTrue(r.contains("Описание: Tomb Raider reboot"), r) + assertTrue(r.contains("Длительность: 135 мин. (8100 сек.)"), r) + assertTrue(r.contains("Постер: есть"), r) + } + + @Test + fun `info без описания и постера — строки не выдумываются`() { + val r = executor().execute("media", "media_info", mapOf("id" to "i2")) + assertFalse(r.contains("Описание"), r) + assertFalse(r.contains("8100"), r) + assertTrue(r.contains("Постер: нет"), r) + assertTrue(r.contains("55 мин. (3300 сек.)"), r) + } + + @Test + fun `info описание длиннее 300 символов — обрезка с многоточием`() { + val source = FakeSource() + source.items[0] = source.items[0].copy(overview = "А".repeat(400)) + val r = executor(source).execute("media", "media_info", mapOf("id" to "i1")) + assertTrue(r.contains("Описание: ${"А".repeat(300)}…"), r) + assertFalse(r.contains("А".repeat(301)), r) + } + + @Test + fun `info не найден и без аргумента`() { + val r = executor().execute("media", "media_info", mapOf("id" to "iX")) + assertTrue(r.contains("\"iX\" не найден"), r) + val noArg = executor().execute("media", "media_info", emptyMap()) + assertTrue(noArg.contains("нет аргумента \"id\""), noArg) + } + + @Test + fun `info источник недоступен — сообщение с причиной`() { + val source = FakeSource() + source.itemFails = true + val r = executor(source).execute("media", "media_info", mapOf("id" to "i1")) + assertTrue(r.contains("Детали недоступны"), r) + assertTrue(r.contains("сеть недоступна"), r) + } + + // ---------- media_play ---------- + + @Test + fun `play передаёт id и seek_ms в игрок`() { + val player = FakePlayer() + val executor = executor(player = player) + val r = executor.execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "3600000")) + assertEquals("включил: i1", r) + assertEquals(listOf("i1" to 3_600_000L), player.calls) + // без seek — ноль + assertEquals("включил: i2", executor.execute("media", "media_play", mapOf("id" to "i2"))) + assertEquals(2, player.calls.size) + assertEquals(0L, player.calls.last().second) + } + + @Test + fun `play без id — схема аргументов`() { + val r = executor().execute("media", "media_play", emptyMap()) + assertTrue(r.contains("нет аргумента \"id\""), r) + assertTrue(r.contains("media_play|"), r) + } + + @Test + fun `play кривой seek_ms — числовое требование`() { + for (bad in listOf("час", "12.5")) { + val r = executor().execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to bad)) + assertTrue(r.contains("\"seek_ms\" должен быть числом"), r) + } + } + + @Test + fun `play отрицательный seek — в ноль`() { + val player = FakePlayer() + executor(player = player).execute("media", "media_play", mapOf("id" to "i1", "seek_ms" to "-100")) + assertEquals(0L, player.calls.single().second) + } + + @Test + fun `play игрок выбросил — обёртка ошибки`() { + val player = FakePlayer(failure = RuntimeException("видео не найдено")) + val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1")) + assertTrue(r.contains("Ошибка воспроизведения"), r) + assertTrue(r.contains("видео не найдено"), r) + } + + @Test + fun `play текст проверки игрока проходит как есть`() { + val player = FakePlayer(mapOf("i1" to "видео не скачано на очки. Скачайте его.")) + val r = executor(player = player).execute("media", "media_play", mapOf("id" to "i1")) + assertEquals("видео не скачано на очки. Скачайте его.", r) + } + + @Test + fun `неизвестный тул — список доступных`() { + val r = executor().execute("media", "media_bogus", emptyMap()) + assertTrue(r.contains("Неизвестный тул"), r) + assertTrue(r.contains("media_search, media_info, media_play"), r) + } + + // ---------- маппинг Jellyfin → MediaItem ---------- + + @Test + fun `маппинг полей, постер, секунды из тиков`() { + val item = JellyfinItem( + Id = "abc", + Name = " Blade ", + Type = "Movie", + ProductionYear = 2259, + Overview = "текст", + RunTimeTicks = 33_000_000_000L, // 3300 сек + ImageTags = mapOf("Primary" to "hash"), + ) + assertEquals( + MediaItem("abc", "Blade", "Movie", 2259, "текст", 3300L, true), + jellyfinToMediaItem(item), + ) + } + + @Test + fun `маппинг пустое имя в id, без постера и секунд`() { + val m = jellyfinToMediaItem(JellyfinItem(Id = "x1", Name = " ", Type = "Episode")) + assertEquals("x1", m.name) + assertFalse(m.hasPoster) + assertEquals(null, m.year) + assertEquals(null, m.durationSeconds) + } + + // ---------- JellyfinMediaSource: защита userId ---------- + + @Test + fun `источник без пользователя — исключение у источника`() { + val source = JellyfinMediaSource(JellyfinClient("http://jellyfin.test", "key")) { " " } + runBlocking { + assertFailsWith { source.search("x", 5) } + assertFailsWith { source.item("i1") } + } + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt index 4bc5ded..b47bd69 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt @@ -18,6 +18,8 @@ data class JellyfinItem( val Id: String, val Name: String? = null, val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder + /** Год релиза (Movie/Series); в ответах может отсутствовать. */ + val ProductionYear: Int? = null, val Overview: String? = null, val ImageTags: Map? = null, // Primary есть — есть постер val SeriesName: String? = null, // для эпизодов diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 8e976eb..3b8b8e8 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -91,8 +91,15 @@ class PhoneActions( * Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках. * Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем). * Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон. + * [startPositionMs] — стартовая позиция (seek для запуска с середины). */ - suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String { + suspend fun watch( + itemId: String, + audioIndex: Int = 0, + localVideoPath: String = "", + phoneAudioReady: Boolean = true, + startPositionMs: Long = 0, + ): String { val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден" val status = mirror.mirrorByItem(itemId) val files = status?.files @@ -107,7 +114,7 @@ class PhoneActions( } val title = item.Name ?: itemId - sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex)) + sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex, startPositionMs = startPositionMs)) // очки не помнят scale между запусками — при старте просмотра шлём текущий if (state.scale != 1f) sender.send(SetScale(state.scale)) state.mode = GlassesMode.MOVIE