#!/usr/bin/env perl # Cantata-Dynamic # # Copyright (c) 2011-2013 Craig Drummond # # ---- # # 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 $serverSender : shared = dirname(__FILE__) . '/message-sender'; 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/&/&/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; $i0) { # 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 # : # : # 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 "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="Dynamic Playlists

Dynamic Playlists

" . "

Click on a playlist name to load

"; my @rules=&listRules(0); my $active=&determineActiveRules(); $body = $body . "

    "; my $num=1; foreach my $rule (@rules) { $rule =~ s/FILENAME://; $rule =~ s/.rules//; $rule =~ s/\n//; if ($rule=~ m/^(TIME:)/) { } else { $body = $body . "
  • "; if ($rule eq $active) { $body = $body . ""; } $body = $body . "
    " . "" .$rule ."
    " . ""; if ($rule eq $active) { $body = $body . "
    "; } $body = $body ."
  • "; $num=$num+1; } } $body = $body . "

"; if (1==$dynamicIsActive) { $body = $body . "

" . "

"; } $body = $body . ""; 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, "404 Not Found", 0); } elsif ($response =~ m/^ERROR/) { &writeToClient($client, "HTTP/1.0 404 Not Found", 1); &writeToClient($client, Socket::CRLF); &writeToClient($client, "${response}", 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 = "" . ""; &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"; }