Кулстори: как устроены последние апдейты на Mikulski_Radio (Liquidsoap, nodejs, telebot: список бустеров с таймером, динамический текст с контролем через Телеграм-бота, реквест-бот для Телеграм) — Mikulski
Наложение сайта

Кулстори: как устроены последние апдейты на Mikulski_Radio (Liquidsoap, nodejs, telebot: список бустеров с таймером, динамический текст с контролем через Телеграм-бота, реквест-бот для Телеграм)

В данном посте я решил поделиться с вами тем, что находится «под капотом» у последних модернизаций Mikulski_Radio. А также ходом процесса создания этих скриптов: от появления идеи до их реализации на базе простейших инструментов. Возможно, что для кого-то это послужит источником вдохновения или поводом для смеха. Для меня же это станет закреплением полученного опыта, а также гайдом на будущее: если вдруг придется восстанавливать все эти алгоритмы заново. Например, мои собственные туториалы по установке и настройке Liquidsoap и Nginx уже пригождались мне не раз: когда необходимо, я просто все делаю следуя инструкциям.

В данной статье я не буду очень подробно останавливаться на каких-то моментах, связанных с работой VPS или особенностях Liquidsoap, как языка программирования. Вероятно, что для лучшего понимания о чем здесь идет речь, следует ознакомиться с предыдущими постами, которые касаются создания Mikulski_Radio:
https://mikulski.rocks/ru/kak-ja-sdelal-svoe-strim-radio-24-7/
https://mikulski.rocks/ru/lofi-strim-24-7-guide/

https://mikulski.rocks/ru/kak-ustroena-rekvest-sistema/

Список подписчиков Boosty

До того момента как радио снова вернулось к трансляции видеофайлов, список бустеров был «вшит» в основное зацикленное видео. То есть, каждый раз, когда возникала необходимость внести какие-либо правки в имеющийся список, мне приходилось в видеоредакторе править этот текст, рендерить видео и загружать его на сервер. В целом, это не занимало много времени и благо, что Liquidsoap не реагировал вылетами на горячую замену файлов (впрочем, я все равно старался подменять файлы во время переключения плейлиста на новостной блок, что создавало дополнительное неудобство в виде ожидания). И я сразу понимал, что логичнее и удобнее было бы пользоваться в Liquidsoap функцией file.getter и просто выводить на экран содержимое файла. Нюанс заключается в том, что вывод текста на экран осуществляется плагинами, которые рендерят текст в изображение. Встроенный native-плагин ограничен и не может использовать никакие другие шрифты, но может отображать список. Сторонние же плагины camlimages (не воспринимает кириллицу) и gd (которым я пользуюсь для отображения всего остального текста) отображают не файл целиком, а только первую его строку.
Поначалу я решил выводить список Бустеров отдельным роликом: аналогично переключению на новостной блок — плейлист переключается на запасной, где лежит видеоролик со списком в определенный интервал времени. Но потом я все же решил вернуться к тому, чтобы отображать список прямо из файла, пускай и в не самом благовидном шрифте. Оставалось только понять как сделать, чтобы список не висел постоянно на экране, а появлялся время от времени.
Идея заключалась в том, чтобы в заданный интервал времени брать содержимое файла А — референса (со списком внутри), копировать его содержимое в файл В (за содержимым которого следит Liquidsoap для вывода на экран), подождать некоторое время и после этого переписать файл В пустой строкой. Удалять файл нельзя, потому что Liquidsoap выдает ошибку при его отсутствии, поэтому в него записывается » «.
По идее, это можно было бы реализовать внутри самого Liquidsoap, но я решил использовать nodejs, чтобы иметь больше контроля над настройками: менять интервалы показа, директории и так далее, без нужды перезапускать каждый раз Liquidsoap:

const fs = require('fs'); // встроенный модуль nodejs для работы с файловой системой
const list = '/home/boosters_updater/boosters_list' // файл-референс со списком
const boosters = '/home/radio/boosters' // файл отображаемый на стриме

