Создаём плэйлисты (playlists) M3U/PLS на PHP

Итак, у нас имеется список треков в формате MP3 в нескольих папках.
Мы хотим создать плэйлисты в формате M3U/PLS для этих треков.
Информации по данным форматам не особо много, она пересекается (согласно гуглу, конечно), но в итоге можно использовать то, что мы прочитаем в википедии:
Описание формата M3U – https://ru.wikipedia.org/wiki/M3U. Для него конечно же будем использовать Extended M3U и формат M3U8 – в кодировке UTF-8.
описание формата PLS в русскоязычной ВИКИ ужасное, сами убедитесь: https://ru.wikipedia.org/wiki/PLS_(%D1%84%D0%BE%D1%80%D0%BC%D0%B0%D1%82_%D1%84%D0%B0%D0%B9%D0%BB%D0%B0). Поэтому будем отталкиваться от английской версии – https://en.wikipedia.org/wiki/PLS_(file_format) Там тоже описание сумбурное какое-то, но в принципе, понятное. Это же ВИКИ, что вы хотели?!

Итак, на основе полученных знаний имеем схему-шаблон.
Для M3U8:

#EXTM3U
#EXTINF:Длина в секундах,Исполнитель - название
Путь к треку
#EXTINF:Длина в секундах,Исполнитель - название
Путь к треку
...
#EXTINF:Длина в секундах,Исполнитель - название
Путь к треку

Для PLS:

[playlist]
File1=Путь к треку
Title1=Названние трека (исполнитель)
Length1=Длина в секундах
 
File2=Путь к треку
Title2=Названние трека (исполнитель)
Length2=Длина в секундах
...
FileN=Путь к треку
TitleN=Названние трека (исполнитель)
LengthN=Длина в секундах
 
NumberOfEntries=N
Version=2

Сохранять регистр букв – обязательно. Путь к трекам может быть как абсолютным, так и относительным, также это могут быть ссылки на треки в интернете (URI раличные). Для каждой ОС прнимаются используемые пути файлов, т.е. для Windows это может быть что типа D:\Media\Music\Виктор Цой\Ночь\Ночь.mp3, для Unix-систем /home/user/media/mp3/albums/Рок/Цой/Ночь/ночь.mp3

