Свой сервер обложек на Python для интернет-радио

в 8:29, , рубрики: icecast, javascript, jquery, nginx, python, Программирование, Разработка веб-сайтов
image

Я перфекционист который любит во всём порядок. Больше всего меня радует когда вещи работают именно так, как они должны работать (в моём, разумеется, понимании). А ещё у меня уже давно есть своё персональное интернет-радио на базе IceCast-KH + LiquidSoap. И много лет мне не давал спокойно спать тот факт, что сервера потокового радиовещания не умеют отдавать обложки (artwork) проигрываемых треков в потоке. Да и не только в потоке — вообще никак не умеют. Я и на IceCast-KH (форк от IceCast2) перешёл только из-за одной его убер-фичи — он умеет отдавать mp3-тэги внутри flv потока (это нужно для отображения исполняемого трека при онлайн воспроизведении на сайте через флэш-плеер). И теперь пришло время закрыть последний вопрос — отдачу обложек проигрываемых треков — и успокоиться. Поскольку готовых решений не нашлось, я не придумал ничего лучше, чем написать свой сервер обложек для .mp3 файлов. Как? Добро пожаловать под кат.

Предыстория

Радио я обычно слушаю в машине, на 2-din магнитоле на базе Android 4.4 KitKat (а дома на планшете под тем же Андроидом). Для прослушивания, после долгого и вдумчивого перебора существующих программ, была выбрана XiiaLive, в основном за то, что она умеет в пользовательские радиостанции (такая банальная, казалось бы фича, но не поддерживается большинством плееров потокового радио — вот тебе каталог ShoutCast/Uber Stations — выбирай и слушай что дают), а также за то, что умеет подкачивать и отображать обложки проигрываемых треков. Да, конечно, не всех, но умеет. Музыка играла, обложки частично показывались и на какое-то время внутренний перфекционист успокоился, но как оказалось — ненадолго.

Через некоторое время всплыл крайне неприятный баг приложения связанный с неверной обработкой юникода — если и название трека и исполнителя было не в латинице — обложка альбома показывалась неверно. Мало того — всегда одна и та же. И я вам даже больше скажу — это почему-то всегда была Нюша. Вот этого я уже вытерпеть не смог.

image

Скриншот иллюстрирующий как XiiaLive покусился на святое.

Можно было бы подождать, пока разработчики пофиксят этот баг, но, здраво рассудив, что вряд ли у них найдутся обложки для всего, что находится в ротации именно на моей станции (у них точно не будет обложек для Ishome, Interior Disposition, tmtnsft и тем более MΣ$†ΛMN ΣKCПØNΛ†), показалось правильнее написать своё api для обложек. Которое будет уметь работать именно по локальной базе файлов с музыкой и, по возможности, без привязки к конкретному серверу вещания.

Исследуем вопрос

Найти описание стандартного протокола для отдачи обложек не удалось (предполагаю, что единого стандарта вообще нет), поэтому решил пойти от обратного — посмотреть как это реализовано у больших дядек, в частности у того же XiiaLive. Вооружаемся Packet Capture на Android, ловим пакеты и смотрим куда приложение ходит и зачем:

GET /songart.php?partner_token=7144969234&title=Umbrella&artist=The+Baseballs&res=hi HTTP/1.1
User-Agent: Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L)
Host: api.dar.fm
Connection: Keep-Alive
Accept-Encoding: gzip

HTTP/1.1 200 OK
Server: Apache/2.2.15 (CentOS)
X-Powered-By: PHP/5.3.3
Set-Cookie: PHPSESSID=u5sgs13h1315k9184nvvutaf33; expires=Fri, 03-Aug-2018 18:39:08 GMT; path=/; domain=.dar.fm
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
X-Train: wreck="mofas16"
Content-Type: application/xml; charset=UTF-8
Content-Length: 57
Accept-Ranges: bytes
Date: Thu, 03 Aug 2017 18:39:08 GMT
Via: 1.1 varnish
Age: 0
Connection: keep-alive
X-Served-By: cache-ams4143-AMS
X-Cache: MISS
X-Cache-Hits: 0
X-Timer: S1501785548.973935,VS0,VE390

