#!/usr/bin/env python
#
# moosicd - the server portion of the moosic jukebox system.

# Copyright (C) 2001 Daniel Pearson <dpears2@umbc.edu>
#
# 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; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

VERSION = "1.2.5"


#---------- support functions ----------#

def browse_object(x):
    """Just a fun little tool for debugging; not directly related to moosic.
    """
    print '-'*40
    for a in dir(x):
        print a, '-', type(getattr(x, a))
        print getattr(getattr(x, a), "__doc__", None)
        print '-'*40

def parse_range(range, start=0, end=0):
    '''Changes a string representing a range into the indices of the range.

    Converts a string with a form similar to that of the Python array slice
    notation into into a pair of ints which represent the starting and ending
    indices of of the slice.  If the string contains a pair of colon-separated
    integers, a 2-tuple of ints with the respective values of those integers
    will be returned.  If the first number in this pair is omitted, then the
    value of the "start" argument is used.  If the second number in this pair
    is omitted, then the value of the "end" argument is used.  If the string
    contains a single integer, then a 2-tuple of ints with the value of that
    integer and its successor, respectively, will be returned.  If the string
    contains anything else, it is considered invalid and a ValueError is
    thrown.
    '''
    import string
    colon_count = string.count(range, ':')
    if colon_count == 1:
        a, b = string.split(range, ':')
        if a:
            start = int(a)
        if b:
            end = int(b)
    elif colon_count == 0:
        start = int(range)
        end = start + 1
    else:
        raise ValueError, "Invalid number of colons."
    return start, end

def shuffle(seq):
    """Returns a shuffled version of the given sequence.

    The returned list contains exactly the same elements as the given sequence,
    but in a random order.
    """
    import whrandom
    shuffled = []
    seq = list(seq)
    while seq: shuffled.append(seq.pop(whrandom.choice(range(len(seq)))))
    return shuffled

def getConfig(filename):
    """Parses a moosic configuration file and returns the data within.

    The "filename" argument specifies the name of the file from which to read
    the configuration. This function returns a list of 2-tuples which associate
    regular expression objects to the commands that will be used to play files
    whose names are matched by the regexps.
    """
    import re, fileinput, string
    config = []
    expecting_regex = 1
    regex = None
    command = None
    for line in fileinput.input(filename):
        # skip empty lines
        if re.search(r'^\s*$', line):
            continue
        # skip lines that begin with a '#' character
        if re.search('^#', line):
            continue
        # chomp off trailing newline
        if line[-1] == '\n':
            line = line[:-1]
        # the first line in each pair is interpreted as a regular expression
        # note that case is ignored. it would be nice if there was an easy way
        # for the user to choose whether or not case should be ignored.
        if expecting_regex:
            regex = re.compile(line, re.I)
            expecting_regex = 0
        # the second line in each pair is interpreted as a command
        else:
            command = string.split(line)
            config.append((regex, command))
            expecting_regex = 1
    return config

def strConfig(config):
    """Stringifies a list of moosic filetype-player associations.

    This function converts the list used to store filetype-to-player
    associations to a string. The "config" parameter is the list to be
    converted. The return value is a good-looking string that represents the
    contents of the list of associations.
    """
    import string
    s = ''
    for regex, command in config:
        s = s + regex.pattern + '\n\t' + string.join(command) + '\n'
    return s


#----- The Primary Components of moosicd -----#
# moosicd consists of several interacting subprocesses: the request handler,
# the queue manager, and the song player.  The request handler is in charge of
# listening for requests from a moosic client and reacting accordingly.  The
# queue manager pops items off of the playlist and plays each of them, one
# after another.  The song player is what the queue manager uses to play each
# song.

#---------- the song player ----------#

def play(config, filename):
    """Plays a single music file, and returns when it's over.
    """
    import sys, os
    global player_pid
    if verbosity > 2:
        print "The current set of file associations are thus:"
        print strConfig(config)
    command = ['true']
    # match the filename against the regexps in our filetype association table
    for regex, cmd in config:
        if regex.search(filename):
            command = cmd
            break
    sys.stdout.flush()
    # classic fork & exec to spawn the external player
    # Portability note: os.fork() is only available on Unix systems.
    player_pid = os.fork()
    if player_pid == 0:
        # We don't want the program to grab input.
        os.close(sys.stdin.fileno())
        # We don't want to see the program's output.
        os.close(sys.stdout.fileno())
        if verbosity < 2:
            # We don't even want to see the error messages.
            os.close(sys.stderr.fileno())
        if '$item' in command:
            while '$item' in command:
                command[command.index('$item')] = filename
        else:
            command.append(filename)
        os.execvp(command[0], command)
    else:
        os.wait()


