]> kaliko git repositories - mpd-sima.git/blob - simadb_cli
Fixed other inconsistencies
[mpd-sima.git] / simadb_cli
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 # Copyright (c) 2010-2013 Jack Kaliko <efrim@azylum.org>
5 #
6 #  This file is part of MPD_sima
7 #
8 #  MPD_sima is free software: you can redistribute it and/or modify
9 #  it under the terms of the GNU General Public License as published by
10 #  the Free Software Foundation, either version 3 of the License, or
11 #  (at your option) any later version.
12 #
13 #  MPD_sima is distributed in the hope that it will be useful,
14 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #  GNU General Public License for more details.
17 #
18 #  You should have received a copy of the GNU General Public License
19 #  along with MPD_sima.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 #
22
23 __version__ = '0.4.0'
24
25 # IMPORT#
26 import re
27
28 from argparse import (ArgumentParser, SUPPRESS, Action)
29 from difflib import get_close_matches
30 from locale import getpreferredencoding
31 from os import (environ, chmod, makedirs)
32 from os.path import (join, isdir, isfile, expanduser)
33 from sys import (exit, stdout, stderr)
34
35 from sima.lib.track import Track
36 from sima.utils import utils
37 from sima.lib import simadb
38 from musicpd import MPDClient, ConnectionError
39
40
41 DESCRIPTION = """
42 simadb_cli helps you to edit entries in your own DB of similarity
43 between artists."""
44 DB_NAME = 'sima.db'
45
46 class FooAction(Action):
47     def check(self, namespace):
48         if namespace.similarity: return True
49         if namespace.remove_art: return True
50         if namespace.remove_sim: return True
51
52     def __call__(self, parser, namespace, values, option_string=None):
53         opt_required = '"--remove_artist", "--remove_similarity" or "--add_similarity"'
54         if not self.check(namespace):
55             parser.error(
56                     'can\'t use {0} option before or without {1}'.format(
57                         option_string, opt_required))
58         setattr(namespace, self.dest, True)
59
60 # Options list
61 # pop out 'sw' value before creating ArgumentParser object.
62 OPTS = list([
63     {
64         'sw':['-a', '--add_similarity'],
65         'type': str,
66         'dest':'similarity',
67         'help': 'Similarity to add formated as follow: ' +
68         ' "art_0,art_1:90,art_2:80..."'},
69     {
70         'sw': ['-c', '--check_names'],
71         'action': 'store_true',
72         'default': False,
73         'help': 'Turn on controls of artists names in MPD library.'},
74     {
75         'sw':['-d', '--dbfile'],
76         'type': str,
77         'dest':'dbfile',
78         'action': utils.Wfile,
79         'help': 'File to read/write database from/to'},
80     {
81         'sw': ['-r', '--reciprocal'],
82         'default': False,
83         'nargs': 0, 
84         'action': FooAction,
85         'help': 'Turn on reciprocity for similarity relation when add/remove.'},
86     {
87         'sw':['--remove_artist'],
88         'type': str,
89         'dest': 'remove_art',
90         'metavar': '"ARTIST TO REMOVE"',
91         'help': 'Remove an artist from DB (main artist entries).'},
92     {
93         'sw':['--remove_similarity'],
94         'type': str,
95         'dest': 'remove_sim',
96         'metavar': '"MAIN ART,SIMI ART"',
97         'help': 'Remove an similarity relation from DB (main artist <=> similar artist).'},
98     {
99         'sw':['-v', '--view_artist'],
100         'type': str,
101         'dest':'view',
102         'metavar': '"ARTIST NAME"',
103         'help': 'View an artist from DB.'},
104     {
105         'sw':['--view_all'],
106         'action': 'store_true',
107         'help': 'View all similarity entries.'},
108     {
109         'sw': ['-S', '--host'],
110         'type': str,
111         'dest': 'mpdhost',
112         'default': None,
113         'help': 'MPD host, as IP or FQDN (default: localhost|MPD_HOST).'},
114     {
115         'sw': ['-P', '--port'],
116         'type': int,
117         'dest': 'mpdport',
118         'default': None,
119         'help': 'Port MPD in listening on (default: 6600|MPD_PORT).'},
120     {
121         'sw': ['--password'],
122         'type': str,
123         'dest': 'passwd',
124         'default': None,
125         'help': SUPPRESS},
126     {
127         'sw': ['--view_bl'],
128         'action': 'store_true',
129         'help': 'View black list.'},
130     {
131         'sw': ['--remove_bl'],
132         'type': int,
133         'help': 'Suppress a black list entry, by row id. Use --view_bl to get row id.'},
134     {
135         'sw': ['--bl_art'],
136         'type': str,
137         'metavar': 'ARTIST_NAME',
138         'help': 'Black list artist.'},
139     {
140         'sw': ['--bl_curr_art'],
141         'action': 'store_true',
142         'help': 'Black list currently playing artist.'},
143     {
144         'sw': ['--bl_curr_alb'],
145         'action': 'store_true',
146         'help': 'Black list currently playing album.'},
147     {
148         'sw': ['--bl_curr_trk'],
149         'action': 'store_true',
150         'help': 'Black list currently playing track.'},
151     {
152         'sw':['--purge_hist'],
153         'action': 'store_true',
154         'dest': 'do_purge_hist',
155         'help': 'Purge play history.'}])
156
157
158 class SimaDB_CLI(object):
159     """Command line management.
160     """
161
162     def __init__(self):
163         self.dbfile = self._get_default_dbfile()
164         self.parser = None
165         self.options = dict({})
166         self.localencoding = 'UTF-8'
167         self._get_encoding()
168         self._upgrade()
169         self.main()
170
171     def _get_encoding(self):
172         """Get local encoding"""
173         localencoding = getpreferredencoding()
174         if localencoding:
175             self.localencoding = localencoding
176
177     def _get_mpd_env_var(self):
178         """
179         MPD host/port environement variables are used if command line does not
180         provide host|port|passwd.
181         """
182         host, port, passwd = utils.get_mpd_environ()
183         if self.options.passwd is None and passwd:
184             self.options.passwd = passwd
185         if self.options.mpdhost is None:
186             if host:
187                 self.options.mpdhost = host
188             else:
189                 self.options.mpdhost = 'localhost'
190         if self.options.mpdport is None:
191             if port:
192                 self.options.mpdport = port
193             else:
194                 self.options.mpdport = 6600
195
196     def _upgrade(self):
197         """Upgrades DB if necessary, create one if not existing."""
198         if not isfile(self.dbfile): # No db file
199             return
200         db = simadb.SimaDB(db_path=self.dbfile)
201         db.upgrade()
202
203     def _declare_opts(self):
204         """
205         Declare options in ArgumentParser object.
206         """
207         self.parser = ArgumentParser(description=DESCRIPTION,
208                                    usage='%(prog)s [-h|--help] [options]',
209                                    prog='simadb_cli',
210                                    epilog='Happy Listening',
211                                    )
212
213         self.parser.add_argument('--version', action='version',
214                 version='%(prog)s {0}'.format(__version__))
215         # Add all options declare in OPTS
216         for opt in OPTS:
217             opt_names = opt.pop('sw')
218             self.parser.add_argument(*opt_names, **opt)
219
220     def _get_default_dbfile(self):
221         """
222         Use XDG directory standard if exists
223         else use "HOME/.local/share/mpd_sima/"
224         http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
225         """
226         homedir = expanduser('~')
227         dirname = 'mpd_sima'
228         if environ.get('XDG_DATA_HOME'):
229             data_dir = join(environ.get('XDG_DATA_HOME'), dirname)
230         else:
231             data_dir = join(homedir, '.local', 'share', dirname)
232         if not isdir(data_dir):
233             makedirs(data_dir)
234             chmod(data_dir, 0o700)
235         return join(data_dir, DB_NAME)
236
237     def _get_mpd_client(self):
238         """"""
239         # TODO: encode properly host name
240         host = self.options.mpdhost
241         port = self.options.mpdport
242         cli = MPDClient()
243         try:
244             cli.connect(host=host, port=port)
245         except ConnectionError as err:
246             mess = 'ERROR: fail to connect MPD (host: %s:%s): %s' % (
247                     host, port, err)
248             print(mess, file=stderr)
249             exit(1)
250         return cli
251
252     def _create_db(self):
253         """Create database if necessary"""
254         if isfile(self.dbfile):
255             return
256         print('Creating database!')
257         open(self.dbfile, 'a').close()
258         simadb.SimaDB(db_path=self.dbfile).create_db()
259
260     def _get_art_from_db(self, art):
261         """Return (id, name, self...) from DB or None is not in DB"""
262         db = simadb.SimaDB(db_path=self.dbfile)
263         art_db = db.get_artist(art, add_not=True)
264         if not art_db:
265             print('ERROR: "%s" not in data base!' % art, file=stderr)
266             return None
267         return art_db
268
269     def _control_similarity(self):
270         """
271          * Regex check of command line similarity
272          * Controls artist presence in MPD library
273         """
274         usage = ('USAGE: "main artist,similar artist:<match score>,other' +
275                 'similar artist:<match score>,..."')
276         cli_sim = self.options.similarity
277         pattern = '^([^,]+?),([^:,]+?:\d{1,2},?)+$'
278         regexp = re.compile(pattern, re.U).match(cli_sim)
279         if not regexp:
280             mess = 'ERROR: similarity badly formated: "%s"' % cli_sim
281             print(mess, file=stderr)
282             print(usage, file=stderr)
283             exit(1)
284         if self.options.check_names:
285             if not self._control_artist_names():
286                 mess = 'ERROR: some artist names not found in MPD library!'
287                 print(mess, file=stderr)
288                 exit(1)
289
290     def _control_artist_names(self):
291         """Controls artist names exist in MPD library"""
292         mpd_cli = self._get_mpd_client()
293         artists_list = mpd_cli.list('artist')
294         sim_formated = self._parse_similarity()
295         control = True
296         if sim_formated[0] not in artists_list:
297             mess = 'WARNING: Main artist not found in MPD: %s' % sim_formated[0]
298             print(mess)
299             control = False
300         for sart in sim_formated[1]:
301             art = sart.get('artist')
302             if art not in artists_list:
303                 mess = str('WARNING: Similar artist not found in MPD: %s' % art)
304                 print(mess)
305                 control = False
306         mpd_cli.disconnect()
307         return control
308
309     def _parse_similarity(self):
310         """Parse command line option similarity"""
311         cli_sim = self.options.similarity.strip(',').split(',')
312         sim = list([])
313         main = cli_sim[0]
314         for art in cli_sim[1:]:
315             artist = art.split(':')[0]
316             score = int(art.split(':')[1])
317             sim.append({'artist': artist, 'score': score})
318         return (main, sim)
319
320     def _print_main_art(self, art=None):
321         """Print entries, art as main artist."""
322         if not art:
323             art = self.options.view
324         db = simadb.SimaDB(db_path=self.dbfile)
325         art_db = self._get_art_from_db(art)
326         if not art_db: return
327         sims = list([])
328         [sims.append(a) for a in db._get_similar_artists_from_db(art_db[0])]
329         if len(sims) == 0:
330             return False
331         print('"%s" similarities:' % art)
332         for art in sims:
333             mess = str('  - {score:0>2d} {artist}'.format(**art))
334             print(mess)
335         return True
336
337     def _remove_sim(self, art1_db, art2_db):
338         """Remove single similarity between two artists."""
339         db = simadb.SimaDB(db_path=self.dbfile)
340         similarity = db._get_artist_match(art1_db[0], art2_db[0])
341         if similarity == 0:
342             return False
343         db._remove_relation_between_2_artist(art1_db[0], art2_db[0])
344         mess = 'Remove: "{0}" "{1}:{2:0>2d}"'.format(art1_db[1], art2_db[1],
345                                                      similarity)
346         print(mess)
347         return True
348
349     def _revert_similarity(self, sim_formated):
350         """Revert similarity string (for reciprocal editing - add)."""
351         main_art = sim_formated[0]
352         similars = sim_formated[1]
353         for similar in similars:
354             yield (similar.get('artist'),
355                 [{'artist':main_art, 'score':similar.get('score')}])
356
357     def bl_artist(self):
358         """Black list artist"""
359         mpd_cli = self._get_mpd_client()
360         if not mpd_cli:
361             return False
362         artists_list = mpd_cli.list('artist')
363         # Unicode cli given artist name
364         cli_artist_to_bl = self.options.bl_art
365         if cli_artist_to_bl not in artists_list:
366             print('Artist not found in MPD library.')
367             match = get_close_matches(cli_artist_to_bl, artists_list, 50, 0.78)
368             if match:
369                 print('You may be refering to %s' %
370                         '/'.join([m_a for m_a in match]))
371             return False
372         print('Black listing artist: %s' % cli_artist_to_bl)
373         db = simadb.SimaDB(db_path=self.dbfile)
374         db.get_bl_artist(cli_artist_to_bl)
375
376     def bl_current_artist(self):
377         """Black list current artist"""
378         mpd_cli = self._get_mpd_client()
379         if not mpd_cli:
380             return False
381         artist = mpd_cli.currentsong().get('artist', '')
382         if not artist:
383             print('No artist found.')
384             return False
385         print('Black listing artist: %s' % artist)
386         db = simadb.SimaDB(db_path=self.dbfile)
387         db.get_bl_artist(artist)
388
389     def bl_current_album(self):
390         """Black list current artist"""
391         mpd_cli = self._get_mpd_client()
392         if not mpd_cli:
393             return False
394         track = Track(**mpd_cli.currentsong())
395         if not track.album:
396             print('No album set for this track: %s' % track)
397             return False
398         print('Black listing album: {0}'.format(track.album))
399         db = simadb.SimaDB(db_path=self.dbfile)
400         db.get_bl_album(track)
401
402     def bl_current_track(self):
403         """Black list current artist"""
404         mpd_cli = self._get_mpd_client()
405         if not mpd_cli:
406             return False
407         track = Track(**mpd_cli.currentsong())
408         print('Black listing track: %s' % track)
409         db = simadb.SimaDB(db_path=self.dbfile)
410         db.get_bl_track(track)
411
412     def purge_history(self):
413         """Purge all entries in history"""
414         db = simadb.SimaDB(db_path=self.dbfile)
415         print('Purging history...')
416         db.purge_history(duration=int(0))
417         print('done.')
418         print('Cleaning database...')
419         db.clean_database()
420         print('done.')
421
422     def view(self):
423         """Print out entries for an artist."""
424         art = self.options.view
425         db = simadb.SimaDB(db_path=self.dbfile)
426         art_db = self._get_art_from_db(art)
427         if not art_db: return
428         if not self._print_main_art():
429             mess = str('"%s" present in DB but not as a main artist' % art)
430             print(mess)
431         else: print('')
432         art_rev = list([])
433         [art_rev.append(a) for a in db._get_reverse_similar_artists_from_db(art_db[0])]
434         if not art_rev: return
435         mess = str('%s" appears as similar for the following artist(s): %s' %
436                 (art,', '.join(art_rev)))
437         print(mess)
438         [self._print_main_art(a) for a in art_rev]
439
440     def view_all(self):
441         """Print out all entries."""
442         db = simadb.SimaDB(db_path=self.dbfile)
443         for art in db.get_artists():
444             if not art[0]: continue
445             self._print_main_art(art=art[0])
446
447     def view_bl(self):
448         """Print out black list."""
449         # TODO: enhance output formating
450         db = simadb.SimaDB(db_path=self.dbfile)
451         for bl_e in db.get_black_list():
452             print('\t# '.join([str(e) for e in bl_e]))
453
454     def remove_similarity(self):
455         """Remove entry"""
456         cli_sim = self.options.remove_sim
457         pattern = '^([^,]+?),([^,]+?,?)$'
458         regexp = re.compile(pattern, re.U).match(cli_sim)
459         if not regexp:
460             print('ERROR: similarity badly formated: "%s"' % cli_sim, file=stderr)
461             print('USAGE: A single relation between two artists is expected here.', file=stderr)
462             print('USAGE: "main artist,similar artist"', file=stderr)
463             exit(1)
464         arts = cli_sim.split(',')
465         if len(arts) != 2:
466             print('ERROR: unknown error in similarity format', file=stderr)
467             print('USAGE: "main artist,similar artist"', file=stderr)
468             exit(1)
469         art1_db = self._get_art_from_db(arts[0].strip())
470         art2_db = self._get_art_from_db(arts[1].strip())
471         if not art1_db or not art2_db: return
472         self._remove_sim(art1_db, art2_db)
473         if not self.options.reciprocal:
474             return
475         self._remove_sim(art2_db, art1_db)
476
477     def remove_artist(self):
478         """ Remove artist in the DB."""
479         deep = False
480         art = self.options.remove_art
481         db = simadb.SimaDB(db_path=self.dbfile)
482         art_db = self._get_art_from_db(art)
483         if not art_db: return False
484         print('Removing "%s" from database' % art)
485         if self.options.reciprocal:
486             print('reciprocal option used, performing deep remove!')
487             deep = True
488         db._remove_artist(art_db[0], deep=deep)
489
490     def remove_black_list_entry(self):
491         """"""
492         db = simadb.SimaDB(db_path=self.dbfile)
493         db._remove_bl(int(self.options.remove_bl))
494
495     def write_simi(self):
496         """Write similarity to DB.
497         """
498         self._create_db()
499         sim_formated = self._parse_similarity()
500         print('About to update DB with: "%s": %s' % sim_formated)
501         db = simadb.SimaDB(db_path=self.dbfile)
502         db._update_similar_artists(*sim_formated)
503         if self.options.reciprocal:
504             print('...and with reciprocal combinations as well.')
505             for sim_formed_rec in self._revert_similarity(sim_formated):
506                 db._update_similar_artists(*sim_formed_rec)
507
508     def main(self):
509         """
510         Parse command line and run actions.
511         """
512         self._declare_opts()
513         self.options = self.parser.parse_args()
514         self._get_mpd_env_var()
515         if self.options.dbfile:
516             self.dbfile = self.options.dbfile
517             print('Using db file: %s' % self.dbfile)
518         if self.options.reciprocal:
519             print('Editing reciprocal similarity')
520         if self.options.bl_art:
521             self.bl_artist()
522             return
523         if self.options.bl_curr_art:
524             self.bl_current_artist()
525             return
526         if self.options.bl_curr_alb:
527             self.bl_current_album()
528             return
529         if self.options.bl_curr_trk:
530             self.bl_current_track()
531             return
532         if self.options.view_bl:
533             self.view_bl()
534             return
535         if self.options.remove_bl:
536             self.remove_black_list_entry()
537             return
538         if self.options.similarity:
539             self._control_similarity()
540             self.write_simi()
541             return
542         if self.options.remove_art:
543             self.remove_artist()
544             return
545         if self.options.remove_sim:
546             self.remove_similarity()
547             return
548         if self.options.view:
549             self.view()
550             return
551         if self.options.view_all:
552             self.view_all()
553         if self.options.do_purge_hist:
554             self.purge_history()
555         exit(0)
556
557
558 def main():
559     SimaDB_CLI()
560
561 # Script starts here
562 if __name__ == '__main__':
563     main()
564
565 # VIM MODLINE
566 # vim: ai ts=4 sw=4 sts=4 expandtab