//сперва записываем пустоту в файл, выходящий на стрим, чтобы при рестарте скрипта точно ничего не отображалось на экране
    fs.writeFile(boosters, " ", (err) => {
        if (err) {
            console.log(err);  
        }
    })  
//основная функция:
function boosters_update() {

fs.readFile(list, "utf8", function(error,data){
    if(error) throw error; //читаем файл референс..

    fs.writeFile(boosters, data, (err) => {
        if (err) {
            console.log(err); //..переписываем отображаемый файл содержимым референса
        }
    })
})
//тайм-аут выполнения функции и сама функция на удаление содержимого отображаемого файла
setTimeout(boosters_erase, 45000) //таймаут 45 секунд (указывается в милисекундах)

//функция удаления:
function boosters_erase() {

    fs.writeFile(boosters, " ", (err) => {
        if (err) {
            console.log(err);  //файл переписывается "пустотой"
        }
    })    
}
}
setInterval(boosters_update, 60000 * 15) //задается интервал выполнения основной функции каждые 15 минут.

Бегущая строка и анонсы живых стримов с управлением через Telegram-бота

Про бегущую строку я думал давно, в том числе и в контексте отображения списка бустеров. Но и здесь поджидали сюрпризы, связанные с особенностями Liquidsoap:
Отображаемому тексту, помимо координат, можно задать скорость, с которой он будет плыть по экрану, а также аргумент «cycle», от которого зависит проплывет ли текст лишь единожды (false) или будет зациклен (true). Штука в том, что цикл работает не по окончанию отображаемого текста, а по достижению «левой границы». При этом это не буквальная граница экрана: текст может за нее уходить и она есть «где-то там». В общем, на практике получается, что в зависимости от размера шрифта для корректного отображения удается вместить лишь определенное количество символов текста. А если задать отрицательную скорость с включенным циклом, то текст проплывет вправо лишь единожды и больше никогда не повторится, ведь он так и не достигнет левой границы.
Все еще размышляя в контексте отображения списка Бустеров, мне пришла хорошая, но тупая идея: отключить цикл и через дополнительный скрипт с определенным интервалом добавлять в строку список Бустеров. В конечном итоге, я отказался от этой идеи, как от неудачной по ряду причин.
Также я пытался внутри Liquidsoap положить функцию отображения текста в другую функцию, которая бы ее вызывала в определенный интервал времени. Но это не привело ни к каким результатам. В лучшем случае, стрим запускался без ошибок, но текст не отображался вовсе.
Я пытался найти какое-то решение на форумах посвященных Liquidsoap и вроде как этому есть костыль: создать отдельные границы экрана и в них пустить текст (насколько я понял), тем самым увеличить объем вмещаемой информации. Но после нескольких безуспешных попыток это повторить, я оставил и эту затею, так как она оказалось явно не по моим скиллам.
Потому я вернулся к тому, чтобы просто отображать в этой строке какую-либо информацию, которая бы помещалась в вычисленные лимиты символов. И пока я над этим думал, я решил, что неплохо было бы еще поместить вверху стрима оповещения о том, что в данный момент идет стрим на основном канале. А чтобы проще было всем этим управлять еще и попробовать прицепить Телеграм-бота, чтобы через команды он менял эту информацию на экране.
В общем, принцип все тот же: в Liquidsoap прописываются file.getter, отображающие содержимое файлов, а сторонний скрипт подсовывает в эти файлы содержимое. Просто теперь в цепочку добавляется еще модуль Telebot для взаимодействия с Telegram.
Шаблон с несколькими примерами:

const fs = require('fs'); //встроенный модуль для работы с файловой системой
const TeleBot = require('telebot'); //npm install telebot
const token = 'токен_телеграм_бота'
const bot = new TeleBot(token); //бот с подключением по токену
const info = '/home/radio/info'; //файл с текстом для бегущей строки