import os, os.path, thread, time, socket, SocketServer

#------ global variables ------#
# 'playlist' is a list of all the songs that will be played.
playlist = []
# 'history' is a list of all the songs that have been played, along with
# timestamps indicating when each song finished playing.
history = []
current_song = ''
# 'listlock' is used to synchronize write-access to the other global variables.
listlock = thread.allocate_lock()
verbosity = 1
max_hist_size = 50
# 'qrunning' is used for controlling the state of the thread that is in charge of
# advancing through the playlist, aka the queue manager. If it is true, then
# the queue manager will continue advancing through the playlist, else the
# queue manager will be idle. As an exception to this rule, setting qrunning to
# the special value or 'quit' will cause the queue manager to shut down
# entirely.
qrunning = 1
# 'player_pid' is the process ID of the process that is playing the current
# song.
player_pid = ''

#---------- the request handler ----------#

class MusicHandler(SocketServer.BaseRequestHandler):
    def do_command(self, cmd, arg):
        import string
        client_addr = self.server.client_addr
        global playlist, current_song, qrunning, config
        # read-only globals
        global VERSION, conffile, history, verbosity, player_pid
        #------------------------------#
        if cmd == 'APPEND':
            playlist.append(arg)
            if verbosity > 1:
                print "Appended to playlist:", arg
        #------------------------------#
        elif cmd == 'PREPEND':
            playlist.insert(0, arg)
            if verbosity > 1:
                print "Prepended to playlist:", arg
        #------------------------------#
        elif cmd == 'CLEAR':
            playlist = []
            if verbosity > 1:
                print "Playlist cleared."
        #------------------------------#
        elif cmd == 'SHUFFLE':
            playlist = shuffle(playlist)
            if verbosity > 1:
                print "Playlist shuffled."
        #------------------------------#
        elif cmd == 'SORT':
            playlist.sort()
            if verbosity > 1:
                print "Playlist sorted."
        #------------------------------#
        elif cmd == 'REVERSE':
            playlist.reverse()
            if verbosity > 1:
                print "Playlist reversed."
        #------------------------------#
        elif cmd == 'PUTBACK':
            playlist.insert(0, current_song)
            if verbosity > 1:
                print current_song, "was put back."
        #------------------------------#
        elif cmd == 'CURR':
            self.socket.sendto(current_song, client_addr)
            if verbosity > 1:
                print "Current song is:", current_song
        #------------------------------#
        elif cmd == 'LIST':
            if verbosity > 1:
                print "The playlist contains the following:"
            if arg == 'PLAIN':
                for item in playlist:
                    self.socket.sendto(item, client_addr)
                if verbosity > 1:
                    print item
            else:
                if arg == '.':
                    start, end = 0, len(playlist)
                else:
                    try: start, end = parse_range(arg, 0, len(playlist))
                    except ValueError, e:
                        self.socket.sendto(
                            'Error: invalid range notation: %s'%e, client_addr)
                        self.socket.sendto('\0', client_addr)
                        if verbosity > 1:
                            print 'Error: invalid range notation: %s' % e
                        return
                if start < 0:
                    count = len(playlist) + start
                else:
                    count = start
                for item in playlist[start:end]:
                    self.socket.sendto('[%d] %s' % (count, item), client_addr)
                    if verbosity > 1:
                        print '[%d] %s' % (count, item)
                    count = count + 1
            # '\0' is the End-Of-List marker
            self.socket.sendto('\0', client_addr)
            if verbosity > 1:
                print "End of playlist."
        #------------------------------#
        elif cmd == 'NOPLAY':
            qrunning = 0
            if verbosity > 1:
                print "Queue traversal has been stopped."
        #------------------------------#
        elif cmd == 'PLAY':
            import os, signal
            if current_song != '':
                os.kill(player_pid, signal.SIGCONT)
            qrunning = 1
            if verbosity > 1:
                print "Queue traversal and song playing have been resumed."
        #------------------------------#
        elif cmd == 'PAUSE':
            import os, signal
            if current_song != '':
                os.kill(player_pid, signal.SIGSTOP)
            if verbosity > 1:
                print "The current song has been suspended."
        #------------------------------#
        elif cmd == 'NEXT':
            import os, signal
            if current_song != '':
                os.kill(player_pid, signal.SIGTERM)
            if verbosity > 1:
                print "Stopped current song."
        #------------------------------#
        # The REMOVE and FILTER commands work by using a rather clever trick.
        # The "match" and "antimatch" classes act as function factories which
        # produce callable instance objects that are tailor-made for judging
        # whether an aribtrary string contains a particular regular expression.
        # Then the builtin Python function, filter(), is used to evaluate each
        # of the items in the playlist with a function created by either match
        # or antimatch.
        elif cmd == 'REMOVE':
            class antimatch:
                """antimatch(regular_expression) -> function(string) -> boolean

                Produces a function object that accepts a single string and
                returns true if and only if the string does not match the
                regular expression given as the argument to antimatch().
                """
                def __init__(self, pattern):
                    import re
                    self.pattern = re.compile(pattern)
                def __call__(self, x):
                    return not self.pattern.search(x)
            playlist = filter(antimatch(arg), playlist)
            if verbosity > 1:
                print "Removed all items matching:", arg
        #------------------------------#
        elif cmd == 'FILTER':
            class match:
                """match(regular_expression) -> function(string) -> boolean

                Produces a function object that accepts a single string and
                returns true if and only if the string matches the regular
                expression given as the argument to match().
                """
                def __init__(self, pattern):
                    import re
                    self.pattern = re.compile(pattern)
                def __call__(self, x):
                    return self.pattern.search(x)
            playlist = filter(match(arg), playlist)
            if verbosity > 1:
                print "Removed all items not matching:", arg
        #------------------------------#
        elif cmd == 'MOVE':
            try:
                src, dst = string.split(arg, None, 1)
            except ValueError, e:
                if verbosity > 0:
                    print 'Error: bad argument to MOVE: "%s"' % arg
                return
            try:
                start, end = parse_range(src, 0, len(playlist))
            except ValueError, e:
                self.socket.sendto(
                    'Error: invalid range notation: %s' % e, client_addr)
                return
            try:
                dst = int(dst)
            except ValueError, e:
                self.socket.sendto('Error: %s' % e, client_addr)
                return
            # check that the destination position does not lie within the
            # source range.
            if start <= dst < end:
                self.socket.sendto('Error: the destination position may not ' +
                    'be within the source range.', client_addr)
                return
            # use an out-of-bound piece of data to mark the destination.
            # since the playlist normally only contains strings, any non-string
            # value will work as an effective marker.
            marker = 3.1415926535897931
            playlist.insert(dst, marker)
            if dst < start:       # inserting the marker can mess up the
                start = start + 1 # meaning of start and end if it is inserted
                end = end + 1     # prior to the range, so we correct for this.
            copy = playlist[start:end]   # copy the items to be moved
            del playlist[start:end]      # delete the items from their old place
            dst = playlist.index(marker) # recompute the destination position
            playlist[dst:dst+1] = copy   # put the items in their final place
            self.socket.sendto('', client_addr)
            if verbosity > 1:
                print "All items numbered from",
                print "%d until %d were moved" % (start, end),
                print "to position number %d." % dst
        #------------------------------#
        elif cmd == 'INSERT':
            try:
                position, item = string.split(arg, None, 1)
            except ValueError, e:
                if verbosity > 0:
                    print 'Error: bad argument to INSERT: "%s"' % arg
                return
            try:
                position = int(position)
            except ValueError, e:
                self.socket.sendto(
                    'Error: invalid integer: ' + str(position), client_addr)
            playlist.insert(position, item)
            if verbosity > 1:
                print "Inserted", item, "at position", position
        #------------------------------#
        elif cmd == 'CUT':
            try:
                start, end = parse_range(arg, 0, len(playlist))
            except ValueError, e:
                self.socket.sendto(
                    'Error: invalid range notation: ' + str(e), client_addr)
                return
            del playlist[start:end]
            self.socket.sendto('', client_addr)
            if verbosity > 1:
                print "All items numbered from",
                print "%d until %d were removed." % (start, end)
        #------------------------------#
        elif cmd == 'CROP':
            try:
                start, end = parse_range(arg, 0, len(playlist))
            except ValueError, e:
                self.socket.sendto(
                    'Error: invalid range notation: ' + str(e), client_addr)
                return
            playlist = playlist[start:end]
            self.socket.sendto('', client_addr)
            if verbosity > 1:
                print "All items except those numbered from",
                print "%d until %d were removed." % (start, end)
        #------------------------------#
        elif cmd == 'HISTORY':
            if arg == '.':
                self.socket.sendto(
                    string.join(history, '\n'), client_addr)
                if verbosity > 1:
                    print "The entire history list was requested."
            else:
                try:
                    self.socket.sendto(
                        string.join(history[-int(arg):], '\n'), client_addr)
                except ValueError:
                    self.socket.sendto(
                        'Error: invalid integer: ' + arg, client_addr)
                if verbosity > 1:
                    print "A history list of size", arg, "was requested."
        #------------------------------#
        elif cmd == 'DIE':
            import os, signal
            # kill the song player
            if current_song != '':
                os.kill(player_pid, signal.SIGTERM)
            # tell the queue manager to shut down
            qrunning = 'quit'
            if verbosity > 1:
                print "Shutting down."
            listlock.release()
            # finally, the request handler exits
            thread.exit()
        #------------------------------#
        elif cmd == 'STATE':
            state = 'Advancement through the playlist is currently '
            if qrunning:
                state = state + 'enabled.'
            else:
                state = state + 'disabled.'
            state = state + '\nThere are currently ' + str(len(playlist)) \
                    + ' items in the playlist.'
            self.socket.sendto(state, client_addr)
            if verbosity > 1:
                print state
        #------------------------------#
        elif cmd == 'VERSION':
            self.socket.sendto(VERSION, client_addr)
            if verbosity > 1:
                print "Server version is:", VERSION
        #------------------------------#
        elif cmd == 'RECONFIG':
            try:
                config = getConfig(conffile)
            except IOError, e:
                if verbosity > 0:
                    print "Error:", conffile + ':', e
                    print "The configuration file could not be reloaded!"
            if verbosity > 1:
                print "The configuration file, "+conffile+", was reloaded."
                print "The new configuration is thus:"
                print strConfig(config)
        #------------------------------#
        elif cmd == 'SHOWCONFIG':
            self.socket.sendto(strConfig(config), client_addr)
            if verbosity > 1:
                print "The currnent filetype configuration was requested."
        #------------------------------#
        else:
            if verbosity > 0:
                print "Error: bad request: '" + self.packet + "'"
    def handle(self):
        global listlock, verbosity
        self.packet, self.socket = self.request
        import string
        try:
            cmd, arg = string.split(self.packet, None, 1)
        except ValueError:
            if verbosity > 0:
                print "Error: bad request: '" + self.packet + "'"
            return
        listlock.acquire()
        try:
            self.do_command(cmd, arg)
        except socket.error, e:
            if verbosity > 0:
                print "socket error:", e
            self.socket.sendto('socket error: %s' % e, client_addr)
        listlock.release()

