664 lines
20 KiB
Perl
Executable File
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";
|
|
}
|