Агент: медиа-тулсет (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,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 сек."
}
}