Кулстори: Радио в голосовом канале Discord — Mikulski
Наложение сайта

Кулстори: Радио в голосовом канале Discord

На github можно найти большое количество музыкальных ботов, которые способны воспроизводить в голосовом канале Discord музыкальный контент с поддержкой чат-команд. Как несложно догадаться, в основном, все они заточены под проигрывание Youtube-ссылок (насколько я понял, сейчас эту возможность обрубили и Discord на своей стороне останавливает подобные трансляции во избежание нарушения прав).
Если поизучать документацию discordjs, посвященную взаимодействию с голосовыми каналами, то можно понять, что воспроизводить можно как файлы, так и ссылки с потоками интернет-радиостанций.
Я решил воспользоваться интернет-радио, во-первых, потому что у меня уже был развернут сервер радио-вещания, а во-вторых, Youtube-стрим периодически слетает по ряду причин, а это значит, что пришлось бы каждый раз вставлять новую действительную ссылку в скрипт.
Самые популярные протоколы для интернет-вещания это Shoutcast и Icecast. Я остановился на Shoutcast, потому что он попался мне первым. Но как оказалось впоследствии, он еще и чуть проще в настройке, чем Icecast.

Shoutcast

Нужно создать папку, где будут храниться файлы радио-сервера, перейти в нее, скачать архив с актуальной версией Shoutcast и разархивировать его в созданную папку:

mkdir shoutcast
cd shoutcast
wget https://download.nullsoft.com/shoutcast/tools/sc_serv2_linux_x64-latest.tar.gz
tar -xzf sc_serv2_linux_x64-latest.tar.gz

По умолчанию Shoutcast использует порт 8000 для подключения клиентов, поэтому в firewall нужно добавить правило, которое разрешит такие соединения:

sudo ufw allow 8000

Теперь надо создать и наполнить определенным содержимым конфигурационный файл:

nano sc_serv.conf
adminpassword=пароль
password=пароль2
requirestreamconfigs=1
streamadminpassword_1=пароль3
streamid_1=1
streampassword_1=пароль4
streampath_1=http://IP_ВАШЕГО_VPS:8000
logfile=logs/sc_serv.log
w3clog=logs/sc_w3c.log
banfile=control/sc_serv.ban
ripfile=control/sc_serv.rip

Все пароли должны быть разными, иначе сервер при запуске выдаст ошибку.
Также, streampassword_1 понадобится позднее для того, чтобы из Liquidsoap запустить на сервер аудио-стрим.

Когда конфигурационный файл будет готов, можно запустить сервер фоновым процессом:

./sc_serv &

Теперь, по адресу: http://IP_ВАШЕГО_VPS:8000 доступен интерфейс Shoutcast-сервера. С помощью логина admin и пароля, который вы указали в конфиг-файле можно зайти в расширенные настройки. Но на самом деле, в этом нет необходимости. Главное, что сервер теперь активен. Если что-то пошло не так, то фоновый процесс сервера можно остановить командой:

killall sc_serv

Liquidsoap

Когда Mikulski_Radio работало в режиме аудио + зацикленный фон, то проблем с тем, чтобы вывести аудио на сервер Shoutcast не было, так как Liquidsoap умеет запускать несколько потоков параллельно. То есть концовка скрипта Liquidsoap выглядела вот так:

#вывод / output
output.url(fallible=true, url=url, enc, radio)
output.shoutcast(%mp3, host="IP_ВАШЕГО_VPS", port=8000, password="streampassword_1 ИЗ КОНФИГА Shoutcast", audio)

Где output.url — это вывод на видео-стрим (который сперва приходит в nginx с rtmp-модулем, а он в свою очередь рестримит на все нужные площадки), а radio — это источник, в котором смешиваются аудио и видео источники (audio=плейлист с треками, video=видео файл с зацикленной анимацией, плюс текст, который накладывается поверх видео из других переменных). Подробнее: https://mikulski.rocks/ru/lofi-strim-24-7-guide/
output.shoutcast — собственно вывод на Shoutcast-сервер, куда отправляется только источник audio.
Все! После этого мы имеем два параллельных стрима с одинаковыми метаданными и порядком воспроизведения.