Оказалось, что посылается обычный GET запрос с четырьмя переменными:

  • partner_token — токен авторизации, при запросе без него, или с неправильным токеном — возвращается 403.
  • title — заголовок трека
  • artist — имя исполнителя
  • res — желаемое разрешение картинки. Несложный перебор дал следующий набор выдаваемых разрешений (обложки квадратные, так что разрешение описывается одним числом):
    * hi — 1080 px
    * low — 250 px
    * во всех остальных случаях — 400 px

В ответ на запрос приложение ожидает в ответ xml такого вида:

<?xml version="1.0" encoding="UTF-8" ?>
<songs>
	<song>
<arturl>http://coverartarchive.org/release/c8b16143-e87e-440d-bbb2-5c96615bed2b/2098621288-500.jpg
	</arturl>
	<artist>The Baseballs</artist>
	<title>Tik Tok</title>
	<album></album>
	<size>1080</size>
</song>
</songs>

И следующим запросом приложение ожидаемо идёт на сервер статики за картинкой. Если по сочетанию “Исполнитель” + “Название трека” ничего не найдено, то возвращается пустой xml:

<?xml version="1.0" encoding="UTF-8" ?>
<songs>
</songs>

Проектирование

Окей, входные и выходные параметры чёрного ящика определены, осталось выстроить логику его работы. И самое главное — решить откуда мы будем брать обложку для запрошенного трека.

Делать отдельную базу картинок обложек, как-то связывать её с треками, поддерживать в актуальном состоянии — мне лень. Да и не стоит умножать сущностей, заводить какие-то лишние базы и связи, ибо формат mp3 тегов ID3v2 поддерживает хранение обложек в самих mp3 файлах уже много лет, вот и будем ходить внутрь файла за обложкой (если она там, конечно, есть). А если файл не найден (или обложки в нём нет), то вместо пустого xml мы лучше будем отдавать одну из дефолтных обложек для радиостанции, чтобы пользователь не смотрел в пустой квадрат.

Вообще я предпочитаю всё скриптовать и поменьше работы делать руками. Например, как сейчас выглядит добавление файла в ротацию: закинул файл по ftp/scp в inbox каталог и забыл. Через минуту пришёл скрипт обслуживания, нашёл файл, переименовал его как нужно и переложил в каталог радиостанции. А раз в 10 минут LiquidSoap перечитает каталог, обнаружит новый файл и добавит его в плейлист. Придёт запрос на обложку — скрипт найдёт файл и извлечёт обложку из него.

У хорошего системного администратора даже Sysadmin Day отмечается автоматически.
По cron-у.

Правда в процессе реализации и тестирования логика несколько усложнилась. Ведь зачастую есть ещё cover.jpg в каталоге альбома (для исполнителей, которые в ротации присутствуют целыми альбомами). А есть ещё многочисленные исполнители из SoundCloud / PromoDJ да и просто из vk которые редко собирают треки в альбомы, или вообще заботятся вопросом обложки для трека. Для этих исполнителей (их не так уж много), заведём на сервере статики отдельный каталог с дефолтными обложками по имени исполнителя.

Последний вопрос: как найти соответствующий запрошенным тэгам файл на диске, учитывая что на момент начала поиска у нас есть только имя исполнителя и название трека? Можно хранить информацию где-нибудь в БД по ключам “исполнитель, трек -> файл на диске”, можно ходить по файлам, смотреть в них mp3-теги сравнивая с запросом (но это долго), а можно, следуя принципу не умножения сущностей просто хранить файлы на диске с именами вида "%artist% — %title%.mp3". У меня сделано именно так. Когда-то для этого я пользовался лучшей, на мой взгляд, для этих целей, программой TagScanner от Сергея Серкова, а потом перешёл на python-скрипт, который автоматически переименовывает файлы в нужный формат.