//защита, чтобы бот реагировал только на определенного пользователя:
const MY_ID = 000000; // telegram userID - уникальный номер пользователя Telegram

bot.mod('message', (data) => {
	const msg = data.message;
	const userId = msg.from.id;

	if (userId !== MY_ID) {
	data.message = {};
	bot.sendMessage(userId, 'Action is not allowed'); //бот проверит совпадение по userID и если оно не совпадает с указанным, то выдаст уведомление, что действие невозможно
	}
	return data;
});

//команда /say с аргументами. то есть все что идет после команды пропишется в бегущей строке:
bot.on(/^\/say (.+)$/, (msg, props) => {
    const text = props.match[1];
    fs.writeFile(info, text, (err) => {
        if (err) {
            console.log(err);
        }});
    return bot.sendMessage(msg.from.id, text + ' -> is Published!')
});

//команда /socials, которая вставляет в файл уже заготовленный текст
bot.on('/socials', (msg) => {
    fs.writeFile(info, "|  Website: https://mikulski.rocks/  |  Linktree: linktr.ee/Mikulski |",  (err) => {
        if (err) {
            console.log(err);
        }
    });
    return bot.sendMessage(msg.from.id, 'Socials is Published!')
});


//команда /clear, чтобы очистить строку
bot.on('/clear', (msg) => {
fs.writeFile(info, " ", (err) => {
    if (err) {
        console.log(err);  
    }
});
return bot.sendMessage(msg.from.id, 'Post is Cleared!')  
});

//команда /stream для оповещения о стримах на Бусти
//с функцией очистки содержимого через 2 часа, после активации команды,
//аналогично примеру со списком Бустеров
bot.on('/stream', (msg) => {
    fs.writeFile(info, 'Streamer is LIVE! ', (err) => {
        if (err) {
            console.log(err);
        }});
      
        setTimeout(go_live_erase, 60000 * 120)
        function go_live_erase() {
            fs.writeFile(info, " ", (err) => {
                if (err) {
                    console.log(err);  
                }
            })      
        }
        return bot.sendMessage(msg.from.id, 'go_boosty -> is Published!')
});

bot.start() //запуск бота

Отслеживание ошибок в реквестах

Долго стоял вопрос о том, как оповещать пользователя о том, что была допущена ошибка в реквесте и Liquidsoap не может найти файл с таким названием. Получалось так, что реквест принимался, статусная строка отображала, что следующим треком будет указанный в команде трек. При этом в логе Liquidsoap выходила ошибка, что файл не найден (не видная пользователю) и следующим треком просто играл другой трек. И только так становилось понятным, что в реквесте или в пути к файлу ошибка.
Music_Craft как-то отмечал, что было бы неплохо показывать, что «что-то пошло не так» при реквестах. И я честно пытался внутри Liquidsoap найти способ вылавливать ошибку «Nonexistent file or ill-formed URI» из лога, чтобы прописывать ее в отдельный файл. Но ничего из этого не вышло.
Но спустя время, когда я уже сделал список Бустеров с бегущей строкой и думал, чтобы еще такого усовершенствовать, я снова вспомнил Music_Craft’а и тут меня осенило:
Можно сохранять лог Liquidsoap в отдельный файл и проверять этот файл сторонним скриптом на наличие строки с содержанием «Nonexistent file or ill-formed URI»!
Скрипт вылавливает эту строку и сохраняет текстовое оповещение об ошибке в файл, за которым следит Liquidsoap для вывода на экран. После чего очищает логфайл, чтобы не напороться на эту строчку снова. И еще добавляется интервал очистки логфайла, чтобы он не разрастался до больших объемов.
Для этого случая понадобился модуль linereader.

