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