# Portability note: Unix domain sockets are used to implement interprocess
# communication between the client and the server. This prevents this
# program from working on (most) non-Unix systems.
class MusicServer(SocketServer.UnixDatagramServer):
    def handle_error(self, request, client_address):
        if sys.exc_info()[0] == SystemExit:
            thread.exit()
        else:
            SocketServer.UnixDatagramServer. \
                    handle_error(self, request, client_address)

def listener_thread(server_addr, client_addr):
    import socket
    try:
        m = MusicServer(server_addr, MusicHandler)
        m.client_addr = client_addr
    except socket.error, e:
        import errno
        if e[0] == errno.EADDRINUSE:
            sys.stderr.write(
            "Error: Either moosicd is already running, or a previous " +
            "invocation\nof moosicd failed to clean up after itself " +
            "properly.\nThe file named \"" + server_addr + "\"\n" +
            "must be removed before moosicd can run.\n" )
        else:
            print e
        # tell the queue manager to shut down
        global qrunning
        qrunning = 'quit'
        thread.exit()
    try:
        m.serve_forever()
    except socket.error, e:
        import errno
        if e[0] == errno.EINTR:
            # ignore "Interrupted system call" exceptions
            pass 
        else:
            print 'socket error:', e
    except SystemExit:
        os.remove(server_addr) # don't leave unused socket files around.
        thread.exit()