const fs = require('fs');
const lineReader = require('line-reader'); //npm install line-reader
const log = '/home/radio/log/log_radio'; //файл куда Liquidsoap сохраняет лог
const request_error = '/home/radio/next-song'; //файл, отображаемый на стриме (статусная строка)

//функция для чтения лога построчно и поиска конкретного содержимого в этой строке
//в случае совпадения -> запись в файл об ошибке и очистка лога
function read() {
lineReader.eachLine(log, function(line) {
   
    if (line.includes('Nonexistent file or ill-formed URI')) {
        fs.writeFile(request_error, 'Oops! Invalid Request! File not found. ', (err) => {
            if (err) {
                console.log(err);
            }});
            fs.writeFile(log, ' ', (err) => {
                if (err) {
                    console.log(err);
                }});
                console.log("Catched bad request");                
    }  
});
}

//функция очистки лога
function flush () {
    fs.writeFile(log, ' ', (err) => {
        if (err) {
            console.log(err);
        }}); 
        console.log("Log is flushed"); 
}

setInterval(read, 5000); //интервал проверки лога (каждые 5 секунд)
setInterval(flush, 60000 * 60); //интервал очистки лога (каждый час)

Реквест треков через Telegram-бота

Когда был сделан отлов ошибок, то я подумал о том, что почему бы не сделать тогда еще заказ треков и через Telegram. Раз уж я все равно освоил модуль telebot в должной мере, чтобы создавать собственных простеньких ботов.
В общем-то, тут дело оставалось за малым. Технология была еще отработана на реквестах через DonationAlerts и чат Твича: https://mikulski.rocks/ru/kak-ustroena-rekvest-sistema/
С тем, чтобы бот определял пользователей по @username, а не по userID (который еще нужно уметь узнать) и как создавать массивы мне очень помог SpaceMelodyLab.

const fs = require('fs');
const TeleBot = require('telebot');
const token = 'Токен_телеграм_бота';
const bot = new TeleBot(token);
const request = '/home/radio/request'; //файл, где указывается путь к файлу для реквеста
const next_song = '/home/radio/next-song'; //файл отображающий название следующего трека (статусная строка)
const songlist = '/home/radio/songlist'; //файл со списком треков (для примера создания команд)


//перечисление пользователей, которые могут пользоваться ботом:
const users = ['user1', 'user2','user3'];

bot.mod('message', (data) => {
    const msg = data.message;
  const userId = msg.from.id;
  const username = msg.from.username;
    //const userId = msg.from.id;
//если пользователя нет в списке, то он получает ответ, что НИЗЗЯ
    if (users.indexOf(username) === -1) {
        data.message = {};
        bot.sendMessage(userId, 'Access only for subscribers');
    }
    return data;
});

//команда /sr с аргументом
bot.on(/^\/sr (.+)/, (msg, props) => {
    const text = props.match[1];
//к аргументу добавляется директория, расширение файла и сохраняется в файл, который считывает Liquidsoap для реквеста
    fs.writeFile(request,'/home/radio/music/Mikulski - ' + text  +'.mp4', (err) => {
        if (err) {
            console.log(err);
        }});
//перезапись статусной строки на стриме с упоминанием пользователя заказавшего трек и названием трека
    fs.writeFile(next_song, `Telegram request by ${ msg.from.first_name } - ` + text, (err) => {
            if (err) {
                console.log(err);
            }});
console.log(`${ msg.from.first_name } ` + 'Requested ' + text);
//бот пишет ответ, что реквест принят на обработку
    return bot.sendMessage(msg.from.id, `${ msg.from.first_name }, ` + '\nyour Groovy request has been accepted for processing!\n|\nВаш бодрый реквест принят на обработку!');
});

//команда /songlist для вывода списка треков из файла
bot.on('/songlist', (msg) => {
    fs.readFile(songlist, "utf8", function(error,data){
        if(error) throw error;
    
    return bot.sendMessage(msg.from.id, data)  
    });
});

bot.start() //запуск бота