Небольшой проектик JFF на perl+mojo

Сиё творение родилось из простенькой, казалось бы, задачи: прицепить подрядовские вебкамеры к медиацентру.

Что хотелось видеть изначально: плейлист с перечнем камер со ссылками вида http://.*\.jpeg. Открываешь его, тыкаешь в нужную камеру м смотришь обстановку на дороге. В крайнем случае - пачку плейлистов, ровно с одним пунктом. Как показывает опыт, xbmc просто до жопы хочет обязательно постоить превьюшку к каждому пункту плейлиста, и начинает их дёргать все сразу, что неприемлемо и в данном случае - не по джентельменски. Из-за дикого количества запросов на сервер подряда, нас могут просто рубануть по ip или useragent'у.

Грабли с превьюшками - это полбеды. Как выяснилось в дальнейшем, xbmc прекрасно понимает http, и пришедший по нему jpeg. НО! обрабатывает их не как картинку, а как видео из одного кадра. Повезёт, если увидишь как там что-то мелькнуло в 1/25ю секунды1.

Поэтому, возникла гениальная идея - быстренько забацать аналог udpxy, который:

  • можно насиловать запросами сколько угодно и с какой угодно частотой,
  • может отдавать не обязательно jpeg.

Из требований - возможно меньше зависимостей, кроме perl'а.

Кодинг

В репозиториях debian'а есть и perl и mojolicious, поэтому установку я расписывать не буду, перейдём сразу к коду.

$ mojo generate app Livecam

Структура проекта:

$ tree -A -S -n livecam
livecam/
├── cache # кэш списка каналов и изображений с камер
├── lib
│   ├── Livecam
│   │   └── Main.pm # контроллер
│   └── Livecam.pm # роутер
├── public
├── script
│   └── livecam # скрипт запуска
├── t # здесь должны быть тесты, но по факту - не используется
│   └── basic.t
└── templates # не используется, у нас все ответы - plain http
    └── layouts
        └── default.html.ep

В роутере нужно дописать 2 пути и повесить Mojo::UserAgent как ресурс приложения, чтобы не инициализировать его каждый раз.

$r->get('/playlist') -> to('main#playlist');
$r->get('/livecam')  -> to('main#livecam');

$self->app->attr(ua => sub {
  require Mojo::UserAgent;
  my $ua = Mojo::UserAgent->new;
  # поставить любой, похожий на настоящий
  $ua->name(qq{Mozilla/5.0 (X11; Linux i686; rv:24.0) Gecko/20140925});
  return $ua;
});

В контроллере кода побольше. Перечень функций с их назначением 2:

# private, работа со списком камер
_pls_cache_path
_pls_cache_save
_pls_cache_load
# private, работа с изображениями камер
_cam_cache_path
_cam_cache_save
_cam_cache_load
# и 2 метода, вызываемых из роутера, которые мы рассмотрим подробнее
playlist
livecam

В любой непонятной ситуации - смотрите код, там его совсем мало.

playlist()

Назначение - получить полный список камер с сайта подряда (и закешировать его), отдать его в виде плейлиста m3u.

Камеры раскиданы по 4 страницам, поэтому получим каждую и пройдёмся по ним парсером. Здесь нам поможет Mojo::DOM, совершенно замечательная штука, этакое jquery для perl'а (селекторы и манипуляция элементами, без ajax'а, которым занимается уже Mojo::UserAgent с Mojo::IOLoop).

Итак, структура html'я каждой камеры довольно проста: три div'а, враппер, имя камеры и ссылка с превью.

# перебор камер на странице
$self->app->ua->get($url)->res->dom('div.cam-preview')->each(sub {<...>});
# ...и пример вытаскивания данных внутри обработчика в each();
#   из такого: <a href="<...>" class="<...>">Title</a>
my $title = $_->at('div.title a')->text;

Всё остальное здесь не представляет особого интереса.

livecam()

Назначение - получить изображение с камеры, отдать перекодированное и ограничить частоту запроса к внешнему серверу за счёт кеширования вывода.

Изначально, я хотел использовать для вывода gif, но в итоге отказался (+зависимость от gd2/im::m/g::m и всё равно - генерация заголовка gif'а руками). В итоге остановился на mjpeg over http. В отличие от просто mjpeg, с точки зрения браузера это выглядит так:

  • в заголовках посылается 'Content-Type: multipart/x-mixed-replace;boundary=--Frame'
  • далее, пока страница не закрыта/держится соединение, периодически посылаются порции данных такого вида:

    --Frame\r\n Content-Type: image/jpg\r\n Content-Length: $size\r\n \r\n \r\n \r\n

С точки зрения mojo, это реализуется так:

  • пишется обработчик с такой логикой:
    • достать из кэша/получить извне кадр с внешней камеры с таким-то id
    • сформировать "кадр" из примера выше, отдать его клиенту
  • повесить обработчик на повторное выполнение каждые N секунд
  • дёрнуть обработчик в первый раз, чтобы клиент не ждал до срабатывания периодического таймера
  • висеть, пока таймер периодически кормит клиента кадрами
  • при отвале клиента - аккуратно снять таймер, закрыть соединение

Всё, теперь мы можем дёргать наш сервер хоть 50 раз в секунду, запросы наружу пойдут только по мере устаревания картинки в кеше.

Эпилог

И всё таки она верт^W^W оно не работает. Потому что в xbmc не настолько продвинутый http-клиент. Картинку кажет, достаточно долго, но вот обновлять - хрен. Зато в браузере работает только так. Кроме того, обнаружился неприятный баг - плейлист самопроизвольно удаляется при закрытии через 'stop'.

Напишу в их багзиллу, пусть думают.


  1. 25fps - берётся по умолчанию ↩

  2. их назначение примерно понятно из названий, но тем не менее ↩