Агент: медиа-тулсет (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:
@@ -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 сек."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user