#------------ "main" - The program's execution starts here  ------------#
import sys, getopt, os.path, signal

#---------- initialization code ----------#

# set the default configuration directory.
confdir = os.path.join(os.environ['HOME'], '.moosic')
# default to sending all our output to a log file
log_to_file = 1

try:
    options, arglist = getopt.getopt(sys.argv[1:], 'hvqds:c:S',
                ['help', 'version', 'quiet', 'debug', 'history-size=',
                'config=', 'stdout'])
except:
    print 'Option processing error:', e
    sys.exit(1)
for opt, val in options:
    if opt == '-h' or opt == '--help':
        print 'usage:', os.path.basename(sys.argv[0]), '[options]' + '''
    Options:
        -s, --history-size  Set the maximum size of the history list.
                            (Default: 50)
        -c, --config        Specify the configuration directory. You really
                            don't want to change this. (Default: ~/.moosic/)
        -q, --quiet         Don't print any informational messages.
        -d, --debug         Print lots and lots of informational messages.
        -S, --stdout        Output messages to stdout instead of logging to a
                            file.
        -v, --version       Print version information and exit.
        -h, --help          Print this help text and exit.
        '''
        sys.exit(0)
    if opt == '-v' or opt == '--version':
        print "moosic", VERSION
        print """
Copyright (C) 2001 Daniel Pearson <dpears2@umbc.edu>
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE."""
        sys.exit(0)
    if opt == '-q' or opt == '--quiet':
        verbosity = 0
    if opt == '-d' or opt == '--debug':
        verbosity = 2
    if opt == '-s' or opt == '--history-size':
        max_hist_size = val
    if opt == '-c' or opt == '--config':
        confdir = val
    if opt == '-S' or opt == '--stdout':
        log_to_file = 0

