#!/usr/local/bin/python
"""
  NAME
    playlist.py: Output album entries matching category criteria

  SYNOPSIS
    playlist.py [-a] [-x cat[,cat ...]] [-l] [-v]  [-f album-list] [-d dir]
                   [-U] [-n] [-c cat[,cat ...]] [cat [cat ] ...]

    Switches:
      -a output all album entries.
      -x exclude categories.
      -l list all categories defined in album-list file.
         If specified twice, display number of albums in each category
      -f read albums and categories from album-list file; default
         is album.list in the working directory.
      -d path location for all album entry names; default is
         /rep/music/mp3.
      -U update album list with any new directories found in the mp3
         directory, as specified by the -d switch.
      -n don't prompt for categories when adding new albums via the -U
         switch; a default category of NEW will be assigned, unless
         overridden by the -c switch.
      -c use category list as default when adding new albums via the
         -U switch.

      One more more categories may be provided.  Categories are implicitly
      or'ed together.  To 'and' categories (i.e select those albums that
      have been tagged with all the categories given, use the plus (+)
      character to join categories.

  DESCRIPTION
    playlist.py is driven by the album-list file, which consists of
    album directory names, one per line, followed by a comma-separated
    list of assigned categories.  Categories are separated from the
    album directory name by a colon.

    For example:
      various_-_stuff:prog,rock

    The program will return a list of album directories which match
    the desired catagories, each album directory prefixed by the -d dir
    argument (or /rep/music/mp3 if -d dir is not given).

    This list is designed to be used by find in order to create a
    playlist of song files.

    The -x argument may be used to exclude certain categories, e.g.
    "playlist.py -a -x rock" will omit rock genre music from an
    otherwise complete playlist.  As another example, "playlist.py
    -x funk jazz" will return all jazz music that is not also
    categorised as funk.

    If -U is specified, any playlist options and categories are
    ignored.  By default, the -U switch will cause playlist.py to
    prompt for the category to assign for each new album located in
    the directory identified by the -d switch.  This prompting may be
    suppressed using the -n switch, in which case new entries are
    given the category NEW.  This default may be modified by providing
    a preferred default category (or list) using -c.

  EXAMPLES
    Generate complete playlist:
      find `playlist.py -a` -type f -name "*.mp3" -print >playlist

    Generate 'pure' jazz playlist:
      find `playlist.py -x rock,funk,world jazz` \
            -type f -name "*.mp3" -print >jazz.pl

    Generate jazz rock playlist:
      find `playlist.py -x world,funk jazz+rock` \
            -type f -name "*.mp3" -print >jazzrock.pl

    Update album.list file with new album entries in the mp3 directory,
    do not prompt for categories but assign the category of "rock":
      python playlist.py -U -n -c rock

"""

import sys
import getopt
import os

def tokenise(str,delim):
    """Return delimited string as list of strings."""
    return [t.strip() for t in str.split(delim)]

def build_lists(file_name):
    """Build and return dictionary of category lists. Key is category name,
       value is an array of matching albums."""
    meta = dict()
    try:
        with open(file_name) as f:
            for line in f:
                toks = tokenise(line,':')
                cats = tokenise(toks[1],',')
                for cat in cats:
                    if cat in list(meta.keys()):
                        meta[cat].append(toks[0])
                    else:
                        meta[cat] = [toks[0],]
    except IOError as e:
        print("%s: unable to process album file: %s" %\
              (sys.argv[0],e), file=sys.stderr)
        sys.exit(1)
    return meta

def read_album_list(file_name):
    """Build and return dictionary of album-list file. Key is album name,
       value is list of categories."""
    albums = dict()
    if os.path.exists(file_name):
        try:
            with open(file_name) as f:
                for line in f:
                    toks = tokenise(line,':')
                    albums[toks[0]] = tokenise(toks[1],',')
        except IOError as e:
            print("%s: unable to process album file: %s" %\
                  (sys.argv[0],e), file=sys.stderr)
            sys.exit(1)
    return albums

def write_album_list(albums,file_name):
    "Write in-memory version of album-list to file."
    with open(file_name,"w") as f:
        for album in sorted(albums.keys()):
            f.write("%s:%s\n"%(album,','.join(albums[album])))
    return

