Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент - QubStore

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Содержание статьи

Почему не Trakt/Letterboxd/Кинопоиск

Я пользовался для трекинга фильмов и сериалов Кинопоиском, Trakt, Letterboxd, IMDb, TMDB, MyShows — и по-честному ни один из них не бесил настолько, чтобы стать поводом всё бросить и писать своё. У каждого свои шероховатости: у Кинопоиска в какой-то момент стало слишком много рекламы, а заодно убрали подробную статистику по жанрам и странам, которая раньше была. У Trakt банально медленный сайт, лимит в 1000 фильмов в списке и ограниченные возможности с созданием списков. У IMDb так и не появилось нормального трекинга сериалов. TMDB — просто не нравится интерфейс. С Letterboxd — не могу даже сформулировать, почему, просто не зашло. А MyShows,устраивает почти во всём: удобно, есть оповещения о новых сериях, реклама ненавязчивая, лёгкая подписка для поддержки сервиса.

Так в чём тогда была зацепка? Не в том, что существующие сервисы плохие — большинство из них вполне рабочие. А в том, что ни один не даёт того сочетания, которое хотелось: self-host, где данные и инфраструктура твои, а не чужие; стриминг собственного контента внутри того же интерфейса, где ты его трекаешь; и просто инженерный азарт — я долгое время обдумывал архитектуру гибридной SDUI/BDUI на Kotlin, когда сервер стримит слоты на клиент, а клиент рендерит заранее заготовленные UI-элементы, при этом чтобы приложение ощущалось и выглядело как нативное. Это была отдельная задача, которую хотелось решить независимо от недостатков конкурентов.

Клиент — минималистичный интерфейс с трекингом фильмов и сериалов: статусы просмотра, оценки, свои списки.

Стек: Kotlin Multiplatform, Compose Multiplatform, Ktor, kotlinx-rpc, TMDB API.

Архитектура: сервер стримит интерфейс, а не просто данные

Базовый поиск и карточки контента было понятно, как делать — TMDB отдаёт почти весь каталог фильмов и сериалов, у него хороший мультиязычный поиск и готовые рекомендации/похожие тайтлы. А вот с главной страницей всё было сложнее: если просто дать юзеру выбрать категорию для показа на главной — не выглядит интересно. Отсюда родилась идея персонализированных рекомендаций на основе того, что человек посмотрел и оценил (об этом ниже).

Когда рекомендательный плагин заработал, стало понятно: часть экранов теперь динамическая, и на клиент нужно как-то доставлять контент сразу с рекомендациями. Здесь и пригодилась архитектура, которую я обдумывал полгода до этого.

Как это работает:

  1. При старте приложения сервер отдаёт клиенту манифест — все экраны и слоты, из которых они состоят.

  2. Клиент, ещё не получив ни байта контента, уже знает вёрстку экрана и рисует нужный shimmer/skeleton. Это значит, что с нулевого кадра пользователь видит структуру нативного приложения, а не пустой экран или спиннер.

  3. После рендера скелетонов сервер по WebSocket начинает стримить «обещания» — сигналы, что в конкретном слоте скоро появится контент, пока идут запросы к TMDB или в базу.

  4. Как только слот получает данные — сервер тут же отправляет их клиенту, и skeleton в нужном месте заполняется контентом.

  5. У каруселей рекомендаций может быть разный формат — и клиент узнаёт про это заранее: манифест сразу содержит верный ID слота под нужный формат, поэтому ещё до получения контента отрисовывается правильный скелетон, а не универсальная заглушка, которую потом пришлось бы перестраивать.

Каждый слот — независимая асинхронная единица. Такой гибрид SDUI/BDUI ощущается как нативное приложение, а не как веб-страница с лоадерами — и благодаря тому, что Compose мультиплатформенный, это одинаково хорошо работает на Android, вебе и ПК.

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Рекомендательный движок: векторы вкуса вместо статичных категорий

Самая интересная часть — не поиск, а персонализация главной страницы. Идея: строить веса-предпочтения по актёрам, режиссёрам, жанрам и — отдельный плюс TMDB — по кейвордам, которые пересекаются между жанрами. Это позволяет находить неочевидные совпадения: например, одинаковые сюжетные тропы в фильмах совершенно разных жанров.

На основе этих весов было собрано 200 шаблонов подборок для главной страницы.

Честно говоря, я пока не уверен, что это именно то, что хочет видеть пользователь — шаблоны и правила ещё нуждаются в правках. Но с другой стороны, рекомендации как будто бы работают: я сам посмотрел несколько фильмов, которые вряд ли стал бы искать сам, и вряд ли стану рассказывать, что это были за фильмы.

