+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2010-2013 Jack Kaliko <efrim@azylum.org>
+#
+# This file is part of MPD_sima
+#
+# MPD_sima 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 3 of the License, or
+# (at your option) any later version.
+#
+# MPD_sima 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 MPD_sima. If not, see <http://www.gnu.org/licenses/>.
+#
+#
+
+__version__ = '0.4.0'
+
+# IMPORT#
+import re
+
+from argparse import (ArgumentParser, SUPPRESS, Action)
+from difflib import get_close_matches
+from locale import getpreferredencoding
+from os import (environ, chmod, makedirs)
+from os.path import (join, isdir, isfile, expanduser)
+from sys import (exit, stdout, stderr)
+
+from sima.lib.track import Track
+from sima.utils import utils
+from sima.lib import simadb
+from musicpd import MPDClient, ConnectionError
+
+
+DESCRIPTION = """
+simadb_cli helps you to edit entries in your own DB of similarity
+between artists."""
+DB_NAME = 'sima.db'
+
+class FooAction(Action):
+ def check(self, namespace):
+ if namespace.similarity: return True
+ if namespace.remove_art: return True
+ if namespace.remove_sim: return True
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ opt_required = '"--remove_artist", "--remove_similarity" or "--add_similarity"'
+ if not self.check(namespace):
+ parser.error(
+ 'can\'t use {0} option before or without {1}'.format(
+ option_string, opt_required))
+ setattr(namespace, self.dest, True)
+
+# Options list
+# pop out 'sw' value before creating ArgumentParser object.
+OPTS = list([
+ {
+ 'sw':['-a', '--add_similarity'],
+ 'type': str,
+ 'dest':'similarity',
+ 'help': 'Similarity to add formated as follow: ' +
+ ' "art_0,art_1:90,art_2:80..."'},
+ {
+ 'sw': ['-c', '--check_names'],
+ 'action': 'store_true',
+ 'default': False,
+ 'help': 'Turn on controls of artists names in MPD library.'},
+ {
+ 'sw':['-d', '--dbfile'],
+ 'type': str,
+ 'dest':'dbfile',
+ 'action': utils.Wfile,
+ 'help': 'File to read/write database from/to'},
+ {
+ 'sw': ['-r', '--reciprocal'],
+ 'default': False,
+ 'nargs': 0,
+ 'action': FooAction,
+ 'help': 'Turn on reciprocity for similarity relation when add/remove.'},
+ {
+ 'sw':['--remove_artist'],
+ 'type': str,
+ 'dest': 'remove_art',
+ 'metavar': '"ARTIST TO REMOVE"',
+ 'help': 'Remove an artist from DB (main artist entries).'},
+ {
+ 'sw':['--remove_similarity'],
+ 'type': str,
+ 'dest': 'remove_sim',
+ 'metavar': '"MAIN ART,SIMI ART"',
+ 'help': 'Remove an similarity relation from DB (main artist <=> similar artist).'},
+ {
+ 'sw':['-v', '--view_artist'],
+ 'type': str,
+ 'dest':'view',
+ 'metavar': '"ARTIST NAME"',
+ 'help': 'View an artist from DB.'},
+ {
+ 'sw':['--view_all'],
+ 'action': 'store_true',
+ 'help': 'View all similarity entries.'},
+ {
+ 'sw': ['-S', '--host'],
+ 'type': str,
+ 'dest': 'mpdhost',
+ 'default': None,
+ 'help': 'MPD host, as IP or FQDN (default: localhost|MPD_HOST).'},
+ {
+ 'sw': ['-P', '--port'],
+ 'type': int,
+ 'dest': 'mpdport',
+ 'default': None,
+ 'help': 'Port MPD in listening on (default: 6600|MPD_PORT).'},
+ {
+ 'sw': ['--password'],
+ 'type': str,
+ 'dest': 'passwd',
+ 'default': None,
+ 'help': SUPPRESS},
+ {
+ 'sw': ['--view_bl'],
+ 'action': 'store_true',
+ 'help': 'View black list.'},
+ {
+ 'sw': ['--remove_bl'],
+ 'type': int,
+ 'help': 'Suppress a black list entry, by row id. Use --view_bl to get row id.'},
+ {
+ 'sw': ['--bl_art'],
+ 'type': str,
+ 'metavar': 'ARTIST_NAME',
+ 'help': 'Black list artist.'},
+ {
+ 'sw': ['--bl_curr_art'],
+ 'action': 'store_true',
+ 'help': 'Black list currently playing artist.'},
+ {
+ 'sw': ['--bl_curr_alb'],
+ 'action': 'store_true',
+ 'help': 'Black list currently playing album.'},
+ {
+ 'sw': ['--bl_curr_trk'],
+ 'action': 'store_true',
+ 'help': 'Black list currently playing track.'},
+ {
+ 'sw':['--purge_hist'],
+ 'action': 'store_true',
+ 'dest': 'do_purge_hist',
+ 'help': 'Purge play history.'}])
+
+
+class SimaDB_CLI(object):
+ """Command line management.
+ """
+
+ def __init__(self):
+ self.dbfile = self._get_default_dbfile()
+ self.parser = None
+ self.options = dict({})
+ self.localencoding = 'UTF-8'
+ self._get_encoding()
+ self._upgrade()
+ self.main()
+
+ def _get_encoding(self):
+ """Get local encoding"""
+ localencoding = getpreferredencoding()
+ if localencoding:
+ self.localencoding = localencoding
+
+ def _get_mpd_env_var(self):
+ """
+ MPD host/port environement variables are used if command line does not
+ provide host|port|passwd.
+ """
+ host, port, passwd = utils.get_mpd_environ()
+ if self.options.passwd is None and passwd:
+ self.options.passwd = passwd
+ if self.options.mpdhost is None:
+ if host:
+ self.options.mpdhost = host
+ else:
+ self.options.mpdhost = 'localhost'
+ if self.options.mpdport is None:
+ if port:
+ self.options.mpdport = port
+ else:
+ self.options.mpdport = 6600
+
+ def _upgrade(self):
+ """Upgrades DB if necessary, create one if not existing."""
+ if not isfile(self.dbfile): # No db file
+ return
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db.upgrade()
+
+ def _declare_opts(self):
+ """
+ Declare options in ArgumentParser object.
+ """
+ self.parser = ArgumentParser(description=DESCRIPTION,
+ usage='%(prog)s [-h|--help] [options]',
+ prog='simadb_cli',
+ epilog='Happy Listening',
+ )
+
+ self.parser.add_argument('--version', action='version',
+ version='%(prog)s {0}'.format(__version__))
+ # Add all options declare in OPTS
+ for opt in OPTS:
+ opt_names = opt.pop('sw')
+ self.parser.add_argument(*opt_names, **opt)
+
+ def _get_default_dbfile(self):
+ """
+ Use XDG directory standard if exists
+ else use "HOME/.local/share/mpd_sima/"
+ http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
+ """
+ homedir = expanduser('~')
+ dirname = 'mpd_sima'
+ if environ.get('XDG_DATA_HOME'):
+ data_dir = join(environ.get('XDG_DATA_HOME'), dirname)
+ else:
+ data_dir = join(homedir, '.local', 'share', dirname)
+ if not isdir(data_dir):
+ makedirs(data_dir)
+ chmod(data_dir, 0o700)
+ return join(data_dir, DB_NAME)
+
+ def _get_mpd_client(self):
+ """"""
+ # TODO: encode properly host name
+ host = self.options.mpdhost
+ port = self.options.mpdport
+ cli = MPDClient()
+ try:
+ cli.connect(host=host, port=port)
+ except ConnectionError as err:
+ mess = 'ERROR: fail to connect MPD (host: %s:%s): %s' % (
+ host, port, err)
+ print(mess, file=stderr)
+ exit(1)
+ return cli
+
+ def _create_db(self):
+ """Create database if necessary"""
+ if isfile(self.dbfile):
+ return
+ print('Creating database!')
+ open(self.dbfile, 'a').close()
+ simadb.SimaDB(db_path=self.dbfile).create_db()
+
+ def _get_art_from_db(self, art):
+ """Return (id, name, self...) from DB or None is not in DB"""
+ db = simadb.SimaDB(db_path=self.dbfile)
+ art_db = db.get_artist(art, add_not=True)
+ if not art_db:
+ print('ERROR: "%s" not in data base!' % art, file=stderr)
+ return None
+ return art_db
+
+ def _control_similarity(self):
+ """
+ * Regex check of command line similarity
+ * Controls artist presence in MPD library
+ """
+ usage = ('USAGE: "main artist,similar artist:<match score>,other' +
+ 'similar artist:<match score>,..."')
+ cli_sim = self.options.similarity
+ pattern = '^([^,]+?),([^:,]+?:\d{1,2},?)+$'
+ regexp = re.compile(pattern, re.U).match(cli_sim)
+ if not regexp:
+ mess = 'ERROR: similarity badly formated: "%s"' % cli_sim
+ print(mess, file=stderr)
+ print(usage, file=stderr)
+ exit(1)
+ if self.options.check_names:
+ if not self._control_artist_names():
+ mess = 'ERROR: some artist names not found in MPD library!'
+ print(mess, file=stderr)
+ exit(1)
+
+ def _control_artist_names(self):
+ """Controls artist names exist in MPD library"""
+ mpd_cli = self._get_mpd_client()
+ artists_list = mpd_cli.list('artist')
+ sim_formated = self._parse_similarity()
+ control = True
+ if sim_formated[0] not in artists_list:
+ mess = 'WARNING: Main artist not found in MPD: %s' % sim_formated[0]
+ print(mess)
+ control = False
+ for sart in sim_formated[1]:
+ art = sart.get('artist')
+ if art not in artists_list:
+ mess = str('WARNING: Similar artist not found in MPD: %s' % art)
+ print(mess)
+ control = False
+ mpd_cli.disconnect()
+ return control
+
+ def _parse_similarity(self):
+ """Parse command line option similarity"""
+ cli_sim = self.options.similarity.strip(',').split(',')
+ sim = list([])
+ main = cli_sim[0]
+ for art in cli_sim[1:]:
+ artist = art.split(':')[0]
+ score = int(art.split(':')[1])
+ sim.append({'artist': artist, 'score': score})
+ return (main, sim)
+
+ def _print_main_art(self, art=None):
+ """Print entries, art as main artist."""
+ if not art:
+ art = self.options.view
+ db = simadb.SimaDB(db_path=self.dbfile)
+ art_db = self._get_art_from_db(art)
+ if not art_db: return
+ sims = list([])
+ [sims.append(a) for a in db._get_similar_artists_from_db(art_db[0])]
+ if len(sims) == 0:
+ return False
+ print('"%s" similarities:' % art)
+ for art in sims:
+ mess = str(' - {score:0>2d} {artist}'.format(**art))
+ print(mess)
+ return True
+
+ def _remove_sim(self, art1_db, art2_db):
+ """Remove single similarity between two artists."""
+ db = simadb.SimaDB(db_path=self.dbfile)
+ similarity = db._get_artist_match(art1_db[0], art2_db[0])
+ if similarity == 0:
+ return False
+ db._remove_relation_between_2_artist(art1_db[0], art2_db[0])
+ mess = 'Remove: "{0}" "{1}:{2:0>2d}"'.format(art1_db[1], art2_db[1],
+ similarity)
+ print(mess)
+ return True
+
+ def _revert_similarity(self, sim_formated):
+ """Revert similarity string (for reciprocal editing - add)."""
+ main_art = sim_formated[0]
+ similars = sim_formated[1]
+ for similar in similars:
+ yield (similar.get('artist'),
+ [{'artist':main_art, 'score':similar.get('score')}])
+
+ def bl_artist(self):
+ """Black list artist"""
+ mpd_cli = self._get_mpd_client()
+ if not mpd_cli:
+ return False
+ artists_list = mpd_cli.list('artist')
+ # Unicode cli given artist name
+ cli_artist_to_bl = self.options.bl_art
+ if cli_artist_to_bl not in artists_list:
+ print('Artist not found in MPD library.')
+ match = get_close_matches(cli_artist_to_bl, artists_list, 50, 0.78)
+ if match:
+ print('You may be refering to %s' %
+ '/'.join([m_a for m_a in match]))
+ return False
+ print('Black listing artist: %s' % cli_artist_to_bl)
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db.get_bl_artist(cli_artist_to_bl)
+
+ def bl_current_artist(self):
+ """Black list current artist"""
+ mpd_cli = self._get_mpd_client()
+ if not mpd_cli:
+ return False
+ artist = mpd_cli.currentsong().get('artist', '')
+ if not artist:
+ print('No artist found.')
+ return False
+ print('Black listing artist: %s' % artist)
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db.get_bl_artist(artist)
+
+ def bl_current_album(self):
+ """Black list current artist"""
+ mpd_cli = self._get_mpd_client()
+ if not mpd_cli:
+ return False
+ track = Track(**mpd_cli.currentsong())
+ if not track.album:
+ print('No album set for this track: %s' % track)
+ return False
+ print('Black listing album: {0}'.format(track.album))
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db.get_bl_album(track)
+
+ def bl_current_track(self):
+ """Black list current artist"""
+ mpd_cli = self._get_mpd_client()
+ if not mpd_cli:
+ return False
+ track = Track(**mpd_cli.currentsong())
+ print('Black listing track: %s' % track)
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db.get_bl_track(track)
+
+ def purge_history(self):
+ """Purge all entries in history"""
+ db = simadb.SimaDB(db_path=self.dbfile)
+ print('Purging history...')
+ db.purge_history(duration=int(0))
+ print('done.')
+ print('Cleaning database...')
+ db.clean_database()
+ print('done.')
+
+ def view(self):
+ """Print out entries for an artist."""
+ art = self.options.view
+ db = simadb.SimaDB(db_path=self.dbfile)
+ art_db = self._get_art_from_db(art)
+ if not art_db: return
+ if not self._print_main_art():
+ mess = str('"%s" present in DB but not as a main artist' % art)
+ print(mess)
+ else: print('')
+ art_rev = list([])
+ [art_rev.append(a) for a in db._get_reverse_similar_artists_from_db(art_db[0])]
+ if not art_rev: return
+ mess = str('%s" appears as similar for the following artist(s): %s' %
+ (art,', '.join(art_rev)))
+ print(mess)
+ [self._print_main_art(a) for a in art_rev]
+
+ def view_all(self):
+ """Print out all entries."""
+ db = simadb.SimaDB(db_path=self.dbfile)
+ for art in db.get_artists():
+ if not art[0]: continue
+ self._print_main_art(art=art[0])
+
+ def view_bl(self):
+ """Print out black list."""
+ # TODO: enhance output formating
+ db = simadb.SimaDB(db_path=self.dbfile)
+ for bl_e in db.get_black_list():
+ print('\t# '.join([str(e) for e in bl_e]))
+
+ def remove_similarity(self):
+ """Remove entry"""
+ cli_sim = self.options.remove_sim
+ pattern = '^([^,]+?),([^,]+?,?)$'
+ regexp = re.compile(pattern, re.U).match(cli_sim)
+ if not regexp:
+ print('ERROR: similarity badly formated: "%s"' % cli_sim, file=stderr)
+ print('USAGE: A single relation between two artists is expected here.', file=stderr)
+ print('USAGE: "main artist,similar artist"', file=stderr)
+ exit(1)
+ arts = cli_sim.split(',')
+ if len(arts) != 2:
+ print('ERROR: unknown error in similarity format', file=stderr)
+ print('USAGE: "main artist,similar artist"', file=stderr)
+ exit(1)
+ art1_db = self._get_art_from_db(arts[0].strip())
+ art2_db = self._get_art_from_db(arts[1].strip())
+ if not art1_db or not art2_db: return
+ self._remove_sim(art1_db, art2_db)
+ if not self.options.reciprocal:
+ return
+ self._remove_sim(art2_db, art1_db)
+
+ def remove_artist(self):
+ """ Remove artist in the DB."""
+ deep = False
+ art = self.options.remove_art
+ db = simadb.SimaDB(db_path=self.dbfile)
+ art_db = self._get_art_from_db(art)
+ if not art_db: return False
+ print('Removing "%s" from database' % art)
+ if self.options.reciprocal:
+ print('reciprocal option used, performing deep remove!')
+ deep = True
+ db._remove_artist(art_db[0], deep=deep)
+
+ def remove_black_list_entry(self):
+ """"""
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db._remove_bl(int(self.options.remove_bl))
+
+ def write_simi(self):
+ """Write similarity to DB.
+ """
+ self._create_db()
+ sim_formated = self._parse_similarity()
+ print('About to update DB with: "%s": %s' % sim_formated)
+ db = simadb.SimaDB(db_path=self.dbfile)
+ db._update_similar_artists(*sim_formated)
+ if self.options.reciprocal:
+ print('...and with reciprocal combinations as well.')
+ for sim_formed_rec in self._revert_similarity(sim_formated):
+ db._update_similar_artists(*sim_formed_rec)
+
+ def main(self):
+ """
+ Parse command line and run actions.
+ """
+ self._declare_opts()
+ self.options = self.parser.parse_args()
+ self._get_mpd_env_var()
+ if self.options.dbfile:
+ self.dbfile = self.options.dbfile
+ print('Using db file: %s' % self.dbfile)
+ if self.options.reciprocal:
+ print('Editing reciprocal similarity')
+ if self.options.bl_art:
+ self.bl_artist()
+ return
+ if self.options.bl_curr_art:
+ self.bl_current_artist()
+ return
+ if self.options.bl_curr_alb:
+ self.bl_current_album()
+ return
+ if self.options.bl_curr_trk:
+ self.bl_current_track()
+ return
+ if self.options.view_bl:
+ self.view_bl()
+ return
+ if self.options.remove_bl:
+ self.remove_black_list_entry()
+ return
+ if self.options.similarity:
+ self._control_similarity()
+ self.write_simi()
+ return
+ if self.options.remove_art:
+ self.remove_artist()
+ return
+ if self.options.remove_sim:
+ self.remove_similarity()
+ return
+ if self.options.view:
+ self.view()
+ return
+ if self.options.view_all:
+ self.view_all()
+ if self.options.do_purge_hist:
+ self.purge_history()
+ exit(0)
+
+
+def main():
+ SimaDB_CLI()
+
+# Script starts here
+if __name__ == '__main__':
+ main()
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab