На 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 перезагружал скрипт.