Веса для шаблонов интереса пересчитываются каждые 24 часа, и вслед за этим обновляются манифесты экранов ленты — так рекомендации не залипают на одном и том же наборе. Отдельно учитывается и время суток: например, вечером сервер может собрать подборку специально под запрос «что посмотреть вечером», а не отдавать одну и ту же ленту вне зависимости от того, когда человек зашёл.

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Видеоплееры: там, где казалось, что всё уже придумано

Казалось, что с видео всё давно решено и работает из коробки. Оказалось — нет, и это заняло больше времени разработки, чем всё остальное вместе взятое: на каждой платформе — свой набор проблем с форматами, кодеками и субтитрами.

Технические детали: как видео работает на каждой платформе

Веб. Браузер не поддерживает нативно кучу форматов видео и аудио. Использовались playsvideo/mediabunny + wasm-ffmpeg, DASH, MPEG-TS, HLS. Отдельно стоит выделить связку playsvideo/mediabunny + wasm-ffmpeg — она ремуксит аудио через wasm-ffmpeg в формат, понятный браузеру, и перекладывает MKV в MP4 прямо в браузере, чтобы видео просто воспроизводилось со звуком. В итоге поддерживается почти любой формат, кроме совсем древних AVI/XviD.

Десктоп. Здесь цель была — максимальная совместимость форматов без переписывания кода под каждую ОС. Используется libmpv: видео декодируется силами видеокарты, кадры попадают в оперативную память, а затем — в текстуру Compose, поверх которой рисуется интерфейс плеера. Это даёт оверхед по памяти, зато код общий для Linux и Windows.

Android. Нативный Media3 + Media3AVI, плюс сборка FFmpeg со всеми нужными аудиокодеками. Иногда этого всё равно недостаточно, поэтому дополнительно подключён mpv — он особенно хорошо работает с субтитрами и корректно их позиционирует.

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Android TV и D-Pad-навигация

Отдельным квестом стала адаптация под ТВ и управление D-Pad’ом. Поскольку весь интерфейс живёт в общем модуле, стандартные библиотеки для ТВ не подходили напрямую — пришлось пересобирать похожее поведение вручную: фокус должен оказываться в нужном месте, не теряться при переходах между экранами и в целом вести себя привычно для пользователей ТВ.

Бэкенд и плагины

Бэкенд — Kotlin + Ktor, с плагинами, которые грузятся через отдельные classloader’ы из JAR-файлов. Между ядром и плагином — единый контракт-библиотека, и на деле весь интерфейс формируется именно плагинами: экран фильма, рекомендации, лента — за всё это отвечают отдельные плагины, а не жёстко зашитая в ядро логика.

Плагину доступны данные, которые приходят от клиента. Например, когда пользователь нажимает кнопку на карточке фильма или сериала, плагин может получить его ID, сходить за нужной ссылкой на контент, обработать её и вернуть клиенту готовую ссылку на видеопоток. При этом клиент никогда не обращается к внешним ресурсам напрямую — ссылка всегда идёт через Ktor-прокси на сервере, так что клиент общается только с самим сервером. В репозитории есть несколько ознакомительных плагинов — как пример того, как это может быть реализовано.

Главное преимущество такой разделённой архитектуры — ядро может работать вообще без плагинов, либо только с плагинами для базовой медиатеки (метаданные, трекинг), либо с полным набором расширений. Обратная сторона: плагин выполняется в полноценной JVM-среде и может делать что угодно — без ограничений, которые есть у клиента. Это даёт огромную гибкость для разработки расширений, но одновременно создаёт риски для сервера, если плагин недоверенный — этот компромисс стоит держать в голове при установке сторонних плагинов.

Для удобства администрирования есть админка — управление пользователями (в частности, чтобы делиться доступом с друзьями), настройками провайдеров метаданных и другими серверными параметрами.

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Как я делал self-host медиатеку на Kotlin Multiplatform и почему сервер «стримит» интерфейс на клиент

Итоги и что дальше

Статус проекта: source-available лицензия (PolyForm Shield для ядра, MIT для контракта плагинов).

Далее планируется добавить оповещения о новых сериях, экран статистики и детализации просмотров, визуализацию интересов (например, облако весов по кейвордам и жанрам) и полноценную историю трекинга и просмотров.

Проект постоянно меняется, часть решений, особенно в рекомендательном движке, это скорее гипотезы, чем окончательные ответы. Если тема self-host медиацентров вам близка — буду рад, если попробуете развернуть у себя и напишете, что зашло, а что нет. Багрепорты, вопросы по архитектуре и просто мнение о том, насколько такой подход к рекомендациям и SDUI имеет смысл, — всё это можно оставить в issues репозитория или в комментариях к статье.

Исходный код и релиз: https://github.com/ensodai/avalon-media-card

Ссылка на основную публикацию