#!/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
            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";
}