if arglist:
    print 'Warning: non-option command line arguments are ignored.'

# read the configuration file, and create it if it doesn't already exist
conffile = os.path.join(confdir, 'config-' + socket.gethostname())
try:
    if not os.path.exists(confdir):
        os.mkdir(confdir)
except IOError, e:
    print "Error:", confdir + ':', e
    sys.exit(1)
try:
    if os.path.isfile(conffile):
        config = getConfig(conffile)
    elif not os.path.exists(conffile):
        f = open(conffile, 'w')
        f.write('''\
# ~/.moosic/config
# This file associates filetypes with commands which play them.
#
# The format of this file is as follows:  Every pair of lines forms a unit.
# The first line in a pair is a regular expression that will be matched against
# items in the play list.  The second line in a pair is the command that will
# be used to play any items that match the regular expression.  The name of the
# item to be played will be appended to the end of this command, unless the
# string "$item" occurs in the command, in which case every occurrence of
# "$item" will be replaced with the name of the item to be played.  The command
# will not be interpreted by a shell, so don't try tricks with quotes or
# globbing.
#
# Blank lines and lines starting with a '#' character are ignored.  Regular
# expressions specified earlier in this file take precedence over those
# specified later.

\.mp3$
mpg123 -q

\.midi?$
timidity -idq

\.(mod|xm|s3m|stm|it|mtm|669|amf)$
mikmod -q

\.(wav|8svx|aiff|aifc|aif|au|cdr|maud|sf|snd|voc)$
sox $item -t ossdsp /dev/dsp

\.ogg$
ogg123 -q''')
        f.close()
        config = getConfig(conffile)
    else:
        print "Error:", conffile, "exists, but is not a reglar file."
        print "I can't run without a proper configuration file."
        sys.exit(1)
except IOError, e:
    print "Error: %s:" % conffile, e
    sys.exit(1)

if log_to_file:
    try:
    	logfilename = os.path.join(confdir, 'log-' + socket.gethostname())
    	if os.path.exists(logfilename):
	    sys.stdout = open(logfilename, 'a', 0)
	else:
	    sys.stdout = open(logfilename, 'w', 0)
	del logfilename
    except IOError, e:
        sys.stdout = sys.__stdout__
        print e
        sys.exit(1)

def cleanup(signum, stackframe):
    '''This is in charge of making sure everything is in a proper state so that
    moosicd can shut down correctly whenever it receives a termination signal.
    '''
    try: os.remove(server_addr) # don't leave unused socket files around.
    except: pass
    # tell the queue manager to shut down
    qrunning = 'quit'
    # kill the song player
    if current_song != '':
        try: os.kill(player_pid, signal.SIGTERM)
        except: pass
    thread.exit()
signal.signal(signal.SIGTERM, cleanup)

server_addr = os.path.join(confdir, 'server-' + socket.gethostname())
client_addr = os.path.join(confdir, 'client-' + socket.gethostname())
# spawn the thread that runs the request handler.
thread.start_new_thread(listener_thread, (server_addr, client_addr))

#---------- the queue manager ----------#
import time
try:
    while qrunning != 'quit':
        if playlist and qrunning:
            listlock.acquire()
            # pop a song off of the playlist
            current_song = playlist.pop(0)
            listlock.release()

            # play the song
            if verbosity > 0:
                print time.strftime('%I:%M:%S%p',
                                    time.localtime(time.time())),
                print 'Playing', current_song
            play(config, current_song)
            # Note that control does not return to this point until the song is
            # finished playing.

            listlock.acquire()
            # update the history to reflect the fact that the song was played
            history.append(time.strftime('%I:%M:%S%p ',
                           time.localtime(time.time())) + current_song)
            if len(history) > max_hist_size:
                history.pop(0)
            # reset current_song to indicate that nothing is being played
            current_song = ''
            listlock.release()
        else:
            time.sleep(0.1)
except KeyboardInterrupt:
    print "Interrupted from the keyboard. Shutting down now."
    os.remove(server_addr) # don't leave unused socket files around.
