Решение проблемы хранения музыки для Интернет-радио

91db5836ff014fecb472f78fe69b4ae3.jpg
Добрый день, %username%!
Как-то мы с компанией друзей решили сделать интернет радио, но как оказалось, выделяемого места на VPS недостаточно для большого архива музыки, более того покупка дополнительных гигабайтов — настоящий грабеж.
Я долго искал решение, как вдруг наткнулся на прекрасную статью ableev«Яндекс.Диск как файловая система». Меня посетила идея, почему бы не хранить музыку на Яндекс диске? Опустим здесь проблемы лицензирования и авторских прав — это совсем другая история, меня же интересует техническая часть. Как оказалось не всегда IceCast успевает подгружать музыку с Яндекс диска, что приводит к запинаниям и прерываниям в вещании, а это совсем не хорошо. Эта проблема меня зацепила, и я нашел решение — определять что играет в текущий момент на радио и заранее подгружать следующие треки, а проигранные треки с сервера удалять. Это порождает трафик, согласен, но на текущий момент VPS с безлимитным трафиком полно, а с безлимитным местом на дисках нет.

Так как из языков я худо-бедно владею C#, пришлось прибегнуть к mono, а также написать несколько вспомогательных скриптов на Python, PHP, bash.

Вспомогательные скрипты в студию!

id3.py получает при вызове аргументом трек, из которого берет теги и записывает их в текстовый файл:

id3.py
#!/usr/bin/env python

class Unbuffered(object):
   def __init__(self, stream):
       self.stream = stream
   def write(self, data):
       self.stream.write(data)
       self.stream.flush()
   def __getattr__(self, attr):
       return getattr(self.stream, attr)

import eyeD3
import sys
import argparse

def createParser ():
        parser = argparse.ArgumentParser()
        parser.add_argument('-f', '--file')
        
        return parser

if __name__ == '__main__':
        parser = createParser()
        namespace = parser.parse_args(sys.argv[1:])


tag = eyeD3.Tag()
tag.link(namespace.file)
sys.stdout = Unbuffered(sys.stdout)
s = tag.getArtist()+" - "+tag.getTitle()
f = open('tag.txt','w')
f.write(s)
f.close()


getCurrent.php парсит страничку IceCast с информацией о текущем треке и выдает название играющего трека.

getCurrent.php
<?php
error_reporting(0);
header("Content-Type: text/html; charset=UTF-8");
$file_name="http://localhost:8000/status.xsl?mount=/stream";
$r=fopen($file_name,'r');
$text=fread($r,10000);
fclose($r);
$mas=explode('<tr>', $text);
$name = explode(':', $mas[3]);
$q = explode ('</td>',$name[1]);
$q2 = explode ('<td class="streamdata">',$q[1]);
$rj = $q2[1];

