Files
cantata/dynamic/cantata-dynamic
2012-08-16 17:15:40 +00:00

664 lines
20 KiB
Perl
Executable File

#!/usr/bin/env perl
# Cantata-Dynamic
#
# Copyright (c) 2011-2012 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;
$PLAY_QUEUE_DESIRED_LENGTH=10;
$PLAY_QUEUE_CURRENT_POS=5;
$host="localhost";
$port="6600";
$passwd="";
# 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) {
$port=$portEnv;
}
if (length($hostEnv)>2) {
my $sep = index($hostEnv, '@');
if ($sep>0) {
$passwd=substr($hostEnv, 0, $sep);
$host=substr($hostEnv, $sep+1, length($hostEnv)-$sep);
} else {
$host=$hostEnv;
}
}
}
$socketData="";
sub readReply() {
local $data;
$socketData="";
while (1) {
$sock->recv($data, 1024);
if (! $data) {
return 0;
}
$socketData="${socketData}${data}";
$data="";
if (($socketData=~ m/(OK)$/) || ($socketData=~ m/^(OK)/)) {
return 1;
} elsif ($socketData=~ m/^(ACK)/) {
return 0;
}
}
}
# Connect to MPD
sub connectToMpd() {
my $connDetails="";
if ($host=~ m/^(\/)/) {
$sock = new IO::Socket::UNIX(Peer => $host, Type => 0);
$connDetails=$host;
} else {
$sock = new IO::Socket::INET(PeerAddr => $host, PeerPort => $port, Proto => 'tcp');
$connDetails="${host}:${port}";
}
if ($sock->connected()) {
if (&readReply()) {
if ($passwd) {
$sock->send("password ${passwd} \n");
if (! &readReply()) {
print "ERROR: Invalid password\n";
close($sock);
return 0;
}
}
return 1;
} 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 0;
}
# Disconnect from MPD
sub disconnectFromMpd() {
if ($sock->connected()) {
close($sock);
}
}
sub sendCommand() {
my $cmd = shift;
my $status = 0;
if (&connectToMpd()) {
$sock->send("${cmd}\n");
if (&readReply()) {
$status=1;
}
&disconnectFromMpd();
}
return $status;
}
sub waitForEvent() {
if (&connectToMpd()) {
$sock->send("idle player playlist\n");
readReply();
}
}
# Check if MPD is running
sub mpdIsRunning() {
if (&connectToMpd()) {
&disconnectFromMpd();
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;
}
$mpdDbUpdated=0;
$rulesChanged=1;
$includeRules;
$excludeRules;
$lastIncludeRules;
$lastExcludeRules;
$initialRead=1;
$rulesTimestamp=0;
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;
}
sub saveRule() {
my $rule=$_[0];
my @dates=@{ $_[1] };
my $ruleMatch=$_[2];
my $isInclude=$_[3];
my @type=();
if ($isInclude == 1) {
@type=@includeRules;
} else {
@type=@excludeRules;
}
my $ruleNum=scalar(@type);
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}\"";
$ruleNum++;
}
} else {
$type[$ruleNum]="${ruleMatch} $rule";
}
if ($isInclude == 1) {
@includeRules=@type;
} else {
@excludeRules=@type;
}
}
# Read rules from ~/.cache/cantata/dynamic/rules
#
# 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
#
sub readRules() {
my $fileName=&baseDir();
$fileName="${fileName}/rules";
# Check if rules (well, the file it points to), has changed since the last read...
$fileTime = stat($fileName)->mtime;
if ($initialRead!=1 && $fileTime==$rulesTimestamp) {
# No change, so no need to read it again!
$rulesChanged=0;
return;
}
$rulesTimestamp=$fileTime;
for(my $i=0; $i<10; $i++) {
open(HANDLE, $fileName);
if (tell(HANDLE) != -1) {
my @lines = <HANDLE>; # Read into an array...
my $ruleMatch="find";
my @dates=();
my $isInclude=1;
my $currentRule="";
@includeRules=();
@excludeRules=();
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) {
&saveRule($currentRule, \@dates, $ruleMatch, $isInclude);
}
$currentRule="";
@dates=();
} 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 "Artist" || $key eq "Album" || $key eq "AlbumArtist" || $key eq "Title" || $key eq "Genre") {
$currentRule="${currentRule} ${key} \"${val}\"";
} elsif ($key eq "Exact" && $val eq "false") {
$ruleMatch="search";
} elsif ($key eq "Exclude" && $val eq "true") {
$isInclude=0;
}
}
}
}
if (length($currentRule)>1) {
&saveRule($currentRule, \@dates, $ruleMatch, $isInclude);
} elsif (@dates) {
&saveRule('', \@dates, $ruleMatch, $isInclude);
}
# 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;
}
sleep 1;
}
&checkRulesChanged();
return 0;
}
# Remove duplicate entries from an array...
sub uniq {
# my %seen = ();
# my @r = ();
# foreach my $a (@_) {
# unless ($seen{$a}) {
# push @r, $a;
# $seen{$a} = 1;
# }
# }
# return @r;
return keys %{{ map { $_ => 1 } @_ }};
}
# Send message to Cantata application...
sub sendMessage() {
my $method=shift;
my $argument=shift;
system("qdbus org.kde.cantata /cantata ${method} ${argument}");
if ( $? == -1 ) {
# Maybe qdbus is not installed? Try dbus-send...
system("dbus-send --type=method_call --session --dest=org.kde.cantata /cantata org.kde.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) {
&sendCommand($rule);
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) {
&sendCommand($rule);
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!
&sendCommand("listall");
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) {
&sendMessage("showError", "NO_SONGS");
exit(0);
}
}
}
#
# Following canAdd/storeSong are used to remeber 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 ($numSongs>50) {
$pqLimit=50;
} elsif ($numSongs>25) {
$pqLimit=25;
} elsif ($numSongs>10) {
$pqLimit=10;
} elsif ($numSongs>5) {
$pqLimit=5;
} else {
$pqLimit=2;
}
# 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 (&sendCommand("status")) { # Use status to obtain the current song pos, and to check that MPD is running...
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;
if (&sendCommand("stats")) {
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
if (&sendCommand("playlist")) {
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)) {
if (&sendCommand("add \"${mpdSongs[$pos]}\"")) {
&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();
} else {
sleep 2;
}
} else {
sleep 2;
}
}
}
sub readPid() {
my $fileName=&lockFile();
if (-e $fileName) {
open(HANDLE, $fileName);
my @lines = <HANDLE>;
if (scalar(@lines)>0) {
my $pid=$lines[0];
return scalar($pid);
}
}
return 0;
}
sub start() {
my $fileName=&lockFile();
if (-e $fileName) {
my $pid=&readPid();
if ($pid>0) {
$exists = kill 0, $pid;
if ($exists) {
print "PROCESS $pid is running!\n";
return;
}
}
}
# 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(HANDLE, ">${fileName}");
print HANDLE $$;
close HANDLE;
&sendMessage("dynamicStatus", "running");
&populatePlayQueue();
}
sub signalHandler {
unlink(&lockFile());
&sendMessage("dynamicStatus", "stopped");
exit(0);
}
sub stop() {
my $pid=&readPid();
if ($pid>0) {
system("kill", $pid);
system("pkill", "-P", $pid);
}
}
if ($ARGV[0] eq "start") {
&start();
} elsif ($ARGV[0] eq "stop") {
&stop();
} elsif ($ARGV[0] eq "test") {
&populatePlayQueue();
} else {
print "Usage: $0 start|stop\n";
}