Свой сервер обложек на Python для интернет-радио
Я перфекционист который любит во всём порядок. Больше всего меня радует когда вещи работают именно так, как они должны работать (в моём, разумеется, понимании). А ещё у меня уже давно есть своё персональное интернет-радио на базе IceCast-KH + LiquidSoap. И много лет мне не давал спокойно спать тот факт, что сервера потокового радиовещания не умеют отдавать обложки (artwork) проигрываемых треков в потоке. Да и не только в потоке — вообще никак не умеют. Я и на IceCast-KH (форк от IceCast2) перешёл только из-за одной его убер-фичи — он умеет отдавать mp3-тэги внутри flv потока (это нужно для отображения исполняемого трека при онлайн воспроизведении на сайте через флэш-плеер). И теперь пришло время закрыть последний вопрос — отдачу обложек проигрываемых треков — и успокоиться. Поскольку готовых решений не нашлось, я не придумал ничего лучше, чем написать свой сервер обложек для .mp3 файлов. Как? Добро пожаловать под кат.
Предыстория
Радио я обычно слушаю в машине, на 2-din магнитоле на базе Android 4.4 KitKat (а дома на планшете под тем же Андроидом). Для прослушивания, после долгого и вдумчивого перебора существующих программ, была выбрана XiiaLive, в основном за то, что она умеет в пользовательские радиостанции (такая банальная, казалось бы фича, но не поддерживается большинством плееров потокового радио — вот тебе каталог ShoutCast/Uber Stations — выбирай и слушай что дают), а также за то, что умеет подкачивать и отображать обложки проигрываемых треков. Да, конечно, не всех, но умеет. Музыка играла, обложки частично показывались и на какое-то время внутренний перфекционист успокоился, но как оказалось — ненадолго.
Через некоторое время всплыл крайне неприятный баг приложения связанный с неверной обработкой юникода — если и название трека и исполнителя было не в латинице — обложка альбома показывалась неверно. Мало того — всегда одна и та же. И я вам даже больше скажу — это почему-то всегда была Нюша. Вот этого я уже вытерпеть не смог.
Скриншот иллюстрирующий как 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 такого вида:
http://coverartarchive.org/release/c8b16143-e87e-440d-bbb2-5c96615bed2b/2098621288-500.jpg
The Baseballs
Tik Tok
1080
И следующим запросом приложение ожидаемо идёт на сервер статики за картинкой. Если по сочетанию «Исполнитель» + «Название трека» ничего не найдено, то возвращается пустой xml:
Проектирование
Окей, входные и выходные параметры чёрного ящика определены, осталось выстроить логику его работы. И самое главное — решить откуда мы будем брать обложку для запрошенного трека.
Делать отдельную базу картинок обложек, как-то связывать её с треками, поддерживать в актуальном состоянии — мне лень. Да и не стоит умножать сущностей, заводить какие-то лишние базы и связи, ибо формат 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 мб), занимала обложка размерами 3508×3508, к тому же с нестандартным цветовым профилем. Этот файл наглухо вешал программу TagScanner, которой я пользуюсь для редактирования тегов. Не знаю, сколько бы отправлялся этот файл по 3G связи, и что стало бы с Андроидом при попытке подогнать его под размер экрана.
Так как XiiaLive не имеет настроек для выбора сервера обложек, пришлось подменить адрес api.dar.fm, к которому он обращается, на свой. На рутованном Android это просто:
/etc/hosts
api.dar.fm
И объясняем Nginx, что все приходящие запросы, вне зависимости от того, куда они пришли и чего хотят — обслуживает наш скрипт. Заодно поднимаем виртуальный хост для статики, откуда будут отдаваться картинки. Конечно, можно всё делать в рамках одного хоста, но всё-таки лучше мухи api отдельно, а котлеты статика — отдельно.
upstream fcgiwrap_factory {
server unix:/run/fcgiwrap.socket;
keepalive 32;
}
server {
listen 80;
server_name api. api.dar.fm;
root /var/wwws//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.
root /var/wwws//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//static/covers/ -maxdepth 1 -type f -iname '*.jpg' -atime +7 -exec rm {} \;
Разумеется, чтобы это работало, на разделе с музыкой не должен быть установлен noatime.
Ну вот всё и заработало, как должно работать.
Доработка
Через неделю я проанализировал логи сервера и обнаружил интересное: сразу после запуска приложение посылает запрос вида:
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, нечувствительно доработанным мной:
{
" ":
{
"name" : " ",
"listeners" : " ",
"listener_peak" : " ",
"description" : " ",
"title" : " ",
"genre" : " ",
"url" : " "
}
,
}
Файл кладём в 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 поток и при воспроизведении отображает проигрываемый трек. Выглядит это так:
А для отображения текущего трека, когда плеер свёрнут или неактивн, я, как и многие другие столкнувшиеся с этой проблемой, до недавнего времени пользовался прокладкой в виде .php скрипта на сервере, который ходил к Icecast за статистикой и возвращал строку с именем проигрываемого трека. Пора избавляться от промежуточных шагов, да и обложки на сайте во время онлайн-воспроизведения показывать бы хотелось, раз уж я теперь умею их отдавать.
Задача решается в два шага:
Добавляем в конфигурацию Nginx для api кастомный header разрешающий обращаться к нему через jQuery с другого хоста:
add_header Access-Control-Allow-Origin *;
И помещаем в тело веб-страницы радиостанции такой скрипт:
var now_playing = '';
setInterval(function () {
jQuery.ajax(
{
type: "GET",
url: "http://api./?partner_token=&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.
Всем музыки!