Агент: медиа-тулсет (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:
@@ -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.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
import pw.binom.viewmate.phone.agent.ChatDb
|
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.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.SkillRegistry
|
||||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||||
@@ -102,9 +105,11 @@ class PhoneApp : Application() {
|
|||||||
assistant?.let { return it }
|
assistant?.let { return it }
|
||||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
||||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||||
|
val toolsets = ToolsetRegistry()
|
||||||
|
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
|
||||||
val tk = toolkit ?: AgentToolkit(
|
val tk = toolkit ?: AgentToolkit(
|
||||||
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
|
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
|
||||||
toolsets = ToolsetRegistry(),
|
toolsets = toolsets,
|
||||||
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
|
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
|
||||||
).also { toolkit = it }
|
).also { toolkit = it }
|
||||||
return Assistant(
|
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 на очки.
|
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
* Ошибка 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 Id: String,
|
||||||
val Name: String? = null,
|
val Name: String? = null,
|
||||||
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
|
||||||
|
/** Год релиза (Movie/Series); в ответах может отсутствовать. */
|
||||||
|
val ProductionYear: Int? = null,
|
||||||
val Overview: String? = null,
|
val Overview: String? = null,
|
||||||
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
|
||||||
val SeriesName: String? = null, // для эпизодов
|
val SeriesName: String? = null, // для эпизодов
|
||||||
|
|||||||
@@ -91,8 +91,15 @@ class PhoneActions(
|
|||||||
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
||||||
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
||||||
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
|
* Звук — на телефоне: [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 item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
@@ -107,7 +114,7 @@ class PhoneActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val title = item.Name ?: itemId
|
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 между запусками — при старте просмотра шлём текущий
|
// очки не помнят scale между запусками — при старте просмотра шлём текущий
|
||||||
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
||||||
state.mode = GlassesMode.MOVIE
|
state.mode = GlassesMode.MOVIE
|
||||||
|
|||||||
Reference in New Issue
Block a user