Но после обратного перехода на воспроизведение видео, то такой код уже выдавал ошибку. Так как теперь audio — это единый источник и звука и видео.
Вероятно, что можно с помощью правильно подобранного кода разрешить эти конфликты внутри Liquidsoap, но я решил пойти более простым способом: запустить еще один экземпляр Liquidsoap, который в качестве источника аудио принимал бы rtmp-ссылку, которая фигурирует в nginx (через которую можно напрямую подключиться к видеостриму, например, через VLC-плеер, подробнее: https://mikulski.rocks/ru/svoy-restrim-server/):

audio = mksafe(input.rtmp(listen=false, "rtmp://IP_VPS/live/имя"))

output.shoutcast(%mp3, format="audio/mp3", icy_metadata="true", name="Mikulski_Radio", genre="Alternative",
host="IP_VPS", port=8000, password="streampassword_1", audio)

Здесь важно входящий источник сделать безопасным (mksafe), чтобы в случае отвала стрима, скрипт не ломался, а начинал транслировать тишину. Также важен аргумент listen=false, иначе без этих двух надстроек скрипт будет выдавать ошибку после запуска.
В output.shoutcast обязательно нужно добавить аргумент icy_metadata (true или false неважно насколько я понял), чтобы цифровому было понятно как именно реагировать на то, что не поступают метаданные. Также, нужно задать название интернет радио (name=» «) и по желанию, жанр.
Минус такого подхода в том, что теперь метаданным (артист-название трека) неоткуда взяться (хотя, конечно, можно было бы попробовать что-то напрограммировать через взятие метаданных из генерируемого текстового файла), но с другой стороны оно и не нужно: это радио все равно никуда больше не пойдет, кроме как в Discord, а в Discord текущий трек отображается в статусе бота другим способом от основного стрима.

Discord

Как и было сказано в самом начале этого поста — музыкальных ботов для Discord огромное множество. Проблема в том, что большинство из них перегружено ненужным мне функционалом (скармливание ссылок для воспроизведения, подключение по команде из чата и так далее), а также в том, что разные версии API discordjs и сопутствующих node-модулей приводят к разного рода конфликтам, приводящих, как правило к полной неработоспособности.
Изначально, я пользовался discobot, сделанным на логике discordjs-12й версии. Работал он исправно, с оговоркой на рандомные потери связи с голосовым каналом или трансляцией тишины (что объяснялось особенностями API). Тем не менее, все это лечилось добавлением process.exit(0) в функции, отлавливающие ошибки, чтобы скрипт останавливал свою работу, а pm2 автоматически его перезапускал. В общем, терпимо, а главное работает.
Однако, после перерыва в трансляции радио в Discord (после того, как Mikulski_Radio вернулось к видео-формату), discobot, по-прежнему, успешно подключался к голосовому каналу, но не запускал экземпляр ffmpeg, который должен перекодировать аудио в opus-формат, который понимает Discord. В общем, после полной переустановки бота и безуспешных попыток отредактировать код, я решил искать другие варианты.
Пожалуй, здесь самое время отметить, что начиная с 13й версии discordjs, взаимодействие с голосовыми каналами отделилось в самостоятельный node-модуль — «@discordjs/voice».
Сперва, я несколько вечеров пытался написать бота по инструкции последней версии discordjs своими силами (ведь, по сути, мне нужен простейший функционал). У меня получалось запустить перекодировщик ffmpeg, но бот никак не подключался к голосовому каналу (инструкция оказалась не очень нуб-юзер-френдли). Я начал пробовать устанавливать других ботов с github, в коде которых мог разобраться. Один из них сработал и успешно запустил музыку в голосовой канал.. Но, спустя минуту звук обрывался и больше не восстанавливался (сейчас я предполагаю, что это было вызвано тем, что в скрипте был выставлен битрейт аудио 128кбс, а на серверах Discord без Nitro в голосовых каналах допустимый максимум 96кбс).
В конце концов, я нашел максимально простой (то, что мне и было нужно с самого начала) скрипт discord-js-v13-24-7-radio.
Различия discordjs между 13 и 14 версиями, не такая разительная, как с 12-й, поэтому совместимость с последней (на момент написания статьи) 14-й, сохранилась. За тем исключением, что вместо npm install sodium, нужно добавить два других модуля.
В общем, вот как выглядит мой package.json:

{
  "dependencies": {
    "@discordjs/opus": "^0.9.0",
    "@discordjs/voice": "^0.16.0",
    "discord.js": "^14.9.0",
    "ffmpeg-static": "^5.1.0",
    "libsodium": "^0.7.11",
    "libsodium-wrappers": "^0.7.11",
    "play-dl": "^1.9.6"
  }
}

Установка нужных модулей:

npm install discord.js
npm install @discordjs/voice
npm install @discordjs/opus
npm install play-dl
npm install ffmpeg-static
npm install libsodium
npm install libsodium-wrappers

Сам скрипт я немного переделал, добавив config.json:

{ 
  "LINK": "http://IP_VPS:8000/Mikulski_Radio", //ссылка на Shoutcast-трансляцию
  "CHANNEL_ID": " ", //Id голосового канала
  "GUILD_ID": " ", //Id сервера Discord
  "TOKEN":" " //токен бота
}

Добавил лог-сигналы на подключение/переподключение/отключение и наобум повставлял отловы ошибок в лог (этот код здесь не буду указывать, потому что ни одной ошибки еще не было за несколько дней работы, что удивительно):

const Discord = require('discord.js');
const { LINK, CHANNEL_ID, GUILD_ID, TOKEN } = require("./config.json");
const { createAudioPlayer, createAudioResource , StreamType, demuxProbe, joinVoiceChannel, NoSubscriberBehavior, AudioPlayerStatus, VoiceConnectionStatus, getVoiceConnection } = require('@discordjs/voice');
const client = new Discord.Client({ intents: [
  Discord.GatewayIntentBits.Guilds,
  Discord.GatewayIntentBits.GuildVoiceStates
]});

client.once('ready', () => {
	console.log("Status: Radio Connected to discord");
});


client.on('ready', async () => {
		function radiostream () {
		let guild = client.guilds.cache.get(GUILD_ID);
		const voiceChannel = guild.channels.cache.get(CHANNEL_ID);

		const connection = joinVoiceChannel({
			channelId: voiceChannel.id,
			guildId: voiceChannel.guild.id,
			adapterCreator: voiceChannel.guild.voiceAdapterCreator,
		});

connection.on("error", e => {
		console.log("Status: Radio Connection error");
		console.log(e);
		process.exit(0);
		});

connection.on(VoiceConnectionStatus.Signalling, () => {
	    console.log('DiscordRadio requests connection to the Voice Channel');
	    process.exit(0);
	});
		
connection.on(VoiceConnectionStatus.Disconnected, () => {
	    console.log('DiscordRadio disconnected from the Voice Channel');
	    process.exit(0);
	});


const resource = createAudioResource(LINK);
		let player = createAudioPlayer({
	    behaviors: {
		noSubscriber: NoSubscriberBehavior.Play,
	},
});

player.play(resource);

player.on('error', error => {
		    console.log('Radio Error: ', error.message);
			radiostream();
			});

player.on(AudioPlayerStatus.Idle, () => {
			radiostream();
			});

connection.subscribe(player);

connection.on('stateChange', (old_state, new_state) => {
			if (old_state.status === VoiceConnectionStatus.Ready && new_state.status === VoiceConnectionStatus.Connecting) {
			connection.configureNetworking();
	 }
  });
}

radiostream();
})

client.login(TOKEN);

Если удастся-таки отловить ошибки, которых пока что еще не было, то дополню этот код в этом посте, ну и добавлю process.exit() для этих случаев, чтобы pm2-менеджер автоматически перезапускал бота. А так все работает очень стабильно и уже продолжительное время!

UPD:
1) Отловил ошибку соединения — добавил блок («Status: Radio Connection error»).
2) Заметил любопытную особенность: если в голосовой канал какое-то время никто не заходит, то экземпляр ffmpeg останавливает свою работу. Когда после этого пользователь присоединяется в голосовой канал, то сперва проигрывается фрагмент, видимо, хранящийся в буфере, после чего ffmpeg возобновляет свой процесс и переключает аудио на актуальный источник.
Чтобы актуальное аудио играло непрырывно — в «let player = createAudioPlayer» добавил аргументы, которые исключают остановку ffmpeg.
3) Удалось отловить еще случаи, когда нарушается работа бота.
Чаще всего происходит следующее: бот переходит в статус запроса на соединение с голосовым каналом и больше не может присоединиться.
Редко бывает и такое, что бот просто вылетает из голосового канала.
Добавил строчки process.exit(0) в скрипт, чтобы pm2 перезагружал скрипт.