def add_from_dir(albums,album_dir,ask,default_cat):
    "Add categories to new directory entries; prompt user if requested."
    count = 0
    dirs = os.listdir(album_dir)
    for dir in dirs:
        if os.path.isfile(dir): continue
        if dir.startswith('.'): continue # ignore mac-created files
        if dir not in albums:
            if ask:
                albums[dir] = get_input("%s: "%(dir,))
            else:
                albums[dir] = [default_cat]
            count += 1
    return count

def get_input(prompt):
    "Get category from user."
    try:
        cat = input(prompt)
        if cat == "q": sys.exit(1)
    except EOFError:
        print("End of file reading from stdin.", file=sys.stderr)
        sys.exit(1)
    return tokenise(cat,',')

def parse_catands(album_cats,cat_string):
    "Return sequence of albums that match anded categories."
    cats = cat_string.split('+')
    if len(cats) <= 1:
        print("%s: '%s' is not a catand - internal error." % \
              (sys.argv[0],cat_string), file=sys.stderr)
        sys.exit(1)
    cat = cats[0]
    try:
        s = set(album_cats[cat])
        for cat in cats[1:]:
            s = s.intersection(set(album_cats[cat]))
    except KeyError:
        print("%s: no such category as %s"% \
              (sys.argv[0],cat), file=sys.stderr)
        sys.exit(1)
    return s

def process_cats(meta_list,cats,exclude_list,root_dir,all):
    "Generate list of albums as determined by arguments."
    names = dict()
    try:
        if all:
            for albums in list(meta_list.values()):
                for album in albums:
                    names[album] = 0
        else:
            # split category arg list into two: those with
            # ands (+) and ors (implicit)
            catands = list()
            cators = list()
            for cat in cats:
                if cat.find('+') > 0:
                    catands.append(cat)
                else:
                    cators.append(cat)
            # get all anded categories first
            for catand in catands:
                albums = parse_catands(meta_list,catand)
                for album in albums:
                    names[album] = 0
            for cat in cators:
                for album in meta_list[cat]:
                    names[album] = 0
        if exclude_list:
            excludes = tokenise(exclude_list,',')
            for cat in excludes:
                for album in meta_list[cat]:
                    try:
                        del names[album]
                    except:
                        continue
    except KeyError:
        print("%s: no such category as %s" % (sys.argv[0],cat), file=sys.stderr)
        sys.exit(1)

    for album in list(names.keys()):
        print("%s/%s"%(root_dir,album))
    return

###################################################################
# program starts here                                             #
###################################################################

LIST_NONE = 0
LIST_CATS = 1
LIST_COUNTS = 2

ls = LIST_NONE
all = False
album_file = "album.list"
exclude_list = None
root_dir = "/rep/music/mp3"
update_list = False
ask = True
default_new = "NEW"
verbose = False

try:
    opts,args = getopt.getopt(sys.argv[1:],'arx:lf:d:Unc:')
    for o,v in opts:
        if o == '-l':
            ls = LIST_CATS if ls == LIST_NONE else LIST_COUNTS
        elif o == '-a': all = True
        elif o == '-f': album_file = v
        elif o == '-x': exclude_list = v
        elif o == '-d': root_dir = v
        elif o == "-U": update_list = True
        elif o == "-n": ask = False
        elif o == "-c": default_new = v
except getopt.GetoptError as e:
    print("%s: illegal argument -%s" % (sys.argv[0],e.opt), file=sys.stderr)
    sys.exit(1)

# if update of album list file required, that's all we'll do
if update_list:
    albums = read_album_list(album_file)
    added = add_from_dir(albums,root_dir,ask,default_new)
    write_album_list(albums,album_file)
    print("Added",added,"albums.")
else:
    # read in album file and build category lists
    meta_list = build_lists(album_file)
    # if a list of the categories desired, that's it.
    if ls == LIST_CATS or ls == LIST_COUNTS:
        for cat in sorted(meta_list.keys()):
            if ls == LIST_COUNTS:
                print(f'{cat:12}{len(meta_list[cat]):4}')
            else:
                print(cat)
    else:
        process_cats(meta_list,args,exclude_list,root_dir,all)
