#!/usr/bin/env python
#-----------------------------------------------------------------------------
# Name:        mkplaylist.py
# Purpose:     Create playlists from directory scans.
#
# Author:      Marc 'BlackJack' Rintsch
#
# Created:     2004-11-09
# Copyright:   (c) 2004
# Licence:     GPL
#-----------------------------------------------------------------------------
"""Make a playlist file.

:var checker: instance of a `MediaFileChecker`.
:var HAVE: dictionary of module names that maps to booleans that tell
    if the module was imported successfully.
:var MODULES: names of modules to read meta data from various media
    file types.
:var TYPES: tuples with information about recognized types.  Each tuple
    consists of the file name extension(s), a descriptive name, and
    a function to read meta data from that file type.
:var WRITERS: dictionary that maps playlist format names to functions
    that write a sequence of `PlaylistEntry` objects in that format
    to a file.

:todo: I am quite unhappy with the way meta data is read -- too much
    dependencies.  This should be moved to the `PlaylistEntry` class
    and maybe just read on demand, i.e. if someone wants to actually
    accesses it.

:2004-11-25:
    renamed read_mp3_mad to read_mp3_mp3. Removed repeated call to 
    try_import in read_mp3_mp3,read_ogg_ogg. Both now return a 
    dictionary with only the entries actually found in the file 
    instead of guessing song infos from filename. title-artist guessing 
    is done in write_extm3u. PlaylistEntry returns the emptry string
    for unknown keys (so there is no need to test with has_key)
"""

import sys
import os
import os.path
import random

__author__ = "Marc 'BlackJack' Rintsch <marc(at)rintsch(dot)de>"
__version__ = '0.3'
__docformat__ = 'ReStructuredText'


#-----------------------------------------------------------------------------
# Configure stuff.  Checking at runtime which packages are there to read
# meta data from media files.
#
# All packages and modules that are optional will be imported within the
# following code section and there is a global dictionary `HAVE` that
# maps module names to boolean values which are set when a module was
# imported or not.

def try_import(module_names):
    """Tries to import modules.
    
    The modules are imported into the global namespace.
    
    :param module_names: names of the modules to import.
    :type module_names: sequence of str
    
    :return: a dictionary with module names mapped to `True` if the
        module was successfully imported, `False` otherwise.
    :rtype: dict of str -> bool
    """
    have = dict()
    for module_name in module_names:
        try:
            module = __import__(module_name)
            have[module_name] = True
            globals()[module_name] = module
        except ImportError:
            have[module_name] = False
    
    return have


MODULES = ('mad', 'ID3', 'ogg', 'ogg.vorbis')
HAVE = try_import(MODULES)


#-----------------------------------------------------------------------------
# Functions to read meta data from media files.
#
# Every format should have a name for function to read the meta data
# consisting of the prefix "read_" and the file name extension.
# This name should be initialized with `None`.
#
# Then one or more real functions are defined and finally one of them
# should be bound to the name mentioned above, depending on the
# availibility of modules used in the functions.
#
# The functions return a dictionary with the meta data.  The minimal
# postcondition is an empty dictionary if no information could be
# read from the file.
#
# Keys to use (yes, they are all uppercase!):
#
# ARTIST, TITLE, TIME (playing time in seconds)
#
# All other keys are currently not used by any output format.


#
# MP3
#

read_mp3 = None

def read_mp3_ID3(path):
    info = ID3.ID3(path).as_dict()
    mf = mad.MadFile(path)
    info['TIME'] = str(int(mf.total_time() / 1000))
    return info


if HAVE['mad'] and HAVE['ID3']:
    read_mp3 = read_mp3_ID3


#
# Ogg Vorbis
#

read_ogg = None

def read_ogg_ogg(path):
    info = dict()
    vfile = ogg.vorbis.VorbisFile(path)
    info = vfile.comment().as_dict()
    for key, value in info.items():
        # type(v) == list ? why ?
        if isinstance(value[0], unicode):
            info[key] = value[0].encode('utf-8')
    # -1 tells time_total to return the time of the whole stream
    # in seconds (type float).
    info['TIME'] = str(int(vfile.time_total(-1)))
    return info


if HAVE['ogg.vorbis']:
    read_ogg = read_ogg_ogg

#-----------------------------------------------------------------------------

class PlaylistEntry:
    """A generic playlist entry with a `path` attribute and dictionary
    like behavior for meta data.
    
    `PlaylistEntry` objects can be converted to strings and are
    comparable with their `path` attribute as key.
    
    The meta data contains at least the path of the media file.
    
    :ivar path: path of the media file.
    :type path: str
    :ivar metadata: meta data of the media file.
    :type metadata: dict of str -> str
    
    :invariant: self['path'] is not None
    """
    def __init__(self, path, metadata=None):
        """Creates a playlist entry.
        
        :param path: the path of the media file.
        :type path: str
        :param metadata: a dictionary with meta data.
        :type metadata: dict of str -> str
        
        :precondition: The meta data must not contain a key 'path'.
        :postcondition: The meta data contains the `path` as key.
        """
        self.path = path
        
        if metadata is None:
            metadata = dict()

        self.metadata = metadata
        assert self.metadata.get('PATH') is None
        self.metadata['PATH'] = self.path
        
        # TODO: Some black magic to fill the metadata from examining
        #       the file name if the dict is empty.
    

    def __getitem__(self, key):
        return self.metadata.get(key, '')
    
    def __setitem__(self, key, value):
        self.metadata[key] = value
    
    def __cmp__(self, other):
        return cmp(self.path, other.path)
        
    def __str__(self):
        return self.path
        

