Решение проблемы хранения музыки для Интернет-радио
Добрый день, %username%!
Как-то мы с компанией друзей решили сделать интернет радио, но как оказалось, выделяемого места на VPS недостаточно для большого архива музыки, более того покупка дополнительных гигабайтов — настоящий грабеж.
Я долго искал решение, как вдруг наткнулся на прекрасную статью ableev«Яндекс.Диск как файловая система». Меня посетила идея, почему бы не хранить музыку на Яндекс диске? Опустим здесь проблемы лицензирования и авторских прав — это совсем другая история, меня же интересует техническая часть. Как оказалось не всегда IceCast успевает подгружать музыку с Яндекс диска, что приводит к запинаниям и прерываниям в вещании, а это совсем не хорошо. Эта проблема меня зацепила, и я нашел решение — определять что играет в текущий момент на радио и заранее подгружать следующие треки, а проигранные треки с сервера удалять. Это порождает трафик, согласен, но на текущий момент VPS с безлимитным трафиком полно, а с безлимитным местом на дисках нет.
Так как из языков я худо-бедно владею C#, пришлось прибегнуть к mono, а также написать несколько вспомогательных скриптов на Python, PHP, bash.
Вспомогательные скрипты в студию!
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 с информацией о текущем треке и выдает название играющего трека.
<?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 проверяет запущен ли скрипт радио и информацию о статусе также пишет в текстовый файл.
#!/bin/bash
rm isOnline.txt
ps ax | grep -v grep | grep radio.sh > isOnline.txt
Скрипт радио liquidsoap 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 плейлист.
#!/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
Теперь осталось написать управляющий скрипт, который будет следить, не упал ли скрипт радио, запускать его в случае падения, следить за плейлистом, подгружать и удалять треки.
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. Все. Дальше он сам запустит радио, сгенерирует плейлист, подгрузит музыку и при этом будет писать в терминал, что он делает.
Радио к сожалению мы закрыли, но мне очень хотелось, чтобы мои труды не пропали напрасно, надеюсь, кому-нибудь помог.