if($rj == "0" or $rj == ""){
echo " Nonstop"; 
}else
{
$fl = file_get_contents('http://localhost:8000/status.xsl?mount=/stream');
function antara($string, $start, $end){
$string = " ".$string;
$ini = strpos($string,$start);
if ($ini == 0) return "";
$ini += strlen($start);
$len = strpos($string,$end,$ini) - $ini;
return substr($string,$ini,$len);
}
$stream = antara($fl,"<td>Stream Title:</td>\n<td class=\"streamdata\">","</td>");

$description = antara($fl, "<td>Stream Description:</td>\n<td class=\"streamdata\">", "</td>");

$listeners = antara($fl, "<td>Current Listeners:</td>\n<td class=\"streamdata\">", "</td>");

$max = antara($fl, "<td>Peak Listeners:</td>\n<td class=\"streamdata\">", "</td>");

$song = antara($fl, "<td>Current Song:</td>\n<td class=\"streamdata\">", "</td>");

echo $song;
?>


Маленький скрипт на bash isOnline.sh проверяет запущен ли скрипт радио и информацию о статусе также пишет в текстовый файл.

isOnline.sh
#!/bin/bash
rm isOnline.txt
ps ax | grep -v grep | grep radio.sh > isOnline.txt


Скрипт радио liquidsoap radio.sh — собственно само радио.

radio.sh
#!/usr/bin/liquidsoap

# создаём переменные быстрого исправления в одном месте по необходимости
# базовая информация о выводимом потоке
out = output.icecast(
# хост с icecast
host = "127.0.0.1",

# его порт
port = 8000,

# логин
user = "source",

# и пароль
password = "password",

# название
name = "Radio Name",

# жанр
genre = "Various",

# ссылка на сайт
url = "http://www.host.local"

# кодировка
encoding = "UTF-8"
)

# включаем telnet-сервер
#set("server.telnet.bind_addr","127.0.0.1")
#set("server.telnet",true)

# _____________________________________
# Описание файловой структуры нашего радиосервера. 
# Переменные можно не использовать, а писать сразу полные пути к плейлистам, но при изменении названия одной из папок, придётся править довольно много строк в конфигурации. Как показала практика, такой подход удобнее.

# абсолютный путь к рабочей директории
#wd = "/home/admin/radio"

# путь к папке с аудиофайлами
#pl = "#{wd}/collection"

# техническая папка
#tech = "#{wd}/technical"

# логи
set("log.file.append",true)
set("log.file",true)
set("log.file.path","/var/data/liquidsoap.log") # путь к файлу лога
set("log.level", 3) # уровень логирования
#set("buffering.kind","disk_manyfiles")
#set("decoding.buffer_length",30.)
#set("buffering.path","/tmp/radio")
# папка с информационными вставками
#promo_dir = "#{pl}/promo"

# папка с программами
#progr_dir = "#{pl}/programs"

# папка с изменяющимся эфиром
#ef = "#{pl}/efir"

# папки соответствующих эфиров
#ni = "#{ef}/night"

# папки с музыкой
mus_ni_dir = "loc.playlist.txt"

# папки с джинглами
jin_ni_dir = "/mnt/username.yadisk/RT/jingles"

#promo
promo_dir = "/mnt/username.yadisk/RT/promo"

mus_ni = playlist (reload = 86400, "#{mus_ni_dir}", mode = "normal")
jin_ni = playlist (reload = 86400, "#{jin_ni_dir}", mode = "normal")
promo    = playlist (reload = 86400,  "#{promo_dir}")
ni = rotate (weights = [1, 10, 1, 5], [jin_ni, mus_ni, promo, mus_ni])

radio = switch (track_sensitive = true, [
  #({ (2w16h10m - 2w16h20m) or (3w14h - 3w14h5m) or (4w16h - 4w16h5m)}, spekt), ({ (3w23h - 3w23h5m) or (4w10h - 4w10h5m)}, pozdr), ({ (2w18h - 2w18h5m) or
#(3w18h - 3w18h5m)}, xmas_trad), ({ (6w14h - 6w14h10m) or (6w18h - 6w18h10m)}, prog1), ({ (6w0h - 7w18h)}, xmas_mus_prog),
  ({ 1w0h - 7w23h59m }, ni)
])

radio = mksafe(radio)
radio = crossfade(start_next=2., fade_out=2., fade_in=2., radio)

out(
 %mp3(bitrate = 128, id3v2 = true),
 description = "Radio Name 128kbps",
 mount = "stream",
 mksafe(radio)
)



Генератор списка воспроизведения generator.sh создает список файлов на смонтированном диске, перемешивает список и записывает в текстовый файл. Это скрипт замечателен тем, что здесь можно добавить много дисков и собрать все в 1 плейлист.

generator.sh
#!/bin/sh
find "/mnt/username.yadisk/RT/music" -name '*.mp3' -print > "disk.playlist.txt"
shuf -n 500 "disk.playlist.txt" -o "disk.playlist.txt"
sed 's/\/mnt\/username.yadisk\/RT\/music/\/home\/admin\/rt\/tmp/g' disk.playlist.txt > loc.playlist.txt


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

radio_control.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Collections;
using System.Diagnostics;


namespace radio_control
{
    class song
    {
        string filename;
        string tagname;
        string prepared;

        System.Diagnostics.Process getTag;


        public song()
        {
            getTag = new System.Diagnostics.Process();
            getTag.EnableRaisingEvents = false;
            getTag.StartInfo.FileName = "./id3.py"; 
        }

        public string FileName
        {
            get { return filename; }
            set { filename = value; }
        }
        public string Tag
        {
            get { return tagname; }
            set { tagname = value; }
        }
        public string Prepared
        {
            get { return prepared; }
            set { prepared = value; }
        }

        /// <summary>
        /// Загружает файл во временную папку, и записывает путь к файлу в поле Prepared
        /// </summary>
        /// <param name="tmpDir"></param>
        public void Prepare(string tmpDir)
        {
            File.Copy(FileName, tmpDir+Path.GetFileName(FileName), true);
            Prepared = tmpDir + Path.GetFileName(FileName);
            Tag = _getTag().Replace(Environment.NewLine,"");
        }

        //удаляет файл из временной директории
        public void Destroy()
        {
            File.Delete(Prepared);
        }

        //Получаем тэги подготовленного файла
        string _getTag()
        {
            getTag.StartInfo.Arguments = "-f " + this.Prepared;
            getTag.Start();
            getTag.WaitForExit();
            StreamReader str = new StreamReader("tag.txt");
            string tag = str.ReadToEnd();
            str.Close();
            return tag;
        }

    }

    class Program
    {


        static void Main(string[] args)
        {
            //счетчик, чтобы определить какой элемент списка подгружать
            int count = 0;

            string pl_file = "/home/admin/rt/disk.playlist.txt"; //расположение плейлиста
            string tmpDir = "/home/admin/rt/tmp/"; //расположение директории для временных файлов
            string newplTime = "23:30";
            //готовим список объектов песен
            List<song> songs = new List<song>();

           
            //Процесс получения текущего играющего трека с icecast
            System.Diagnostics.Process getCurrent = new System.Diagnostics.Process();
            getCurrent.EnableRaisingEvents = false;
            getCurrent.StartInfo.RedirectStandardOutput = true;
            getCurrent.StartInfo.FileName = "/usr/bin/php";
            getCurrent.StartInfo.UseShellExecute = false;
            getCurrent.StartInfo.Arguments = "getCurrent.php";

            //Процесс проверки, запущен ли скрипт радио
            System.Diagnostics.Process isRadio = new System.Diagnostics.Process();
            isRadio.EnableRaisingEvents = false;
            getCurrent.StartInfo.UseShellExecute = false;
            getCurrent.StartInfo.RedirectStandardOutput = true;
            isRadio.StartInfo.FileName = "isOnline.sh";

            //Процесс запуска радио
            System.Diagnostics.Process radio = new System.Diagnostics.Process();
            radio.EnableRaisingEvents = false;
            getCurrent.StartInfo.UseShellExecute = false;
            getCurrent.StartInfo.RedirectStandardOutput = true;
            radio.StartInfo.FileName = "screen";
            radio.StartInfo.Arguments = "-dmS radio liquidsoap --verbose radiotera.sh";

            //Процесс прибития скрина radio 
            System.Diagnostics.Process killRadio = new System.Diagnostics.Process();
            killRadio.EnableRaisingEvents = false;
            getCurrent.StartInfo.UseShellExecute = false;
            getCurrent.StartInfo.RedirectStandardOutput = true;
            killRadio.StartInfo.FileName = "screen";
            killRadio.StartInfo.Arguments = "-X -S radio quit";

            //Процесс запуска генератора плейлиста
            Process genPl = new Process();
            genPl.EnableRaisingEvents = false;
            genPl.StartInfo.UseShellExecute = false;
            genPl.StartInfo.FileName = "generator.sh";

            //создаем очередь из треков, очередь - это стек типа first in - first out
            Queue queue = new Queue(3);

                //загружаем в плейлист список файлов
                string[] playlist = (System.IO.File.ReadAllLines(pl_file));
                log("Loading playlist");
                //определяем ID3 теги файлов и заполняем настоящий плейлист
                song sng;
                foreach (string value in playlist)
                {
                    sng = new song();
                    sng.FileName = value;
                    songs.Add(sng);
                }
                log("Playlist loaded.", ConsoleColor.Green);
                //подготавливаем первые 3 трека и увеличиваем счетчик
                songs[0].Prepare(tmpDir);
                count++;
                songs[1].Prepare(tmpDir);
                count++;
                songs[2].Prepare(tmpDir);
                count++;
                

                //добавляем их в очередь
                queue.Enqueue(songs[0]);
                queue.Enqueue(songs[1]);
                queue.Enqueue(songs[2]);
                log("First 3 tracks prepared:\n"+songs[0].Tag+"\n"+songs[1].Tag+"\n"+songs[2].Tag, ConsoleColor.Green);



                //основной цикл программы
                song tmp = new song();
                StreamReader rdr;
            string online = "";
            
                while (true)
                {
                    log("Check time to change playlist"); //проверяем время изменения плейлиста
                    if (DateTime.Now.ToString("HH:mm") == newplTime)
                    {
                        log("Its time to change playlist!", ConsoleColor.Red);
                        genPl.Start();  //генерируем плейлист
                        genPl.WaitForExit();
                        log("Playlist generated", ConsoleColor.Green);
                        log("Loading playlist");
                        playlist = (System.IO.File.ReadAllLines(pl_file));
                        songs.Clear();
                        foreach (string value in playlist) //загружаем плейлист
                        {
                            sng = new song();
                            sng.FileName = value;
                            songs.Add(sng);
                        }
                        log("Playlist loaded", ConsoleColor.Green);

                    }

                    log("Check the availability of radio");

                    isRadio.Start();
                    isRadio.WaitForExit();
                    rdr = new StreamReader("isOnline.txt");
                    online=rdr.ReadToEnd();
                    rdr.Close();
                    //Включено ли радио?
                    if (online == "")
                    {
                        log("Radio is offline!\nClearing screen", ConsoleColor.Red);
                        killRadio.Start();
                        killRadio.WaitForExit();
                        log("Starting the radio");
                        radio.Start();
                        log("Waiting for 10 seconds");
                        System.Threading.Thread.Sleep(10000);
                    }
                    log("Get current song"); //получаем текущий трек
                    getCurrent.Start();
                    getCurrent.WaitForExit();
                    string curr = getCurrent.StandardOutput.ReadToEnd();
                    if (curr == "") continue;
                    //получаем первый трек очереди
                    tmp = new song();
                    tmp = (song)queue.Peek();

                    //проверяем, этот ли трек сейчас играет
                    log("Now playing: " + curr, ConsoleColor.Cyan);
                    log("Checking queue tag: "+tmp.Tag, ConsoleColor.Cyan);
                    if (tmp.Tag != curr)
                    {
                        //если играет не он, но играет джингл — ничего не делаем.
                        //Задавайте своим джинглам одинаковые тэги и впишите их в проверку здесь
                        if ((curr == "Radio TERA - Radio TERA") || (curr=="Unknown") || (curr=="NonstopS")) 
                        {
                            log("Now playing radio jingle, move on to the next iteration");
                            continue;
                        }
                        log("The current track is different from the queue!!!\nMove the queue", ConsoleColor.Red);
                        //если все таки трек уже закончился, то убираем трек из очереди
                        queue.Dequeue();
                        log("Dequene track with tag "+tmp.Tag);

                        //удаляем трек из временной папки
                        log("Remove the track ended");
                        tmp.Destroy();

                        //копируем следующий трек во временную папку
                        log("Prepare next track");
                        songs[count].Prepare(tmpDir);
                        //добавляем его в очередь
                        log("Adding it to queue "+songs[count].Tag);
                        queue.Enqueue(songs[count]);
                        
                        
                        //увеличиваем счетчик
                        count++;
                        log("Count now: " + count.ToString());
                        //если счетчик превышает количество песен, значит плейлист закончился и пора играть все сначала
                        if (count > songs.Count - 1)
                        {
                            count = 0;
                        }
                    }

                    //висим 30 секунд
                    System.Threading.Thread.Sleep(30000);
                }

        }
        static void log(string str)
        {
            Console.WriteLine(str);
        }
        static void log(string str, ConsoleColor frcolor)
        {
            Console.ForegroundColor = frcolor;
            Console.WriteLine(str);
            Console.ForegroundColor=ConsoleColor.White;
        }
    }
}


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

Для нормальной работы вам понадобятся:

  • mono
  • liquidsoap
  • screen
  • IceCast
  • davfs2
  • php
  • eyeD3 для Python

Запускается система через radio_control.cs. Все. Дальше он сам запустит радио, сгенерирует плейлист, подгрузит музыку и при этом будет писать в терминал, что он делает.

Радио к сожалению мы закрыли, но мне очень хотелось, чтобы мои труды не пропали напрасно, надеюсь, кому-нибудь помог.

© Habrahabr.ru