class MediaFileChecker:
    """A media file checker allows registritation of media file types,
    their file name extensions and functions for reading meta data from
    the files.
    
    This is not a "real" class but more an abuse of a class as a
    namespace.  If the programs grows so large that it will become
    unavoidable to split it into several modules, the contents of
    this class will be moved to the top level of a module.
    
    :ivar types: dictionary that maps a file name extension to a
        tuple containing a descriptive name of the type and a
        sequence of functions to read meta data from this file type.
    :type types: dict
    """
    def __init__(self):
        self.types = dict()

    
    def registerType(self, name, extensions, metadata_reader=None):
        """Registers a media file type, its name, and a function to
        read meta data from the file.
        
        :note: Re-registering extensions replaces the existing
            entries.  Acceptable behavior!?
        
        :param name: descriptive name of the media file type.
        :type name: str
        :param extensions: a single file name extension or a sequence
            of file name extensions of the media file type.
        :type extensions: str or sequence of str
        :param metadata_reader: function that reads the meta data from
            a given media file.
        :type metadata_reader: callable or `None`
        
        :todo: Add doctest code.
        """
        if isinstance(extensions, basestring):
            extensions = [extensions]
        
        for extension in extensions:
            if not extension.startswith('.'):
                extension = '.' + extension
            self.types[extension.lower()] = (name, metadata_reader)


    def isMediaFile(self, path):
        """Checks file if it is a known media file.

        The check is based on the filename extension and is case
        insensitive.

        :param path: filename of the file to check.
        :type path: str

        :returns: `True` if known media file, `False` otherwise.
        :rtype: bool
        """
        file_extension = os.path.splitext(path)[1].lower()
        return file_extension in self.types


    def readMetadata(self, path):
        """Reads meta data from the given media file.
        
        :param path: path to the media file.
        :type path: str
        
        :return: dictionary with meta data.
        :rtype: dict of str -> str
        """
        file_extension = os.path.splitext(path)[1].lower()
        reader = self.types[file_extension][1]
        if reader is None:
            return dict()
        else:
            return reader(path)

#
# Create a MediaFileChecker instance and populate it with the
# known file extensions.
#
checker = MediaFileChecker()

TYPES = (
    ("mp3", "MPEG II Layer 3", read_mp3),
    ("ogg", "Ogg Vorbis", read_ogg),
    ("wav", "Wave", None),
    ("flac", "Free Losless Audio Codec", None),
    ("ac3", "AC3/A52", None),
    ("mod", "Tracker Module", None),
    ("s3m", "Scream Tracker 3", None),
    ("it", "Impulse Tracker", None))

for _ext, _name, _metadata_reader in TYPES:
    checker.registerType(_name, _ext, _metadata_reader)


#-----------------------------------------------------------------------------
# Playlist writers.

def write_m3u(playlist, outfile):
    # TODO: Check if writing in a loop is faster.
    outfile.write('\n'.join(map(str, playlist)))
    outfile.write('\n')


def write_extm3u(playlist, outfile):
    print >> outfile, '#EXTM3U'
    for entry in playlist:
        info = ""
        if entry['ARTIST'] and entry['TITLE'] and entry['TIME']:
            info = '#EXTINF:' + entry['TIME'] + ',' + entry['ARTIST'] + \
                   ' - ' + entry['TITLE']
            print >> outfile,info
        # should this use os.path.abspath(outfile) ?
        print >> outfile, str(entry)


WRITERS = { 'm3u': write_m3u,
            'extm3u': write_extm3u }

#-----------------------------------------------------------------------------


def search(path):
    """Searches media files in `path` (recursivly).
    
    Symlinked directories are skipped to avoid endless
    scanning due to possible cycles in directory structure.
    
    :param path: root of the directory tree to search.
    :type path: str
    
    :returns: list of paths to the media files.
    :rtype: list
    """
    result = list()
    for root, __, filenames in os.walk(path):
        print >> sys.stderr, "Scanning %s..." % root
        # TODO: Check if there are linked directories.
        for filename in filenames:
            if checker.isMediaFile(filename):
                full_name = os.path.join(root, filename)
                entry = PlaylistEntry(full_name,
                                      checker.readMetadata(full_name))
                result.append(entry)
    
    return result


def main():
    """usage: %prog [options] directory [directory ...]"""
    
    from optparse import OptionParser
    
    parser = OptionParser(usage=main.__doc__, version=__version__)
    parser.add_option("-o", "--output", type="string", dest="outfile",
                      default='-',
                      help="name of the output file or '-' for stdout (default)")
    parser.add_option("-f", "--output-format", type="string",
                      dest="output_format", default="extm3u",
                      help="format of the output %r (default: extm3u)" %
                            WRITERS.keys())
    parser.add_option("--shuffle", action="store_true", default=False,
                      help="shuffle the playlist before saving it.")
    
#    parser.add_option("-q", "--quiet", action="store_true", default=False,
#                      dest="quiet", help="Be really quiet.")
    
    (options, args) = parser.parse_args()
    
    if len(args) == 0:
        parser.error("wrong number of arguments")
    
    
    try:
        write_playlist = WRITERS[options.output_format]
    except KeyError:
        parser.error("unknown output format: %s" % options.output_format)
    
    #
    # Input.
    #
    media_files = list()
    for path in args:
        media_files += search(path)
    
    #
    # Processing.
    #
    if options.shuffle:
        random.shuffle(media_files)
    else:
        media_files.sort()

    #
    # Output.
    #
    outfile = sys.stdout
    if options.outfile != '-':
        outfile = file(options.outfile, "w")
    
    
    write_playlist(media_files, outfile)
    outfile.close()
    
    print >> sys.stderr, "That's %d file(s)." % len(media_files)


if __name__ == '__main__':
    main()