Окончательная логика работы получилась такая:

  • Приняли GET запрос.
  • Если запрос пустой (не содержит GET параметров) — возвращает пустой XML
  • Если включена авторизация по токенам (не нулевой список tokens в файле конфигурации) — проверяется пришедший токен. Если токен неверен — 401 Unauthorized.
  • Если в запросе присутствуют переменные artist и title происходит поиск в локальном каталоге mp3 файлов:
  • Если файл не найден — возвращается пустой XML
  • Если файл найден — последовательность следующая:
  • Проверяем — нет ли уже готовой обложки для этого файла в каталоге обложек? Если есть — отдаём ссылку на неё.
  • Если в файле есть обложка — извлекаем её в каталог с обложками, отдаём ссылку.
  • Если в в каталоге с .mp3 файлом есть обложка альбома (файл cover.jpg) — переносим его в каталог обложек альбомов, отдаём ссылку на него.
  • Если в каталоге исполнителей есть обложка с именем `artist` — отдаём ссылку на него.
  • Если совсем ничего не найдено — отдаём случайную картинку из каталога дефолтных обложек радиостанции.

Ну а теперь, когда логика работы определена, осталось только оформить её в виде функций.

Код

Для извлечения обложек из mp3-файлов воспользуемся модулем mutagen. Функция, которая извлекает обложки из mp3 файлов и пишет их в .jpg:

import mutagen.mp3

def extract_cover(local_file, covers_dir, cover_name):
   """
   Extracts cover art from mp3 file
   :param local_file: file name (with path)
   :param covers_dir: path to store cover art files
   :param cover_name: name for extracted cover art file
   :rtype: bool
   :return:
       False - file not found or contains no cover art
       True - all ok, cover extracted
   """
   try:
       tags = mutagen.mp3.Open(local_file)
       data = ""
       for i in tags:
           if i.startswith("APIC"):
               data = tags[i].data
               break
       if not data:
           return False
       else:
           with open(covers_dir + cover_name, "w") as cover:
               cover.write(data)
               return True
   except:
       logging.error('extract_cover: File "%s" not found in %s', local_file, covers_dir)
       return False

Если в файле есть обложка и мы её успешно извлекли — делаем ресайз под нужные размеры с сохранением пропорций картинки (ибо не всегда в файле лежат стандартные квадратные обложки). С этим отлично справляется Python Imaging Library (PIL), который ещё и умеет в antialias:

from PIL import Image

def resize_image(image_file, new_size):
   """
   Resizes image keeping aspect ratio
   :param image_file: file name (with full path)
   :param new_size: new file max size
   :rtype bool
   :return:
       False - resize unsuccessful or file not found
       True - otherwise
   """
   try:
       img = Image.open(image_file)
   except:
       return False
   if max(img.size) != new_size:
       k = float(new_size) / float(max(img.size))
       new_img = img.resize(tuple([int(k * x) for x in img.size]), Image.ANTIALIAS)
       img.close()
       new_img.save(image_file)
   return True

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

В моей практике был случай, когда из 15-и мегабайтного .mp3 файла половину (7.62 мб), занимала обложка размерами 3508x3508, к тому же с нестандартным цветовым профилем. Этот файл наглухо вешал программу TagScanner, которой я пользуюсь для редактирования тегов. Не знаю, сколько бы отправлялся этот файл по 3G связи, и что стало бы с Андроидом при попытке подогнать его под размер экрана.

Так как XiiaLive не имеет настроек для выбора сервера обложек, пришлось подменить адрес api.dar.fm, к которому он обращается, на свой. На рутованном Android это просто:

/etc/hosts
<my_api_ip>		api.dar.fm

И объясняем Nginx, что все приходящие запросы, вне зависимости от того, куда они пришли и чего хотят — обслуживает наш скрипт. Заодно поднимаем виртуальный хост для статики, откуда будут отдаваться картинки. Конечно, можно всё делать в рамках одного хоста, но всё-таки лучше мухи api отдельно, а котлеты статика — отдельно.

upstream fcgiwrap_factory {
   server                        unix:/run/fcgiwrap.socket;
   keepalive                     32;
}