Титул трека, как я понял из этих сумбурных/неполных описаний, может быть любой, если есть исполнитель, для M3U его надо указать первым, а потом через `-` (тире) само название, но по-моемиу, это совсем не обязательно, поэтому мы будем строить титулы треков в виде: Название – Исполнитель (если есть). Данные по трекам будем брать из MP3-тегов, используя мощную PHP-библиотеку getId3 (http://getid3.sourceforge.net), но подробнее о ней чуть позже.

Время также можно не указывать, плееры по идее должны сами его опрдеелять. Для стримов можно указывать значение -1, т.к. для них длина трека не определена.

Итак, на основании вышеприведденых схем составим уже готовые шаблоны, которые будем использовать при составлении плейлистов. Какждый шаблон должен быть разбит на два под-шаблона – первый – общий шаблон, включающий в себя заголовок/футер и сам списко треков, и второй шаблон – для каждого трека, которые затем объедими в общий список и вставим в общий шаблон.

Получается 4 файлика, по 2 на каждый формат.
m3u8.html общий шаблон

#EXTM3U
%%PLAYLIST%%

m3u8-bit.html шаблон для каждого трека

#EXTINF:%%LENGTH%%,%%TITLE%%
%%PATH%%

pls.html общий шаблон

[playlist]
%%PLAYLIST%%
 
NumberOfEntries=%%NUM%%
Version=2

pls-bit.html шаблон для каждого трека

File%%NUM%%=%%PATH%%
Title%%NUM%%=%%TITLE%%
Length%%NUM%%=%%LENGTH%%

Мы используем общие для всех форматов переменные –
%%PLAYLIST%% – все треки (содержание плэйлиста)
%%TITLE%% – титул трека
%%LENGTH%% – длина трека в секундах
%%PATH%% – абсолютный путь к треку
%%NUM%% – номер текущего трека или общее кол-во треков (используем только для PLS-формата).

Итак, положим эти четыре файлика в папку templates.

Теперь переходим к главному – движку. Используем язык PHP, как мой любимый, тем более он как раз для этого как будто предназначен, судя по его названию. Как указано на главной страничке фака по PHP: PHP stands for PHP: Hypertext Preprocessor. Это реверсивный акроним, так как первая буква P обозначет сама себя, или я что-то не так понимаю в этих реверсивных акронимах? Кроме того, его название можно читать справа налевао и слева направо, и всё равно получим один и тот же результат. Типа как “а роза упала на лапу Азора”. Тупо, но прокатывает. Иногда.

Наша задача – пройтись по всем файлам/подпапкам указанной директории, найти MP3-файлы, пропарсить их теги (будем парсить только артиста (исполнителя) и титул, найти их длину и вставить всё это добро в плэйлист.

Для полного парсинга MP3 файлов будем использовать либу getID3(), о которой уже упоминалось чуть выше. Быстрый гугл не выявил полной документации по использованию либы, и один из комментаторов на SF.NET указывал на этот недостаток. Кроме того, в то время как основная часть либы опубликована под лицензией GPL, разрешающее свободное использование в продуктах, при условии открытости (части) кода, есть куски, лицензированые под коммерческой лицензией. Подробнее можно узнать здесь – http://www.getid3.org (там же скачать пакет).

getID3() может получать инфо о десятках разных форматах – звуковых, видео, архивных, и в неё (библиотеку) входит порядка 75 PHP-файлов (по крайней мере для текущей версии 1.9.12), но нас интересует только те, что имеют отношение к парсингу MP3-файлов, поэтому возьмем из библиотеки и положим в папку getid3 следующие файлы: getid3.php getid3.lib.php module.audio.mp3.php module.tag.apetag.php module.tag.id3v1.php module.tag.id3v2.php module.tag.lyrics3.php. Забегая вперед замечу, что скачать архив с проектом, содержащий все файлы для создания M3U8/PLS плэйлистов на PHP, можно по этой ссылке: http://beotiger.com/download/playlist

Итак, для использования getID3 надо подключить в свой проект главный файл getid3.php, создать новый инстанс класса getID3, и затем применить к нему метод analyze() с параметром, включающим в себя путь к нужному нам MP3-треку.
Примерно так:

require_once('lib/getid3.php');
$getID3 = new getID3;
$info = $getID3->analyze('c:\Users\Andrey\Desktop\Viktor_Tsoi_Mama_Anarhiya.mp3');

В итоге мы получаем ассоциативный (хэш-) массив с кучей полезной инфо об интересующем нас объекте.
Например, такой:

print_r($info); // =>
Array
(
    [GETID3_VERSION] => 1.9.12-201612051806
    [filesize] => 5097416
    [filepath] => C:/Users/Andrey/Desktop
    [filename] => Viktor_Tsoi_Mama_Anarhiya.mp3
    [filenamepath] => C:/Users/Andrey/Desktop/Viktor_Tsoi_Mama_Anarhiya.mp3
    [avdataoffset] => 2367
    [avdataend] => 5097288
    [fileformat] => mp3
    [audio] => Array
        (
            [dataformat] => mp3
            [channels] => 2
            [sample_rate] => 44100
            [bitrate] => 320000
            [channelmode] => joint stereo
            [bitrate_mode] => cbr
            [codec] => LAME
            [encoder] => LAME3.99
            [lossless] => 
            [encoder_options] => --preset insane
            [compression_ratio] => 0.22675736961451
            [streams] => Array
                (
                    [0] => Array
                        (
                            [dataformat] => mp3
                            [channels] => 2
                            [sample_rate] => 44100
                            [bitrate] => 320000
                            [channelmode] => joint stereo
                            [bitrate_mode] => cbr
                            [codec] => LAME
                            [encoder] => LAME3.99
                            [lossless] => 
                            [encoder_options] => --preset insane
                            [compression_ratio] => 0.22675736961451
                        )
 
                )
 
        )
 
    [tags] => Array
        (
            [id3v1] => Array
                (
                    [title] => Array
                        (
                            [0] => Мама Анархия
                        )
 
                    [artist] => Array
                        (
                            [0] => Виктор Цой
                        )
 
                    [album] => Array
                        (
                            [0] => Цой 50 (CD1)
                        )
 
                    [year] => Array
                        (
                            [0] => 2012
                        )
 
                    [comment] => Array
                        (
                            [0] => ExactAudioCopy v0.99pb5
                        )
 
                    [track] => Array
                        (
                            [0] => 3
                        )
 
                    [genre] => Array
                        (
                            [0] => Other
                        )
 
                )
...

Нас интресуют три поля всего – титул трека, исполнитель и длина трека в секундах.
Версий MP3 тэгов 2 – id3v1 и id3v2. Сперва будем пытаться получить инфо из второй версии, при неудаче – из первой таким образом:

// титул
$title = empty($info['tags']['id3v2']['title'][0]) ?
	(empty($info['tags']['id3v1']['title'][0]) ? '' : $info['tags']['id3v1']['title'][0]) :
		$info['tags']['id3v2']['title'][0];
// исполнитель
$artist = empty($info['tags']['id3v2']['artist'][0]) ?
	(empty($info['tags']['id3v1']['artist'][0]) ? '' : $info['tags']['id3v1']['artist'][0]) :
		$info['tags']['id3v2']['artist'][0];

Итак, вот краткий алгоритм работы:
1. обходим дерево директорий в поисках MP3 треков.
2. Для каждого найденного трека определим его титул/исполнителя/длину и запишем в шаблоны плейлистов M3U/PLS.
3. Соберём полученные шаблоны в общий шаблон для каждого из формата плэйлиста M3U/PLS и сохраним их в соотв. файлах.
4. Сообщим общее кол-во треков и полную длину всех треков, а также имена сохраненнёх плейлистов на простой HTML-страничке.

Т.о. в начале скрипта нам надо определить две константы – рутовая папка (корень), от которой будем начинать поиск, и название файлов для плэйлиста (расширения будем использовать стандартные M3U8 для M3U и PLS для PLS.

Также я заметил одну особенность при использовании скрипта в системе Windows – мы получаем имена файлов/папок, заданных на языке текущей локализации (то есть написанных по-русски), в ANSI формате. Так же для поиска файлов/папок с русскими буквами нам надо траслировать их из UTF-8 в Windows-1251 (ANSI кодировка для русской Windows). Т.о. для системы Windows нам понадобится ещё одна константа – FILESYSTEM_ENCODING, равной ‘WINDOWS-1251’. В Линуксе такой проблемы я не наблюдал, все имена файлов отдавались в кодировке UTF-8. Перекодировать туда-сюда имена файлов/папок будем только при наличии данной константы.
Итак, вот наши родимые константы для системы Windows, в Линуксе нужно опустить константу FILESYSTEM_ENCODING:

define('PLAYLIST_NAME', 'radio');
define('ROOT_DIR', 'E:/RADIO');
define('FILESYSTEM_ENCODING', 'WINDOWS-1251'); // для Windows RU

Для обхода дерева каталогов будем использовать написанную нами ещё несколько лет назад маленькую универсальную PHP-функцию processFiles. О том, как её использовать, подробно описанно в соотв. статье этого блога здесь: http://atzar.ru/php-grouped-file-processing/

Итак, вот полный готовый для запуска PHP-скрипт:
Архив с этим скриптом, шаблонами и необходмыми файлами бибилотеки getID3 можно скачать здесь: http://beotiger.com/download/playlist

<?php
/* ****************************** */
/* ****** Playlist creator ****** */
/* Создание PLS и M3U8 плэйлистов */ 
/*	   для заданного пути 	  */
/* ****************************** */
 
set_time_limit(0);	// на всякий случай, бережёного Бог бережёт ;)
 
define('PLAYLIST_NAME', 'radio');
define('ROOT_DIR', 'E:/RADIO');
define('FILESYSTEM_ENCODING', 'WINDOWS-1251'); // для Windows RU
 
require_once('getid3/getid3.php');
 
$getID3 = new getID3;
 
$totallength = 0;	// общая длина всех треков
$num = 0;	// число треков
$t_M3U = ''; // for .m3u8
$t_PLS = ''; // for .pls
 
$dir = ROOT_DIR;
if(defined('FILESYSTEM_ENCODING'))
	$dir = iconv('UTF-8', FILESYSTEM_ENCODING, $dir);
 
$maindir = getcwd();	// сохраним текущий каталог для исп. в функции addItem
 
$timer = time();	// засечём таймер
processFiles($dir, 'playLists', '#\.mp3$#');
 
$m3u = str_replace('%%PLAYLIST%%', $t_M3U, file_get_contents('templates/m3u8.html'));
 
$pls = str_replace('%%PLAYLIST%%', $t_PLS, file_get_contents('templates/pls.html'));
$pls = str_replace('%%NUM%%', $num, $pls);
 
ob_start();
echo '<h1>' . PLAYLIST_NAME . '</h1>' . "\n\n";
echo '<h2>Creating files ' . PLAYLIST_NAME . '.m3u8 & ' . PLAYLIST_NAME . '.pls</h2>' . "\n";
 
// need to convert
$local_title = defined('FILESYSTEM_ENCODING') ?
	iconv('UTF-8', FILESYSTEM_ENCODING, PLAYLIST_NAME) : PLAYLIST_NAME;
 
file_put_contents($local_title . '.m3u8', $m3u);
file_put_contents($local_title . '.pls', $pls);
 
$formatted = formatTime($totallength);
$totallength = round($totallength);
$timer = time() - $timer;	// таймер стоп!
 
echo "<h3>Total files: $num with $totallength seconds [$formatted]<h3>\n";
echo "<h4>Script run for $timer seconds</h4>\n";
 
$s = ob_get_clean();
/* END OF MAIN CODE */
 
/* **************************************************************** */
/* **************************************************************** */
/* **************************************************************** */
function playLists($name)
{
	global $t_M3U, $t_PLS, $getID3, $num, $totallength;
 
	++$num;
 
	$info = $getID3->analyze($name);
 
	// титул
	$title = empty($info['tags']['id3v2']['title'][0]) ?
		(empty($info['tags']['id3v1']['title'][0]) ? '' : $info['tags']['id3v1']['title'][0]) :
			$info['tags']['id3v2']['title'][0];
	// исполнитель
	$artist = empty($info['tags']['id3v2']['artist'][0]) ?
		(empty($info['tags']['id3v1']['artist'][0]) ? '' : $info['tags']['id3v1']['artist'][0]) :
			$info['tags']['id3v2']['artist'][0];
 
	// getID3 иногда не может определить время трека?
	$seconds = empty($info['playtime_seconds']) ? -1 : $info['playtime_seconds'];
 
	// try to convert name into UTF-8 encoding
	$path = defined('FILESYSTEM_ENCODING') ?
		iconv(FILESYSTEM_ENCODING, 'UTF-8', realpath($name)) : realpath($name);
 
	if(!empty($title))
		$title = !empty($artist) ? $title . ' - ' . $artist : $title;
	else
		if(!empty($artist))
			$title = $artist;
 
	$t_M3U .= addItem('m3u8', round($seconds), $title, $path);
	$t_PLS .= addItem('pls', round($seconds), $title, $path, $num);
 
	if($seconds > 0)
		$totallength += $seconds;
}
 
// add one item to the specified playlist (pls or m3u8)
function addItem($type, $seconds, $title, $path, $num = 0)
{
	// we need to fall back to out main directory
	global $maindir;
	$curDir = getcwd();
	chdir($maindir);
 
  $bit = file_get_contents('templates/' . $type . '-bit.html');
 
  $bit = str_replace('%%LENGTH%%', $seconds, $bit);
  $bit = str_replace('%%TITLE%%', $title, $bit);
  $bit = str_replace('%%PATH%%', $path, $bit);
  $bit = str_replace('%%NUM%%', $num, $bit);
 
  chdir($curDir);
  return $bit;
}
 
function processFiles($dir, $callback, $mask = '/.*/', $recursive = true)
{
	$dh = opendir($dir);
	if(!$dh)
		return;
 
	$curDir = getcwd();
	chdir($dir);
 
	while (($entry = readdir($dh)) !== false) {
		if(substr($entry, 0, 1) == '.')
		// hidden entry
			continue;
		$fullName = $dir . '/' . $entry;
		if(is_dir($entry)) {
			if ($recursive)
				processFiles($entry, $callback, $mask);
			continue;
		}
		if(preg_match($mask,$fullName))
			call_user_func($callback,$entry);
	}
 
	chdir($curDir);
}
 
// показать число секунд в формате ЧЧ:ММ:СС (часы/минуты/секунды)
function formatTime($duration)
{
    $hours = floor($duration / 3600);
    $minutes = floor( ($duration - ($hours * 3600)) / 60);
    $seconds = $duration - ($hours * 3600) - ($minutes * 60);
   	return sprintf("%02d:%02d:%02d", $hours, $minutes, $seconds);
}
 
?>
<!doctype html>
<html>
<head>
	<meta charset="utf-8">
	<title>Playlists creator</title>
</head>
<body>
<?php echo $s; ?>
</body>
</html>

Пара замечаний: почему-то для некотрых треков getID3 не может найти длину, но, похоже, это случается довольно редко. В таком случае мы указываем её в плйлисте как -1. Так же возможна путаница в кодировках тэгов, так как их можно задавать и в UTF-8, и в Windows-1251, да в любой кодировке. Но крякозябры в титулах/исполнителях я видел постоянно в разных плеерах, что под Виндовс, что под Линух, что 10 лет назад, что сейчас, так что ничего страшного, но при желании, думаю, эту проблему можно как-то обойти, хотя не уверен на 100%.

А в чём можно быть уверенным в наше время на 100%? Да ни в чём, наверное, кроме как в завтрашнем дне.

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

Удачи на даче, чаю крепкого как виски, а не как пиво, на столе!
С наступающим новым 2017 годом!