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