server {
   listen                        80;
   server_name                   api.<yourserver> api.dar.fm;

   root                          /var/wwws/<yourserver>/api;
   access_log                    /var/log/nginx/api.access.log main;
   error_log                     /var/log/nginx/api.error.log;

   location / {
       try_files                 $uri    /api.py?$args;
   }

   location ~ api.py {
       fastcgi_pass              fcgiwrap_factory;
       include                   /etc/nginx/fastcgi_params;
       fastcgi_param             SCRIPT_FILENAME   $document_root$fastcgi_script_name;
   }
}

server {
    listen                        80;
    server_name                   static.<yourserver>

    root                          /var/wwws/<yourserver>/static;
    access_log                    /var/log/nginx/static.access.log main;
    error_log                     /var/log/nginx/static.error.log;
    index                         index.html;

    location / {
    }
}

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

find /var/wwws/<yourserver>/static/covers/ -maxdepth 1 -type f -iname '*.jpg' -atime +7 -exec rm {} ;

Разумеется, чтобы это работало, на разделе с музыкой не должен быть установлен noatime.

image
Ну вот всё и заработало, как должно работать.

Доработка

Через неделю я проанализировал логи сервера и обнаружил интересное: сразу после запуска приложение посылает запрос вида:

GET /songart.php?partner_token=7144969234&res=hi HTTP/1.1" 200 334 "-" "Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L)

И только некоторое время спустя:

GET /songart.php?partner_token=7144969234&title=Summer+Nights&artist=John+Travolta+and+Olivia+Newton-John&res=hi HTTP/1.1" 200 334 "-" "Dalvik/1.6.0 (Linux; U; Android 4.4.2; QuadCore-R16 Build/KVT49L)

Соответственно между этими двумя запросами на экране никакой обложки нет, темнота и уныние.

Непорядок.

Причина ясна: приложение при запуске ещё не успело извлечь из потока тэги и не знает что играет, почему бы ему не помочь? Добавим первым пунктом ещё одно условие в логику работы программы:

  • Если пришел GET запрос с токеном авторизации, но без указания исполнителя и названия трека — отдать картинку для текущего проигрываемого трека. Если есть переменная stream — из запрошенного потока вещания, иначе — из того, который мы считаем основным.

Но откуда брать название текущего трека? Не грепать же логи сервера. Очень удачно, что Icecast умеет отдавать состояние примонтированных точек в XML или JSON формате. JSON для Python более нативен, поэтому будем использовать его. Т.к. в Icecast-KH такой статистики “из коробки” нет, воспользуемся xsl файлом из статьи уважаемого namikiri, нечувствительно доработанным мной:

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
   <xsl:output omit-xml-declaration="yes" method="text" doctype-public="-//W3C//DTD XHTML 1.0 Strict//EN"
               doctype-system="http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" indent="no" encoding="UTF-8"
               media-type="application/json; charset=utf-8"/>
   <xsl:strip-space elements="*"/>
   <xsl:template match="/icestats">
       {
       <xsl:for-each select="source">
           "<xsl:value-of select="@mount"/>":
           {
           "name" : "<xsl:value-of select="server_name"/>",
           "listeners" : "<xsl:value-of select="listeners"/>",
           "listener_peak" : "<xsl:value-of select="listener_peak"/>",
           "description" : "<xsl:value-of select="server_description"/>",
           "title" : "<xsl:value-of select="title"/>",
           "genre" : "<xsl:value-of select="genre"/>",
           "url" : "<xsl:value-of select="server_url"/>"
           }
           <xsl:if test="position() != last()">
               <xsl:text>,</xsl:text>
           </xsl:if>
       </xsl:for-each>
       }
   </xsl:template>
</xsl:stylesheet>

Файл кладём в web каталог Icecast-kh (на Ubuntu по умолчанию это /usr/local/share/icecast/web/), и при обращении через http получаем в ответ что-то типа такого:

{
            "/256":
            {
            "name" : "Radio /256kbps",
            "listeners" : "2",
            "listener_peak" : "5",
            "description" : "mp3, 265kbit",
            "title" : "The Kelly Family - Fell In Love With An Alien",
            "genre" : "Various",
            "url" : ""
            },

            "/128":
            {
            "name" : "Radio /128kbps",
            "listeners" : "0",
            "listener_peak" : "1",
            "description" : "mp3, 128kbit",
            "title" : "The Kelly Family - Fell In Love With An Alien",
            "genre" : "Various",
            "url" : ""
            },

            "/64":
            {
            "name" : "Radio /64kbps",
            "listeners" : "0",
            "listener_peak" : "2",
            "description" : "mp3, 64kbit",
            "title" : "The Kelly Family - Fell In Love With An Alien",
            "genre" : "Various",
            "url" : ""
            }
}

Как видно — радио имеет три точки монтирования (на самом деле несколько больше), вещающих один и тот же поток, но с разным качеством. Ну а дальше всё совсем просто:

import urllib2
import json

def get_now_playing(stats_url, stats_stream):
   """
   Retruns current playing song - artist and title
   :param stats_url: url points to icecast stats url (JSON format)
   :param stats_stream: main stream to get info
   :return: string "Artist - Title"
   """
   try:
       stats = json.loads(urllib2.urlopen(stats_url).read())
   except:
       logging.error('get_current_song: Can not open stats url "%s"', stats_url)
       return False

   if stats_stream not in stats:
       logging.error('get_current_song: Can not find stream "%s" in stats data', stats_stream)
       return False
   return stats[stats_stream]['title'].encode("utf-8")

Функция ходит по указанному адресу статистики, и возвращает исполнителя и заголовок текущей композиции из нужного потока. Поток приходит либо в запросе, либо берётся дефолтный (из настроек).

Web

Теперь пришла пора заняться сайтом. Для онлайн воспроизведения я давным-давно использую бесплатный flash-плеер от uppod в минималистичных настройках, который смотрит в /flv поток и при воспроизведении отображает проигрываемый трек. Выглядит это так:

image

А для отображения текущего трека, когда плеер свёрнут или неактивн, я, как и многие другие столкнувшиеся с этой проблемой, до недавнего времени пользовался прокладкой в виде .php скрипта на сервере, который ходил к Icecast за статистикой и возвращал строку с именем проигрываемого трека. Пора избавляться от промежуточных шагов, да и обложки на сайте во время онлайн-воспроизведения показывать бы хотелось, раз уж я теперь умею их отдавать.

Задача решается в два шага:

Добавляем в конфигурацию Nginx для api кастомный header разрешающий обращаться к нему через jQuery с другого хоста:

add_header                      Access-Control-Allow-Origin *;

И помещаем в тело веб-страницы радиостанции такой скрипт:

var now_playing = '';

setInterval(function () {
   jQuery.ajax(
       {
           type: "GET",
           url: "http://api.<yoursite>/?partner_token=<token>&stream=/<stream>",
           dataType: "xml",
           success: xmlParser
       })
}, 5000);

function xmlParser(xml) {
   var parsedXml = jQuery(xml);
   var title = parsedXml.find('title').text();
   var artist = parsedXml.find('artist').text();
   var arturl = parsedXml.find('arturl').text();
   var song = artist.concat(" — ").concat(title);

   if (now_playing !== song) {
       jQuery('div.now_playing').html(song);
       jQuery('div.cover_art').html(arturl);
       now_playing = song;
   }
};

Как видим, раз в пять секунд скрипт ходит туда же, куда и приложение, авторизуется там, получает .xml файл и забирает из него проигрываемый трек и ссылку на обложку. И если с момента прошлой проверки они изменились — то пишет их в нужные div-ы веб-страницы радиостанции для отображения. Сразу прошу господ фронтенд-разработчиков не ругаться на возможную корявость скрипта — jQuery я вижу первый (ну ладно — второй), раз в жизни. Скрипт может и неказист, но прекрасно работает.

Заключение

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

Я понимаю, что описанная тема достаточно узкоспецифичная, и может быть интересна небольшому кругу людей, но думаю, что мой опыт кому-нибудь всё-таки пригодится. Так что полные тексты всего описанного выше кода, плюс примеры настроек Nginx и описание установки, доступны на GitHub.

Всем музыки!

Автор: adel-s

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js