#!/usr/bin/perl -w
#
# Automatic Twitch DJ
# (C)2021 Pegasus Epsilon - Distribute Unmodified
# https://pegasus.pimpninjas.org/license
use strict;
use v5.10;
use threads;
use threads::shared;
use IO::Socket; # IRC
use MPV::Simple; # media playback
use Data::Dumper; # share the unshareable
use constant VOLUME => 75;
use constant ABORT_SFX => "/home/pegasus/chromeapps/Empire Records - Track Abort SFX.mp3";
use constant BGM_PLAYLIST => "/home/pegasus/Music/mp3/Volcano_Girls_Radio.m3u";
use constant IRC_SERVER => "irc.chat.twitch.tv";
use constant IRC_PORT => 6667;
use constant IRC_NICK => "pegasus_epsilon";
use constant IRC_PASS => "/home/pegasus/private_html/twitch.oauth";
use constant IRC_CHANNEL => "#" . IRC_NICK;
sub inrange { $_[1] <= $_[0] && $_[2] >= $_[0] }
sub index_or {
my @a = (shift, shift);
return $a[1] unless defined $a[0];
inrange($a[0], 0, $#_) ? @_[$a[0]] : $a[1];
}
# enum "NAME" => "ERRORNAME", qw(LIST OF ENUMS);
sub enum {
if ("START" ne ${^GLOBAL_PHASE}) {
my ($t, $f, $l) = caller;
die "enums must be declared in a BEGIN block at $f line $l.\n";
}
my ($f, $x) = (shift, shift);
eval "sub $f { index_or(shift, \"" . (join '", "', $x, @_) . "\"); }";
eval "use constant $f($_) => $_;" for 0 .. $#_;
eval "use constant $f(-1) => -1;" unless (do { no strict 'refs'; defined &$x });
}
BEGIN {
enum qw(
EVENT INVALID MPV_EVENT_NONE MPV_EVENT_SHUTDOWN MPV_EVENT_LOG_MESSAGE
MPV_EVENT_GET_PROPERTY_REPLY MPV_EVENT_SET_PROPERTY_REPLY
MPV_EVENT_COMMAND_REPLY MPV_EVENT_START_FILE MPV_EVENT_END_FILE
MPV_EVENT_FILE_LOADED MPV_EVENT_TRACKS_CHANGED MPV_EVENT_TRACK_SWITCHED
MPV_EVENT_IDLE MPV_EVENT_PAUSE MPV_EVENT_UNPAUSE MPV_EVENT_TICK
MPV_EVENT_SCRIPT_INPUT_DISPATCH MPV_EVENT_CLIENT_MESSAGE
MPV_EVENT_VIDEO_RECONFIG MPV_EVENT_AUDIO_RECONFIG
MPV_EVENT_METADATA_UPDATE MPV_EVENT_SEEK MPV_EVENT_PLAYBACK_RESTART
MPV_EVENT_PROPERTY_CHANGE MPV_EVENT_CHAPTER_CHANGE
MPV_EVENT_QUEUE_OVERFLOW MPV_EVENT_HOOK
);
enum qw(BOOL TRUE FALSE);
}
my @queue :shared; # pending URL list
my @buffers :shared; # spawned mpv instances - max 2
my @threads; # list of threads to join
# reverse Data::Dumper to hack around perl threads sucking
sub undump { eval "my " . shift }
sub slurp { local @ARGV = @_; <> }
# search youtube
sub youtube {
say "searching youtube for \"$_[0]\"";
use IO::Socket::SSL;
my $yt = IO::Socket::SSL->new("www.youtube.com:443") or return "";
binmode $yt, ":encoding(ISO-8859-1):crlf";
(my $q = shift) =~ s/ /+/g;
say $yt "GET /results?search_query=$q HTTP/1.0";
say $yt "Host: www.youtube.com\n";
/"videoId": ?"([^"]+)"/ and return "https://youtu.be/$1" while <$yt>;
}
my $bgm = MPV::Simple->new;
$bgm->set_property_string("config", "yes");
$bgm->set_property_string("vo", "null");
$bgm->set_property_string("pause", "yes");
$bgm->set_property_string("volume", VOLUME * 8 / 9);
$bgm->set_property_string("loop-playlist", "yes");
$bgm->set_property_string("shuffle", "yes");
$bgm->initialize;
$bgm->command("loadfile", BGM_PLAYLIST);
my $abort = MPV::Simple->new;
$abort->set_property_string("config", "no");
$abort->set_property_string("vo", "null");
$abort->set_property_string("pause", "yes");
$abort->set_property_string("volume", 100);
$abort->set_property_string("loop-playlist", "yes");
$abort->set_property_string("shuffle", "yes");
$abort->initialize;
$abort->command("loadfile", ABORT_SFX);
# abort thread
threads->create(sub {
my $playing;
while ($_ = $abort->wait_event(1)) {
MPV_EVENT_UNPAUSE == $_->{id} and $playing = TRUE;
MPV_EVENT_PLAYBACK_RESTART == $_->{id} and $playing and do {
$playing = FALSE;
$abort->set_property_string("pause", "yes");
$abort->command("playlist-next");
};
}
});
# player thread
sub player {
my $p = shift;
while ($_ = $p->wait_event(1)) {
MPV_EVENT_PLAYBACK_RESTART == $_->{id} and do {
say "buffered, waiting...";
push @buffers, Dumper($p);
};
MPV_EVENT_IDLE == $_->{id} and do {
say "playback complete, thread exiting";
last;
};
}
threads->exit;
}
my $playing :shared = Dumper($bgm);
# IRC thread
sub irc {
my $IRC = new IO::Socket::INET(
PeerAddr => IRC_SERVER,
PeerPort => IRC_PORT,
Proto => 'tcp'
) or die "Can't connect to IRC";
binmode $IRC, ":encoding(UTF-8):crlf";
say $IRC "PASS oauth:", (slurp IRC_PASS) if IRC_PASS;
say $IRC "NICK ", IRC_NICK;
say $IRC "CAP REQ :twitch.tv/membership";
say $IRC "JOIN ", IRC_CHANNEL;
while (<$IRC>) {
s/[\r\n]*//g;
/^PING(.*)/i and say $IRC "PONG $1";
s/^:[^:]+:// and say $_;
/^!quit$/ and exit;
/^!pause$/ and undump($playing)->set_property_string("pause", "yes");
/^!play$/ and undump($playing)->set_property_string("pause", "no");
/^!skip$/ and do {
$abort->set_property_string("pause", "no");
undump($playing)->command("playlist-next", "force");
};
/^!yt (.*)/ and $_ = (map { $_ and length ? "!v $_" : "" } youtube $1)[0];
/^!v (.*)/ and push @queue, $1;
}
threads->exit;
}
my $irc = threads->create("irc");
$bgm->set_property_string("pause", "no");
while ($_ = $bgm->wait_event(1)) {
# nothing is ready to play, and queue is empty, play BGM
unless (@buffers or @queue) {
$playing = Dumper($bgm);
next;
}
# thread slots available, and items in queue, spawn threads
if (@threads < 2 and @queue) {
# pending requests, buffer is not full
local $_ = MPV::Simple->new;
$_->set_property_string("config", "yes");
$_->set_property_string("vo", "null");
$_->set_property_string("pause", "yes");
$_->set_property_string("volume", VOLUME);
$_->initialize;
$_->command("loadfile", shift @queue);
push @threads, threads->create(sub { player $_ });
next;
}
# something is ready to play
if (@buffers) { # pause BGM
say "interrupting bgm for request";
$abort->set_property_string("pause", "no");
$bgm->set_property_string("pause", "yes");
do { # play ready things
$playing = shift @buffers;
undump($playing)->set_property_string("pause", "no");
# wait for it to finish
shift(@threads)->join;
} while (@buffers); # then start BGM again
$bgm->command("playlist-next");
$bgm->set_property_string("pause", "no");
}
}