Files
cantata/dynamic/cantata-dynamic
2014-02-28 20:14:45 +00:00

1286 lines
43 KiB
Perl
Executable File

#!/usr/bin/perl
# Cantata-Dynamic
#
# Copyright (c) 2011-2014 Craig Drummond <craig.p.drummond@gmail.com>
#
# ----
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; see the file COPYING. If not, write to
# the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
# Boston, MA 02110-1301, USA.
use IO::Socket::INET;
use POSIX;
use File::stat;
use File::Basename;
use Cwd 'abs_path';
# use LWP::Simple;
use Thread;
use Socket;
use IO::Socket;
use threads;
use threads::shared;
use URI::Escape;
use Encode;
use Sys::Hostname;
use Socket qw(:all);
# UDP multicast message sender - used to communicate with Cantata when cantata-dynamic is run in server mode!
my $mcastGroup : shared = '239.123.123.123';
my $mcastPort : shared = 6602;
my $mcastTtl : shared = 1;
my $isServerMode : shared =0;
my $dynamicIsActive : shared =1;
my $currentStatus : shared ="IDLE";
$testMode=0;
$PLAY_QUEUE_DESIRED_LENGTH=10;
$PLAY_QUEUE_CURRENT_POS=5;
my $mpdHost : shared ="localhost";
my $mpdPort : shared ="6600";
my $mpdPasswd : shared ="";
my $idString : shared = hostname();
my $currentStatusTime = time;
# Read MPDs host, port, and password details from env - if set
sub readConnectionDetails() {
my $hostEnv=$ENV{'MPD_HOST'};
my $portEnv=$ENV{'MPD_PORT'};
if (length($portEnv)>2) {
$mpdPort=$portEnv;
}
if (length($hostEnv)>2) {
my $sep = index($hostEnv, '@');
if ($sep>0) {
$mpdPasswd=substr($hostEnv, 0, $sep);
$mpdHost=substr($hostEnv, $sep+1, length($hostEnv)-$sep);
} else {
$mpdHost=$hostEnv;
}
}
}
sub readReply() {
my $sock=shift;
local $data;
my $socketData;
while ($sock->connected()) {
$sock->recv($data, 1024);
if (! $data) {
return '';
}
$socketData="${socketData}${data}";
$data="";
if (($socketData=~ m/(OK)$/) || ($socketData=~ m/^(OK)/)) {
return $socketData;
} elsif ($socketData=~ m/^(ACK)/) {
return '';
}
}
}
# Connect to MPD
sub connectToMpd() {
my $connDetails="";
my $sock;
if ($mpdHost=~ m/^(\/)/) {
$sock = new IO::Socket::UNIX(Peer => $mpdHost, Type => 0);
$connDetails=$mpdHost;
} else {
$sock = new IO::Socket::INET(PeerAddr => $mpdHost, PeerPort => $mpdPort, Proto => 'tcp');
$connDetails="${mpdHost}:${mpdPort}";
}
if ($sock && $sock->connected()) {
if (&readReply($sock)) {
if ($mpdPasswd) {
$sock->send("password ${mpdPasswd} \n");
if (! &readReply($sock)) {
print "ERROR: Invalid password\n";
eval { close $sock; };undef $sock;
}
}
} else {
print "ERROR: Failed to read connection reply fom MPD (${connDetails})\n";
close($sock);
}
} else {
print "ERROR: Failed to connect to MPD (${connDetails})\n";
}
return $sock;
}
sub sendCommand() {
my $cmd = shift;
my $status = 0;
my $sock=&connectToMpd();
my $sockData;
$cmd="${cmd}\n";
if ($sock && $sock->connected()) {
print $sock encode('utf-8' => $cmd);
$sockData=&readReply($sock);
eval { close $sock; }; undef $sock;
}
if ($sockData ne '') {
return decode_utf8($sockData);
}
return $sockData;
}
sub waitForEvent() {
my $sock=&connectToMpd();
if ($sock && $sock->connected()) {
$sock->send("idle player playlist\n");
&readReply($sock);
eval { close $sock; };undef $sock;
return 1;
}
return 0;
}
sub baseDir() {
my $cacheDir=$ENV{'XDG_CACHE_HOME'};
if (!$cacheDir) {
$cacheDir="$ENV{'HOME'}/.cache";
}
$cacheDir="${cacheDir}/cantata/dynamic";
return $cacheDir
}
sub lockFile() {
my $fileName=&baseDir();
$fileName="${fileName}/lock";
return $fileName;
}
$lastArtistSearch="";
@artistSearchResults=();
# Query LastFM for artists similar to supplied artist
sub querySimilarArtists() {
my $artist=uri_escape(shift);
if ($artist ne $lastArtistSearch) {
@artistSearchResults=();
# my $text = get 'http://ws.audioscrobbler.com/1.0/artist/'.$artist.'/similar.txt';
# my $artistNum=0;
# open(my $fileHandle, '<', \$text);
# if (tell($fileHandle) != -1) {
# my @lines = <$fileHandle>; # Read into an array...
@lines=`wget \'http://ws.audioscrobbler.com/1.0/artist/${artist}/similar.txt\' -O -`;
foreach my $line (@lines) {
@parts = split(/,/, $line);
if (3==scalar(@parts)) {
my $artist=$parts[2];
$artist =~ s/&amp;/&/g;
$artist =~ s/\n//g;
$artistSearchResults[$artistNum]=$artist;
$artistNum++;
}
}
# }
# close($fileHandle);
}
}
$mpdDbUpdated=0;
$rulesChanged=1;
$includeRules;
$excludeRules;
$lastIncludeRules;
$lastExcludeRules;
$initialRead=1;
$rulesTimestamp=0;
# Determine if rules file has been updated
sub checkRulesChanged() {
if ($initialRead==1) { # Always changed on first run...
$rulesChanged=1;
$initialRead=0;
} elsif ( scalar(@lastIncludeRules)!=scalar(@includeRules) ||
scalar(@lastExcludeRules)!=scalar(@excludeRules)) { # Different number of rules
$rulesChanged=1;
} else { # Same number of rules, so need to check if the rules themselves have changed or not...
$rulesChanged=0;
for (my $i=0; $i<scalar(@includeRules) && $rulesChanged==0; $i++) {
if ($includeRules[$i] ne $lastIncludeRules[$i]) {
$rulesChanged=1;
}
}
for (my $i=0; $i<scalar(@excludeRules) && $rulesChanged==0; $i++) {
if ($excludeRules[$i] ne $lastExcludeRules[$i]) {
$rulesChanged=1;
}
}
}
@lastIncludeRules=@includeRules;
@lastExcludeRules=@excludeRules;
}
# Add a rule to the list of rules that will be used to query MPD
sub saveRule() {
my $rule=$_[0];
my @dates=@{ $_[1] };
my @artistList=@{ $_[2] };
my @genreList=@{ $_[3] };
my $ruleMatch=$_[4];
my $isInclude=$_[5];
my @type=();
if ($isInclude == 1) {
@type=@includeRules;
} else {
@type=@excludeRules;
}
# We iterate through the list of artists - so if this is empty, add a blank artist.
# artistList will only be set if we have been told to find tracks by similar artists...
if (scalar(@artistList)==0) {
$artistList[0]="";
}
if (scalar(@genreList)==0) {
$genreList[0]="";
}
my $ruleNum=scalar(@type);
for my $genre (@genreList) {
for my $artist (@artistList) {
$line =~ s/\"//g;
if (scalar(@dates)>0) { # Create rule for each date (as MPDs search does not take ranges)
my $baseRule=$rule;
foreach my $date (@dates) {
$type[$ruleNum]="${ruleMatch} ${baseRule} Date \"${date}\"";
if ($artist ne "") {
$type[$ruleNum]=$type[$ruleNum]." Artist \"${artist}\"";
}
if ($genre ne "") {
$type[$ruleNum]=$type[$ruleNum]." Genre \"${genre}\"";
}
$ruleNum++;
}
} elsif ($artist ne "" || $genre ne "" || $rule ne "") {
$type[$ruleNum]="${ruleMatch} $rule";
if ($artist ne "") {
$type[$ruleNum]=$type[$ruleNum]." Artist \"${artist}\"";
}
if ($genre ne "") {
$type[$ruleNum]=$type[$ruleNum]." Genre \"${genre}\"";
}
$ruleNum++;
}
}
}
if ($isInclude == 1) {
@includeRules=@type;
} else {
@excludeRules=@type;
}
}
# Read rules from ~/.cache/cantata/dynamic/rules
# (or from ${filesDir}/rules in HTTP mode)
#
# File format:
#
# Rule
# <Tag>:<Value>
# <Tag>:<Value>
# Rule
#
# e.g.
#
# Rule
# AlbumArtist:Various Artists
# Genre:Dance
# Rule
# AlbumArtist:Wibble
# Date:1980-1989
# Exact:false
# Exclude:true
#
$activeFile="";
$activeLinksTo="";
sub readRules() {
if ($activeFile eq "") {
$activeFile=&baseDir();
$activeFile="${activeFile}/rules";
}
unless (-e $activeFile) {
$rulesChanged=0;
return;
}
# Check if rules (well, the file it points to), has changed since the last read...
my $currentActiveLink=abs_path($activeFile);
$fileTime = stat($currentActiveLink)->mtime;
if ($initialRead!=1 && $fileTime==$rulesTimestamp && $activeLinksTo eq $currentActiveLink) {
# No change, so no need to read it again!
$rulesChanged=0;
return;
}
$activeLinksTo=$currentActiveLink;
$rulesTimestamp=$fileTime;
for(my $i=0; $i<10; $i++) {
open(my $fileHandle, "<:encoding(utf8)", $activeFile);
if (tell($fileHandle) != -1) {
my @lines = <$fileHandle>; # Read into an array...
my $ruleMatch="find";
my @dates=();
my @similarArtists=();
my $isInclude=1;
my $currentRule="";
@includeRules=();
@excludeRules=();
close($fileHandle);
foreach my $line (@lines) {
if (! ($line=~ m/^(#)/)) {
$line =~ s/\n//g;
my $sep = index($line, ':');
if ($sep>0) {
$key=substr($line, 0, $sep);
$val=substr($line, $sep+1, length($line)-$sep);
} else {
$key=$line;
$val="";
}
if ($key=~ m/^(Rule)/) { # New rule...
if (length($currentRule)>1 || scalar(@similarArtists)>0 || scalar(@dates)>0 || scalar(@genres)>0) {
&saveRule($currentRule, \@dates, \@similarArtists, \@genres, $ruleMatch, $isInclude);
}
$currentRule="";
@dates=();
@similarArtists=();
@genres=();
} else {
if ($key eq "Date") {
my @dateVals = split("-", $val);
if (scalar(@dateVals)==2) {
my $fromDate=scalar($dateVals[0]);
my $toDate=scalar($dateVals[1]);
if ($fromDate > $toDate) { # Fix dates if from>to!!!
my $tmp=$fromDate;
$fromDate=$toDate;
$toDate=$tmp;
}
my $pos=0;
for(my $d=$fromDate; $d<=$toDate; $d++) {
$dates[$pos]=$d;
$pos++;
}
} else {
@dates=($val)
}
} elsif ($key eq "Genre" && $val =~ m{\*}) {
# Wildcard genre - get list of genres from MPD, and find the ones that contain the genre string.
$val =~ s/\*//g;
my $socketData=&sendCommand("list genre");
my @mpdGenres = split("\n", $socketData);
my $pos=0;
foreach my $genre (@mpdGenres) {
$genre =~ s/Genre: //g;
if ($genre ne "OK" && $genre ne "" && $genre =~/$val/i) {
$genres[$pos]=$genre;
$pos++;
}
}
} elsif ($key eq "Artist" || $key eq "Album" || $key eq "AlbumArtist" || $key eq "Composer" || $key eq "Comment" || $key eq "Title" || $key eq "Genre") {
$currentRule="${currentRule} ${key} \"${val}\"";
} elsif ($key eq "SimilarArtists") {
&querySimilarArtists($val); # Perform a last.fm query to find similar artists
@artistSearchResults; # Save results of query
@artistSearchResults=uniq(@artistSearchResults); # Ensure we only have unique entries...
if (scalar(@artistSearchResults)>1) {
my @mpdArtists = ();
my $pos=0;
# Get MPD artists...
my $socketData=&sendCommand("list artist");
my @mpdResponse=split("\n", $socketData);
foreach my $artist (@mpdResponse) {
$artist =~ s/Artist: //g;
if ($artist ne "OK" && $artist ne "" && $artist ne $val) {
$mpdArtists[$pos]=$artist;
$pos++;
}
}
## Get MPD album-artists...
#$socketData=&sendCommand("list albumartist");
#@mpdResponse=split("\n", $socketData);
#foreach my $artist (@mpdResponse) {
# $artist =~ s/AlbumArtist: //g;
# if ($artist ne "OK" && $artist ne "" && $artist ne $val) {
# $mpdArtists[$pos]=$artist;
# $pos++;
# }
#}
@mpdArtists=uniq(@mpdArtists);
# Now chec which last.fm artists MPD actually has...
my $pos=0;
foreach my $artist (@artistSearchResults) {
my @match = grep(/^$artist/i, @mpdArtists);
if (scalar(@match)>0) {
$similarArtists[$pos]=$artist;
$pos++;
}
}
}
$similarArtists[scalar(@similarArtists)]=$val; # Add ourselves!!!
} elsif ($key eq "Exact" && $val eq "false") {
$ruleMatch="search";
} elsif ($key eq "Exclude" && $val eq "true") {
$isInclude=0;
}
}
}
}
if (length($currentRule)>1 || scalar(@similarArtists)>0 || scalar(@dates)>0 || scalar(@genres)>0) {
&saveRule($currentRule, \@dates, \@similarArtists, \@genres, $ruleMatch, $isInclude);
}
if (1==$testMode) {
print "INCLUDE--------------\n";
foreach my $rule (@includeRules) {
print "${rule}\n";
}
print "EXCLUDE--------------\n";
foreach my $rule (@excludeRules) {
print "${rule}\n";
}
print "---------------------\n"
}
&checkRulesChanged();
return 1;
}
if (0==$isServerMode) {
sleep 1;
}
}
&checkRulesChanged();
return 0;
}
# Remove duplicate entries from an array...
sub uniq {
return keys %{{ map { $_ => 1 } @_ }};
}
# Send message to Cantata application - ued when run in server mode
sub sendServerMessage() {
my $message=shift;
if (length($message)<=0) {
$message="STATUS:STATE:${currentStatus}\nRULES:${activeRules}\nTIME:${currentStatusTime}";
}
my $sock = IO::Socket::INET->new(Proto=>'udp', PeerAddr=>${mcastGroup}, PeerPort=>${mcastPort});
if ($sock && $sock->connected()) {
setsockopt($sock, IPPROTO_IP, IP_TTL, $mcastTtl);
$sock->send("{CANTATA/${idString}}${message}");
eval { close $sock; }; undef $sock;
}
}
# Send message to Cantata application...
sub sendMessage() {
my $method=shift;
my $argument=shift;
system("qdbus com.googlecode.cantata /cantata ${method} ${argument}");
if ( $? == -1 ) {
# Maybe qdbus is not installed? Try dbus-send...
system("dbus-send --type=method_call --session --dest=com.googlecode.cantata /cantata com.googlecode.cantata.${method} string:${argument}");
}
}
# Use rules to obtain a list of songs from MPD...
sub getSongs() {
# If we have no current songs, or rules have changed, or MPD has been updated - then we need to run the rules against MPD to get song list...
if (scalar(@mpdSongs)<1 || $rulesChanged==1 || $mpdDbUpdated==1) {
my @excludeSongs=();
if (scalar(@excludeRules)>0) {
# Get list of songs that should be removed from the song list...
my $mpdSong=0;
foreach my $rule (@excludeRules) {
my $socketData=&sendCommand($rule);
if (defined($socketData)) {
my @lines = split('\n', $socketData);
foreach my $line (@lines) {
if ($line=~ m/^(file\:)/) {
my $sep = index($line, ':');
if ($sep>0) {
$excludeSongs[$mpdSong]=substr($line, $sep+2, length($line)-($sep+1));
$mpdSong++;
}
}
}
}
@excludeSongs=uniq(@excludeSongs);
}
}
my %excludeSongSet = map { $_ => 1 } @excludeSongs;
@mpdSongs=();
my $mpdSong=0;
if (scalar(@includeRules)>0) {
foreach my $rule (@includeRules) {
my $socketData=&sendCommand($rule);
if (defined($socketData)) {
my @lines = split('\n', $socketData);
foreach my $line (@lines) {
if ($line=~ m/^(file\:)/) {
my $sep = index($line, ':');
if ($sep>0) {
my $song=substr($line, $sep+2, length($line)-($sep+1));
if (! $excludeSongSet{$song}) {
$mpdSongs[$mpdSong]=$song;
$mpdSong++;
}
}
}
}
}
@mpdSongs=uniq(@mpdSongs);
}
} else {
# No 'include' rules => get all songs!
my $socketData=&sendCommand("listall");
if (defined($socketData)) {
my @lines = split('\n', $socketData);
foreach my $line (@lines) {
if ($line=~ m/^(file\:)/) {
my $sep = index($line, ':');
if ($sep>0) {
my $song=substr($line, $sep+2, length($line)-($sep+1));
if (! $excludeSongSet{$song}) {
$mpdSongs[$mpdSong]=$song;
$mpdSong++;
}
}
}
}
}
}
if (scalar(@mpdSongs)<1) {
if (1==$isServerMode) {
$currentStatus="NO_SONGS";
&sendServerMessage();
} else {
&sendMessage("showError", "NO_SONGS");
exit(0);
}
} elsif (1==$isServerMode) {
$currentStatus="HAVE_SONGS";
&sendServerMessage();
}
if (1==$testMode) {
print "SONGS--------------\n";
foreach my $song (@mpdSongs) {
print "${song}\n";
}
print "---------------------\n"
}
}
}
#
# Following canAdd/storeSong are used to remember songs that have been added to the playqueue, so that
# we don't re-add them too soon!
#
@playQueueHistory=();
$playQueueHistoryLimit=0;
$playQueueHistoryPos=0;
sub canAdd() {
my $file=shift;
my $numSongs=shift;
my $pqLimit=0;
# Calculate a reasonable level for the history...
if (1==$numSongs) {
return 1;
} elsif ($numSongs<5) {
$pqLimit=int(($numSongs/2)+0.5);
} else {
$pqLimit=int(($numSongs*0.75)+0.5);
if ($pqLimit>200) {
$pqLimit=200;
}
}
# If the history level has changed, then so must have the rules/mpd/whatever, so add this song anyway...
if ($pqLimit != $playQueueHistoryLimit) {
$playQueueHistoryLimit=$pqLimit;
@playQueueHistory=();
return 1;
}
my $size=scalar(@playQueueHistory);
if ($size>$playQueueHistoryLimit) {
$size=$playQueueHistoryLimit;
}
for (my $i=0; $i<$size; ++$i) {
if ($playQueueHistory[$i] eq $file) {
return 0;
}
}
return 1;
}
sub storeSong() {
my $file=shift;
if ($playQueueHistoryLimit<=0) {
$playQueueHistoryLimit=5;
}
if ($playQueueHistoryPos>=$playQueueHistoryLimit) {
$playQueueHistoryPos=0;
}
$playQueueHistory[$playQueueHistoryPos]=$file;
$playQueueHistoryPos++;
}
#
# This is the 'main' function of the dynamizer
#
sub populatePlayQueue() {
&readConnectionDetails();
my $lastMpdDbUpdate=-1;
while (1) {
if (0==$dynamicIsActive && 1==$isServerMode) {
while (0==$dynamicIsActive) {
if (0==&waitForEvent()) {
# TODO: Could not connect to MPD, and dynamic is not active, so wait??? Not sure aobut this...
sleep(2);
}
}
}
my $socketData='';
if (1==$dynamicIsActive) {
# Use status to obtain the current song pos, and to check that MPD is running...
$socketData=&sendCommand("status");
}
if (defined($socketData)) {
my @lines = split('\n', $socketData);
my $playQueueLength=0;
my $playQueueCurrentTrackPos=0;
my $isPlaying=0;
foreach my $val (@lines) {
if ($val=~ m/^(song\:)/) {
my @vals = split(": ", $val);
if (scalar(@vals)==2) {
$playQueueCurrentTrackPos=scalar($vals[1]);
}
} elsif ($val=~ m/^(state\:)/) {
my @vals = split(": ", $val);
if (scalar(@vals)==2 && $vals[1]=~ m/^(play)/) {
$isPlaying=1;
}
}
}
# Call stats, so that we can obtain the last time MPD was updated.
# We use this to determine when we need to refresh the searched set of songs
$mpdDbUpdated=0;
$socketData=&sendCommand("stats");
if (defined($socketData)) {
my @lines = split('\n', $socketData);
foreach my $val (@lines) {
if ($val=~ m/^(db_update\:)/) {
my @vals = split(": ", $val);
if (scalar(@vals)==2) {
my $mpdDbUpdate=scalar($vals[1]);
if ($mpdDbUpdate!=$lastMpdDbUpdate) {
$lastMpdDbUpdate=$mpdDbUpdate;
$mpdDbUpdated=1;
}
}
break;
}
}
}
# Get current playlist info
$socketData=&sendCommand("playlist");
if (defined($socketData)) {
my @lines = split('\n', $socketData);
my $playQueueLength=scalar(@lines);
if ($playQueueLength>0 && $lines[$playQueueLength-1]=~ m/^(OK)/) {
$playQueueLength--;
}
# trim playlist start so that current becomes <=$PLAY_QUEUE_CURRENT_POS
for (my $i=0; $i < $playQueueCurrentTrackPos - ($PLAY_QUEUE_CURRENT_POS-1); $i++) {
&sendCommand("delete 0");
$playQueueLength--;
}
if ($playQueueLength<0) {
$playQueueLength=0;
}
&readRules();
&getSongs();
my $numMpdSongs=scalar(@mpdSongs);
if ($numMpdSongs>0) {
# fill up playlist to 10 random tunes
my $failues=0;
my $added=0;
while ($playQueueLength < $PLAY_QUEUE_DESIRED_LENGTH) {
my $pos=int(rand($numMpdSongs));
if ($failues > 100 || &canAdd(${mpdSongs[$pos]}, $numMpdSongs)) {
my $file=${mpdSongs[$pos]};
$file =~ s/\\/\\\\/g;
$file =~ s/\"/\\\"/g;
if (&sendCommand("add \"${file}\"") ne '') {
&storeSong(${mpdSongs[$pos]});
$playQueueLength++;
$failues=0;
$added++;
}
} else { # Song is already in playqueue history...
$failues++;
}
}
# If we are not currently playing and we filled playqueue - then play first!
if ($isPlaying==0 && $added==$PLAY_QUEUE_DESIRED_LENGTH) {
&sendCommand("play 0")
}
}
&waitForEvent();
} elsif (0==$isServerMode) {
sleep 2;
}
} elsif (0==$isServerMode) {
sleep 2;
}
}
}
sub readPid() {
my $fileName=shift;
if (-e $fileName) {
open(my $fileHandle, $fileName);
my @lines = <$fileHandle>;
close($fileHandle);
if (scalar(@lines)>0) {
my $pid=$lines[0];
return scalar($pid);
}
}
return 0;
}
sub daemonize() {
my $fileName=shift;
# daemonize process...
chdir '/';
umask 0;
open STDIN, '/dev/null' or die "Can't read /dev/null: $!";
open STDOUT, '>>/dev/null' or die "Can't write to /dev/null: $!";
open STDERR, '>>/dev/null' or die "Can't write to /dev/null: $!";
defined( my $pid = fork ) or die "Can't fork: $!";
exit if $pid;
# dissociate this process from the controlling terminal that started it and stop being part
# of whatever process group this process was a part of.
POSIX::setsid() or die "Can't start a new session.";
# callback signal handler for signals.
$SIG{INT} = $SIG{TERM} = $SIG{HUP} = \&signalHandler;
$SIG{PIPE} = 'ignore';
# Write our PID the lock file, so that 'stop' knows which PID to kill...
open(my $fileHandle, ">${fileName}");
print $fileHandle $$;
close $fileHandle;
}
sub start() {
my $pidFile=&lockFile();
my $pid=&readPid($pidFile);
if ($pid>0) {
$exists = kill 0, $pid;
if ($exists) {
print "PROCESS $pid is running!\n";
return;
}
}
my $fileName=&lockFile();
&daemonize($fileName);
&sendMessage("dynamicStatus", "running");
&populatePlayQueue();
}
sub signalHandler {
if (0==$isServerMode) {
unlink(&lockFile());
&sendMessage("dynamicStatus", "stopped");
} else {
unlink($pidFile);
}
exit(0);
}
sub stop() {
my $pidFile=&lockFile();
my $pid=&readPid($pidFile);
if ($pid>0) {
system("kill", $pid);
system("pkill", "-P", $pid);
}
}
# #####################################
# HTTP SERVER MODE
#
# GetID: GET http://host:port/id
# GetStatus: GET http://host:port/status
# ListPlaylists: GET http://host:port/list http://host:port/list?withDetails={1/0}
# GetPlaylist: GET http://host:port/${playlist}
# DeletePlaylist: DELETE http://host:port/${playlist}
# SavePlaylist: POST http://host:port/save?name=${playlist} [BODY:Content]
# SetActive: POST http://host:port/setActive?name=${playlist}&start={1/0}
# ControlDynamizer: POST http://host:port/control?state={start/stop}
#
# #####################################
$filesDir="/var/lib/mpd/dynamic";
$httpPort=6601;
$httpControlPage=0;
$pidFile="/var/run/cantata-dynamic/pid";
# Attempt to load a config file that will specify MPD connection settings and dynamic folder location
sub loadConfig() {
my $config=shift;
if (!$config || ($config=~ m/^(default)/)) {
$config="/etc/cantata-dynamic.conf";
}
open(my $fileHandle, $config) || die "ERROR: Failed to load config $config - $!\n";
$activeFile="/var/run/cantata-dynamic/rules";
if (tell($fileHandle) != -1) {
my @lines = <$fileHandle>; # Read into an array...
close($fileHandle);
foreach my $line (@lines) {
if (! ($line=~ m/^(#)/)) {
$line =~ s/\n//g;
my $sep = index($line, '=');
if ($sep>0) {
$key=substr($line, 0, $sep);
$val=substr($line, $sep+1, length($line)-$sep);
if ($key=~ m/^(filesDir)/) {
$filesDir=$val;
} elsif ($key=~ m/^(activeFile)/) {
$activeFile=$val;
} elsif ($key=~ m/^(mpdHost)/) {
$mpdHost=$val;
} elsif ($key=~ m/^(mpdPort)/) {
$mpdPort=$val;
} elsif ($key=~ m/^(mpdPassword)/) {
$mpdPasswd=$val;
} elsif ($key=~ m/^(httpPort)/) {
$httpPort=$val;
} elsif ($key=~ m/^(httpControlPage)/) {
$httpControlPage=$val;
} elsif ($key=~ m/^(pidFile)/) {
$pidFile=$val;
} elsif ($key=~ m/^(id)/ && length($val)>0) {
$idString=$val;
} elsif ($key=~ m/^(msgPort)/) {
$mcastPort=$val;
} elsif ($key=~ m/^(msgGroup)/) {
$mcastGroup=$val;
} elsif ($key=~ m/^(msgTtl)/ && length($val)>0 && $val>0 && $val<128) {
$mcastTtl=$val;
}
}
}
}
}
# Create folders, if these do not already exist...
my $pidDir=dirname($pidFile);
my $activeFileDir=dirname($activeFile);
unless (-d $pidDir) {
mkdir $pidDir or die;
}
unless (-d $activeFileDir) {
mkdir $activeFileDir or die;
}
unless (-d $filesDir) {
mkdir $filesDir or die;
}
}
sub readRuleFile() {
my @result=();
my $fileName=uri_unescape(shift);
open(my $fileHandle, $fileName);
if (tell($fileHandle) != -1) {
my @lines = <$fileHandle>; # Read into an array...
close($fileHandle);
foreach my $line (@lines) {
if ($line =~ /\n$/) {
push(@result, $line);
} else {
push(@result, $line."\n");
}
}
}
return @result;
}
sub listRules() {
my @result=();
my $showContents=shift;
opendir(D, "$filesDir");
while (my $f = readdir(D)) {
if ($f=~m/.rules$/) {
push(@result, "FILENAME:${f}\n");
if ($showContents>0) {
push(@result, &readRuleFile($filesDir."/".$f));
}
}
}
closedir(D);
push(@result, "\nTIME:${currentStatusTime}");
return @result;
}
sub determineActiveRules() {
local $fileName="";
if (-f $activeFile && -l $activeFile) {
$fileName=basename abs_path($activeFile);
$fileName =~ s/.rules//g;
}
return $fileName;
}
sub saveRulesToFile() {
my $name=uri_unescape(shift);
if (! $name) {
return "ERROR: No name specified";
}
if ($name =~ m/\.rules/ || $name =~ m/\//) {
return "ERROR: Invalid name";
}
my $content=shift;
# TODO: Parse content!!!
my $rulesName=$name;
$rulesName="${filesDir}/${rulesName}.rules";
open (my $fileHandle, '>'.$rulesName);
if (tell($fileHandle) != -1) {
print $fileHandle $content;
close($fileHandle);
$currentStatusTime = time;
&sendServerMessage();
return "OK";
} else {
return "ERROR: Failed to create file";
}
}
sub deleteRules() {
my $name=uri_unescape(shift);
$name =~ s/\///g;
my $active=&determineActiveRules();
my $rulesName=$name;
$rulesName="${filesDir}/${rulesName}.rules";
if (!unlink($rulesName)) {
return "ERROR: Failed to remove file";
}
$currentStatusTime = time;
if ($name eq $active) {
&control("stop");
}
&sendServerMessage();
return "OK";
}
sub control() {
my $command=shift;
if ($command eq "start") {
$dynamicIsActive=1;
$currentStatus="STARTING";
&sendCommand("clear");
&sendServerMessage();
return "OK";
} elsif ($command eq "stop") {
my $doClear=shift;
$dynamicIsActive=0;
$currentStatus="IDLE";
if ($doClear eq "true" || $doClear eq "1") {
&sendCommand("clear");
}
&sendServerMessage();
return "OK";
}
return "ERROR: Invalid command";
}
sub setActiveRules() {
my $name=uri_unescape(shift);
if ($name eq "") {
return "ERROR: No name supplied";
}
my $rulesName=$name;
my $active=&determineActiveRules();
if ($rulesName eq $active) {
return "OK";
}
$rulesName="${filesDir}/${rulesName}.rules";
if (-f $rulesName) {
if (-l $activeFile) {
if (!unlink($activeFile)) {
return "ERROR: Failed to remove link";
}
} elsif (-f $activeFile) {
return "ERROR: 'rules' is not a link";
}
system("ln -s \"${rulesName}\" \"${activeFile}\"");
if (0!=$?) {
return "ERROR: Failed to create 'rules' symlink";
}
&sendServerMessage();
return "OK";
} else {
return "ERROR: Could not file ${name}";
}
}
sub buildControlPage() {
my $body="<html><head><title>Dynamic Playlists</title></head><body><h2>Dynamic Playlists</h2>"
. "<p><i>Click on a playlist name to load</i></p>";
my @rules=&listRules(0);
my $active=&determineActiveRules();
$body = $body . "<p><ul>";
my $num=1;
foreach my $rule (@rules) {
$rule =~ s/FILENAME://;
$rule =~ s/.rules//;
$rule =~ s/\n//;
if ($rule=~ m/^(TIME:)/) {
} else {
$body = $body . "<li>";
if ($rule eq $active) {
$body = $body . "<b>";
}
$body = $body . "<form name=\"ruleForm". ${num} ."\" method=\"post\" action=\"/setActive?name=" . $rule . "&start=1&showStartPage=1\">"
. "<a href=\"javascript: loadRule" .${num} ."()\">" .$rule ."</a></form>"
. "<script type=\"text/javascript\">function loadRule" .${num} ."() { document.ruleForm". ${num} .".submit(); }</script>";
if ($rule eq $active) {
$body = $body . "</b>";
}
$body = $body ."</li>";
$num=$num+1;
}
}
$body = $body . "</ul></p>";
if (1==$dynamicIsActive) {
$body = $body . "<br/><p><form method=post enctype=\"text/plain\" action=\"/control?state=stop&showStartPage=1\">"
. "<input type=\"submit\" name=\"submit\" value=\"Stop Dynamizer\"></form></p>";
}
$body = $body . "</body></html>";
return $body;
}
sub writeToClient() {
my $client=shift;
my $message=shift;
my $addCrlf=shift;
if ($client->connected()) {
if (1==$addCrlf) {
print $client $message, Socket::CRLF;
} else {
print $client $message;
}
}
}
sub httpServer() {
my $server = new IO::Socket::INET(Proto => 'tcp', LocalPort => $httpPort, Listen => SOMAXCONN, Reuse => 1);
$server or die "ERROR: Unable to create HTTP socket (${httpPort}): $!";
print "Starting HTTP server on ${httpPort}\n";
while (my $client = $server->accept()) {
$client->autoflush(1);
my $prevStatusTime=$currentStatusTime;
my %request = ();
my %data;
local $/ = Socket::CRLF;
while (<$client>) {
chomp; # Main http request
if (/\s*(\w+)\s*([^\s]+)\s*HTTP\/(\d.\d)/) {
$request{METHOD} = uc $1;
$request{URL} = $2;
$request{HTTP_VERSION} = $3;
} # Standard headers
elsif (/:/) {
(my $type, my $val) = split /:/, $_, 2;
$type =~ s/^\s+//;
foreach ($type, $val) {
s/^\s+//;
s/\s+$//;
}
$request{lc $type} = $val;
} # POST data
elsif (/^$/) {
read($client, $request{CONTENT}, $request{'content-length'})
if defined $request{'content-length'};
last;
}
}
local $response="";
local $responseType="text/plain";
$queryItems{statusTime}=0;
if ($request{URL} =~ /(.*)\?(.*)/) {
$request{URL} = $1;
$request{QUERY} = $2;
my @args=split("&", $request{QUERY});
for my $arg (@args) {
(my $type, my $val) = split /=/, $arg, 2;
$queryItems{$type}=$val;
}
}
# print "${request{METHOD}} URL:${request{URL}} QUERY:${request{QUERY}}\n";
if ($request{METHOD} eq 'GET') {
if ($request{URL} eq '/status') {
local $activeRules=&determineActiveRules();
$response="STATE:${currentStatus}\nRULES:${activeRules}\nTIME:${currentStatusTime}\n";
} elsif ($request{URL} eq '/id') {
$response="ID:${idString}\nGROUP:${mcastGroup}\nPORT:${mcastPort}";
} elsif ($request{URL} eq '/list') {
$response = join('', &listRules($queryItems{withDetails}));
} elsif ($request{URL} eq '/' && 1==$httpControlPage) {
$responseType="text/html";
$response = &buildControlPage();
} else {
$response="ERROR: Invalid URL";
}
} elsif ($request{METHOD} eq 'POST') {
if ($request{URL} eq '/setActive') {
my @args=split("&", $request{QUERY});
$response=&setActiveRules($queryItems{name});
if ($response eq "OK" && ($queryItems{start} eq "true" || $queryItems{start} eq "1")) {
$response=&control("start");
}
} elsif ($request{URL} eq '/save') {
$response=&saveRulesToFile($queryItems{name}, $request{CONTENT});
} elsif ($request{URL} eq '/control') {
$response=&control($queryItems{state}, $queryItems{clear});
}
} elsif ($request{METHOD} eq 'DELETE') {
$response=&deleteRules($request{URL});
} elsif (1==$httpControlPage) {
$responseType="text/html";
$response = &buildControlPage();
}
if ($response eq "") {
&writeToClient($client, "HTTP/1.0 404 Not Found", 1);
&writeToClient($client, Socket::CRLF, 0);
&writeToClient($client, "<html><body>404 Not Found</body></html>", 0);
} elsif ($response =~ m/^ERROR/) {
&writeToClient($client, "HTTP/1.0 404 Not Found", 1);
&writeToClient($client, Socket::CRLF);
&writeToClient($client, "<html><body>${response}</body></html>", 0);
} elsif ($request{METHOD} eq 'POST') {
&writeToClient($client, "HTTP/1.0 201 Created", 1);
if ($queryItems{statusTime}!=0) {
&writeToClient($client, "Content-type: ${responseType}", 1);
&writeToClient($client, Socket::CRLF);
if ($prevStatusTime > $queryItems{statusTime}) {
&writeToClient($client, "UPDATE_REQUIRED", 1);
}
&writeToClient($client, "TIME:".$currentStatusTime, 0);
} elsif ($queryItems{showStartPage} eq "1") {
# Reload start page :-)
$response = "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">"
. "<html><head><meta http-equiv=\"REFRESH\" content=\"0;url=http://" . $request{host} . "\"></head></body></html>";
&writeToClient($client, "Content-type: text/html", 1);
&writeToClient($client, Socket::CRLF);
&writeToClient($client, $response, 1);
}
} else {
&writeToClient($client, "HTTP/1.0 200 OK", 1);
&writeToClient($client, "Content-type: ${responseType}", 1);
&writeToClient($client, Socket::CRLF);
if ($queryItems{statusTime}!=0 && $response eq "OK") {
&writeToClient($client, "OK", 1);
if ($prevStatusTime > $queryItems{statusTime}) {
&writeToClient($client, "UPDATE_REQUIRED", 1);
}
&writeToClient($client, "TIME:".$currentStatusTime, 0);
} else {
&writeToClient($client, $response);
}
}
close $client;
}
}
sub serverMode() {
&loadConfig(shift);
&daemonize($pidFile);
$isServerMode=1;
$dynamicIsActive=0;
threads->create(\&populatePlayQueue, 1);
&httpServer();
&sendServerMessage();
}
sub stopServer() {
&loadConfig(shift);
my $pid=&readPid($pidFile);
if ($pid>0) {
system("kill", $pid);
system("pkill", "-P", $pid);
}
$currentStatus="TERMINATED";
&sendServerMessage();
}
if ($ARGV[0] eq "start") {
&start();
} elsif ($ARGV[0] eq "stop") {
&stop();
} elsif ($ARGV[0] eq "server") {
&serverMode($ARGV[1]);
} elsif ($ARGV[0] eq "stopserver") {
&stopServer($ARGV[1]);
} elsif ($ARGV[0] eq "test") {
$testMode=1;
&populatePlayQueue();
} else {
print "Cantata MPD Dynamizer script\n";
print "\n";
print "Usage: $0 start|stop|server|stopserver\n";
}