Агент: медиа-тулсет (search/info/play) на Jellyfin + скилл media

- MEDIA_TOOLSET + MediaToolExecutor: search (поиск с лимитом), info (детали), play (проверки+запуск)
- JellyfinMediaSource: обёртка JellyfinClient (search/item) с защитой userId
- agentPlayMedia: проверки (очки на связи, видео скачано на очки, звук на телефоне) → watch с позицией
- PhoneActions.watch(startPositionMs), JellyfinItem.ProductionYear
- скилл app-phone/skills/media.md (frontmatter: name/description)
- MediaToolsTest: 22 кейса — всего 142 теста зелёные
This commit is contained in:
2026-08-22 21:42:53 +03:00
parent c5cbc893ac
commit 28d0f9b68d
7 changed files with 537 additions and 3 deletions
+28
View File
@@ -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|<id> — название, тип, год, описание, длительность, постер.
4. Запуск: TOOL_CALL: media_play|<id> [seek_ms=<позиция в мс>]
— видео должно быть скачано на очки, звук — на телефон (это проверка исполнителя,
а не твоя: если не готово — в ответе будет точная причина и команда заказа).
Питфоллы:
- media_* вызывается ТОЛЬКО когда тулсет активен: если набор не активен, сначала
enable_toolset|media — иначе система подменит вызов на активацию и скажет,
что поиск ещё не выполнялся.
- enable_toolset|media повторять не нужно: повторный вызов — продление таймаута,
не ошибка. Таймаут активности 10 минут с последнего вызова.
- Если media_play вернул «видео не скачано / звук не готов / очки не подключены»
— сообщи пользователю как есть (это фактическое состояние, не сбой нейросети)
и предложи дождаться или скачать вручную.
- Ответы тул фактические: не додумывай фильмов, которых нет в результате media_search.
@@ -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 и т.п.) — коротким сообщением пользователю, не падаем.
@@ -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<MediaItem> {
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,
)
@@ -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<MediaItem>
/** Детали элемента по [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, String>): 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|<query> [limit=<n>]"
}
val limit = rawLimit?.trim()?.toIntOrNull()?.coerceIn(1, 20) ?: MEDIA_SEARCH_DEFAULT_LIMIT
val items: List<MediaItem> = 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|<id>"
}
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|<id> [seek_ms=<n>]"
}
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 сек."
}
}
@@ -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<MediaItem> {
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<String, String> = emptyMap(),
private var failure: Throwable? = null,
) : MediaPlayer {
val calls = mutableListOf<Pair<String, Long>>()
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<String, String>(), mapOf("query" to ""))) {
val r = executor().execute("media", "media_search", args)
assertTrue(r.contains("нет аргумента \"query\""), r)
assertTrue(r.contains("media_search|<query>"), 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|<id>"), 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<MediaSourceException> { source.search("x", 5) }
assertFailsWith<MediaSourceException> { source.item("i1") }
}
}
}
@@ -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<String, String>? = null, // Primary есть — есть постер
val SeriesName: String? = null, // для эпизодов
@@ -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