From 200a98d4df9f07365dcdf5087d1236ec3cb3c058 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Wed, 29 Feb 2012 18:55:44 -0500 Subject: [PATCH 01/29] add format option to list_items command you can provide a custom format option to the list command. Example: $ beet ls -f '$title - $album' --- beets/ui/commands.py | 16 +++++++++++++-- beets/util/functemplate.py | 3 ++- test/test_ui.py | 40 ++++++++++++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index c4b55bfed..0e5855136 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -30,6 +30,7 @@ import beets.autotag.art from beets import plugins from beets import importer from beets.util import syspath, normpath, ancestry, displayable_path +from beets.util.functemplate import Template from beets import library # Global logger. @@ -743,7 +744,7 @@ default_commands.append(import_cmd) # list: Query and show library contents. -def list_items(lib, query, album, path): +def list_items(lib, query, album, path, format): """Print out items in lib matching query. If album, then search for albums instead of single items. If path, print the matched objects' paths instead of human-readable information about them. @@ -752,12 +753,21 @@ def list_items(lib, query, album, path): for album in lib.albums(query): if path: print_(album.item_dir()) + elif format is not None: + template = Template(format) + out = template.substitute(album._record) + print_(out) else: print_(album.albumartist + u' - ' + album.album) else: for item in lib.items(query): if path: print_(item.path) + elif format is not None: + template = Template(format) + out = template.substitute(item.record) + print_(out) + else: print_(item.artist + u' - ' + item.album + u' - ' + item.title) @@ -766,8 +776,10 @@ list_cmd.parser.add_option('-a', '--album', action='store_true', help='show matching albums instead of tracks') list_cmd.parser.add_option('-p', '--path', action='store_true', help='print paths for matched items or albums') +list_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format (WIP)') def list_func(lib, config, opts, args): - list_items(lib, decargs(args), opts.album, opts.path) + list_items(lib, decargs(args), opts.album, opts.path, opts.format) list_cmd.func = list_func default_commands.append(list_cmd) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 1c2384a4c..3f7c088ee 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -109,7 +109,8 @@ class Expression(object): out.append(part) else: out.append(part.evaluate(env)) - return u''.join(out) + #return u''.join(out) + return u''.join([unicode(i) for i in out]) class ParseError(Exception): pass diff --git a/test/test_ui.py b/test/test_ui.py index 6130fbd9b..a14145330 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -47,7 +47,7 @@ class ListTest(unittest.TestCase): self.io.restore() def test_list_outputs_item(self): - commands.list_items(self.lib, '', False, False) + commands.list_items(self.lib, '', False, False, None) out = self.io.getoutput() self.assertTrue(u'the title' in out) @@ -56,42 +56,66 @@ class ListTest(unittest.TestCase): self.lib.store(self.item) self.lib.save() - commands.list_items(self.lib, [u'na\xefve'], False, False) + commands.list_items(self.lib, [u'na\xefve'], False, False, None) out = self.io.getoutput() self.assertTrue(u'na\xefve' in out.decode(self.io.stdout.encoding)) def test_list_item_path(self): - commands.list_items(self.lib, '', False, True) + commands.list_items(self.lib, '', False, True, None) out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx/yyy') def test_list_album_outputs_something(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertGreater(len(out), 0) def test_list_album_path(self): - commands.list_items(self.lib, '', True, True) + commands.list_items(self.lib, '', True, True, None) out = self.io.getoutput() self.assertEqual(out.strip(), u'xxx') def test_list_album_omits_title(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertTrue(u'the title' not in out) def test_list_uses_track_artist(self): - commands.list_items(self.lib, '', False, False) + commands.list_items(self.lib, '', False, False, None) out = self.io.getoutput() self.assertTrue(u'the artist' in out) self.assertTrue(u'the album artist' not in out) def test_list_album_uses_album_artist(self): - commands.list_items(self.lib, '', True, False) + commands.list_items(self.lib, '', True, False, None) out = self.io.getoutput() self.assertTrue(u'the artist' not in out) self.assertTrue(u'the album artist' in out) + def test_list_item_format_artist(self): + commands.list_items(self.lib, '', False, False, '$artist') + out = self.io.getoutput() + self.assertTrue(u'the artist' in out) + + def test_list_item_format_multiple(self): + commands.list_items(self.lib, '', False, False, '$artist - $album - $year') + out = self.io.getoutput() + self.assertTrue(u'1' in out) + self.assertTrue(u'the album' in out) + self.assertTrue(u'the artist' in out) + self.assertEqual(u'the artist - the album - 1', out.strip()) + + def test_list_album_format(self): + commands.list_items(self.lib, '', True, False, '$genre') + out = self.io.getoutput() + self.assertTrue(u'the genre' in out) + self.assertTrue(u'the album' not in out) + + def test_list_item_path_ignores_format(self): + commands.list_items(self.lib, '', False, True, '$year - $artist') + out = self.io.getoutput() + self.assertEqual(out.strip(), u'xxx/yyy') + class RemoveTest(unittest.TestCase): def setUp(self): self.io = _common.DummyIO() From e05bda87b4ac9f039fedfae84b0d8d72b025236f Mon Sep 17 00:00:00 2001 From: Karl Southern Date: Wed, 14 Mar 2012 14:17:41 -0700 Subject: [PATCH 02/29] Fixes up colours on Windows by using colorama. ANSICON, and other solutions are not reliable. --- beets/ui/__init__.py | 3 +++ 1 files changed, 3 insertions(+), 0 deletions(-) --- beets/ui/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 3503842cb..cc0dc6bcb 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -32,6 +32,9 @@ from beets import library from beets import plugins from beets import util +import colorama +colorama.init() + # Constants. CONFIG_PATH_VAR = 'BEETSCONFIG' DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig' From e6999df2d915dda512792900fbece143ead615a5 Mon Sep 17 00:00:00 2001 From: Karl Southern Date: Wed, 14 Mar 2012 14:17:41 -0700 Subject: [PATCH 03/29] Ammends beets to only import and requires colorama on win32 --- beets/ui/__init__.py | 5 +++-- setup.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) --- beets/ui/__init__.py | 5 +++-- setup.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index cc0dc6bcb..1cf4bf9ec 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -32,8 +32,9 @@ from beets import library from beets import plugins from beets import util -import colorama -colorama.init() +if sys.platform == 'win32': + import colorama + colorama.init() # Constants. CONFIG_PATH_VAR = 'BEETSCONFIG' diff --git a/setup.py b/setup.py index e3f7a51a9..d047aeb47 100755 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ setup(name='beets', 'munkres', 'unidecode', 'musicbrainzngs', - ], + ] + (['colorama'] if (sys.platform == 'win32') else []), classifiers=[ 'Topic :: Multimedia :: Sound/Audio', From 194f224687c7a0ef8de37390624fe2ea1524356d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 14 Mar 2012 14:23:31 -0700 Subject: [PATCH 04/29] changelog about colorama (#21 on GitHub) --- docs/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 5263c0d85..2fb4a51b8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,7 @@ lays the foundation for more features to come in the next couple of releases. albums. * The autotagger now also tolerates tracks whose track artists tags are set to "Various Artists". +* Terminal colors are now supported on Windows via `Colorama`_ (thanks to Karl). * When previewing metadata differences, the importer now shows discrepancies in track length. * Importing with ``import_delete`` enabled now cleans up empty directories that @@ -64,6 +65,8 @@ lays the foundation for more features to come in the next couple of releases. data. * Fix the ``list`` command in BPD (thanks to Simon Chopin). +.. _Colorama: http://pypi.python.org/pypi/colorama + 1.0b12 (January 16, 2012) ------------------------- From 1b1b8c4ae32223c4ff4d16751458dc8e1601b747 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 14 Mar 2012 14:51:13 -0700 Subject: [PATCH 05/29] cleanup/docs for list formatting (#203; #22 on GH) --- beets/ui/commands.py | 29 ++++++++++++++--------------- beets/util/functemplate.py | 3 +-- docs/changelog.rst | 4 ++++ docs/reference/cli.rst | 12 +++++++++--- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 0e5855136..769966fab 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -744,32 +744,31 @@ default_commands.append(import_cmd) # list: Query and show library contents. -def list_items(lib, query, album, path, format): +def list_items(lib, query, album, path, fmt): """Print out items in lib matching query. If album, then search for albums instead of single items. If path, print the matched objects' paths instead of human-readable information about them. """ + if fmt is None: + # If no specific template is supplied, use a default. + if album: + fmt = u'$albumartist - $album' + else: + fmt = u'$artist - $album - $title' + template = Template(fmt) + if album: for album in lib.albums(query): if path: print_(album.item_dir()) - elif format is not None: - template = Template(format) - out = template.substitute(album._record) - print_(out) - else: - print_(album.albumartist + u' - ' + album.album) + elif fmt is not None: + print_(template.substitute(album._record)) else: for item in lib.items(query): if path: print_(item.path) - elif format is not None: - template = Template(format) - out = template.substitute(item.record) - print_(out) - - else: - print_(item.artist + u' - ' + item.album + u' - ' + item.title) + elif fmt is not None: + print_(template.substitute(item.record)) list_cmd = ui.Subcommand('list', help='query the library', aliases=('ls',)) list_cmd.parser.add_option('-a', '--album', action='store_true', @@ -777,7 +776,7 @@ list_cmd.parser.add_option('-a', '--album', action='store_true', list_cmd.parser.add_option('-p', '--path', action='store_true', help='print paths for matched items or albums') list_cmd.parser.add_option('-f', '--format', action='store', - help='print with custom format (WIP)') + help='print with custom format', default=None) def list_func(lib, config, opts, args): list_items(lib, decargs(args), opts.album, opts.path, opts.format) list_cmd.func = list_func diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 3f7c088ee..5d6921799 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -109,8 +109,7 @@ class Expression(object): out.append(part) else: out.append(part.evaluate(env)) - #return u''.join(out) - return u''.join([unicode(i) for i in out]) + return u''.join(map(unicode, out)) class ParseError(Exception): pass diff --git a/docs/changelog.rst b/docs/changelog.rst index 2fb4a51b8..74ebb92ef 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -16,6 +16,10 @@ lays the foundation for more features to come in the next couple of releases. * Items now expose their audio **sample rate**, number of **channels**, and **bits per sample** (bitdepth). See :doc:`/reference/pathformat` for a list of all available audio properties. Thanks to Andrew Dunn. +* The ``beet list`` command now accepts a "format" argument that lets you **show + specific information about each album or track**. For example, run ``beet ls + -af '$album: $tracktotal' beatles`` to see how long each Beatles album is. + Thanks to Philippe Mongeau. * The autotagger now tolerates tracks on multi-disc albums that are numbered per-disc. For example, if track 24 on a release is the first track on the second disc, then it is not penalized for having its track number set to 1 diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 890a8b793..c9a7b7d71 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -131,9 +131,15 @@ Want to search for "Gronlandic Edit" by of Montreal? Try ``beet list gronlandic``. Maybe you want to see everything released in 2009 with "vegetables" in the title? Try ``beet list year:2009 title:vegetables``. (Read more in :doc:`query`.) You can use the ``-a`` switch to search for -albums instead of individual items. The ``-p`` option makes beets print out -filenames of matched items, which might be useful for piping into other Unix -commands (such as `xargs`_). +albums instead of individual items. + +The ``-p`` option makes beets print out filenames of matched items, which might +be useful for piping into other Unix commands (such as `xargs`_). Similarly, the +``-f`` option lets you specify a specific format with which to print every album +or track. This uses the same template syntax as beets' :doc:`path formats +`. For example, the command ``beet ls -af '$album: $tracktotal' +beatles`` prints out the number of tracks on each Beatles album. Remember to +enclose the template argument in single quotes to avoid shell expansion. .. _xargs: http://en.wikipedia.org/wiki/Xargs From d461e7675ece2dcc82833561b49d07f2cbe9426d Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 15 Mar 2012 08:00:58 -0700 Subject: [PATCH 06/29] catch Mutagen exceptions when reading fields (#356) --- beets/mediafile.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/beets/mediafile.py b/beets/mediafile.py index 15b9912af..47e682331 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -317,7 +317,13 @@ class MediaField(object): # possibly index the list if style.list_elem: if entry: # List must have at least one value. - return entry[0] + # Handle Mutagen bugs when reading values (#356). + try: + return entry[0] + except: + log.error('Mutagen exception when reading field: %s' % + traceback.format_exc) + return None else: return None else: From b49beac9bbbb29e200205eeee1a21032b0cb7ce9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 16 Mar 2012 09:55:30 -0700 Subject: [PATCH 07/29] add release date to changelog --- docs/changelog.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 74ebb92ef..ea6a06292 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,15 +1,16 @@ Changelog ========= -1.0b13 (in development) +1.0b13 (March 16, 2012) ----------------------- Beets 1.0b13 consists of a plethora of small but important fixes and refinements. A lyrics plugin is now included with beets; new audio properties -are catalogged; the autotagger is more tolerant of different tagging styles; and -importing with original file deletion now cleans up after itself more -thoroughly. Many, many bugs—including several crashers—were fixed. This release -lays the foundation for more features to come in the next couple of releases. +are catalogged; the ``list`` command has been made more powerful; the autotagger +is more tolerant of different tagging styles; and importing with original file +deletion now cleans up after itself more thoroughly. Many, many bugs—including +several crashers—were fixed. This release lays the foundation for more features +to come in the next couple of releases. * The :doc:`/plugins/lyrics`, originally by `Peter Brunner`_, is revamped and included with beets, making it easy to fetch **song lyrics**. From 5ddb9ebc950ee1810c369dba9b1d14db2644a3d8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 16 Mar 2012 09:58:48 -0700 Subject: [PATCH 08/29] Added tag 1.0b13 for changeset b6c10981014a --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 0eda06449..57da60f91 100644 --- a/.hgtags +++ b/.hgtags @@ -10,3 +10,4 @@ a256ec5b0b2de500305fd6656db0a195df273acc 1.0b9 88807657483a916200296165933529da9a682528 1.0b10 4ca1475821742002962df439f71f51d67640b91e 1.0b11 284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12 +b6c10981014a5b3a963460fca3b31cc62bf7ed2c 1.0b13 From f670fc87abb331e8b7fafa7007abb645020e36d4 Mon Sep 17 00:00:00 2001 From: kraymer Date: Sat, 17 Mar 2012 12:12:12 +0100 Subject: [PATCH 09/29] add 'm3uupdate' plugin --- beetsplug/m3uupdate.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 beetsplug/m3uupdate.py diff --git a/beetsplug/m3uupdate.py b/beetsplug/m3uupdate.py new file mode 100644 index 000000000..a0192cba2 --- /dev/null +++ b/beetsplug/m3uupdate.py @@ -0,0 +1,52 @@ +# This file is part of beets. +# Copyright 2012, Adrian Sampson. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Write paths of imported files in a m3u file to ease later import in a music +player. +""" + +from __future__ import with_statement +import os + +from beets import ui +from beets.plugins import BeetsPlugin +from beets.util import normpath + +class m3uPlugin(BeetsPlugin): + def configure(self, config): + global M3U_FILENAME + M3U_FILENAME = ui.config_val(config, 'm3uupdate', 'm3u', None) + + if not M3U_FILENAME: + M3U_FILENAME = os.path.join( + ui.config_val(config, 'beets', 'directory', '.'), + 'imported.m3u') + M3U_FILENAME = normpath(M3U_FILENAME) + m3u_dir = os.path.dirname(M3U_FILENAME) + if not os.path.exists(m3u_dir): + os.makedirs(m3u_dir) + +@m3uPlugin.listen('album_imported') +def album_imported(lib, album, config): + with open(M3U_FILENAME, 'a') as f: + for item in album.items(): + f.write(os.path.relpath(item.path, + os.path.dirname(M3U_FILENAME)) + '\n') + +@m3uPlugin.listen('item_imported') +def item_imported(lib, item, config): + with open(M3U_FILENAME, 'a') as f: + f.write(os.path.relpath(item.path, + os.path.dirname(M3U_FILENAME)) + '\n') + From aa36fa288c18ee485f91d6c7d823199b7a68c5aa Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 17 Mar 2012 12:03:28 -0700 Subject: [PATCH 10/29] version bump: b14 --- beets/__init__.py | 2 +- docs/changelog.rst | 4 ++++ docs/conf.py | 4 ++-- setup.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/beets/__init__.py b/beets/__init__.py index 06e92b361..a0a74f3a8 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -__version__ = '1.0b13' +__version__ = '1.0b14' __author__ = 'Adrian Sampson ' import beets.library diff --git a/docs/changelog.rst b/docs/changelog.rst index ea6a06292..9a2bd0ba8 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,10 @@ Changelog ========= +1.0b14 (in development) +----------------------- + + 1.0b13 (March 16, 2012) ----------------------- diff --git a/docs/conf.py b/docs/conf.py index 19380812e..65d81f4e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,8 @@ master_doc = 'index' project = u'beets' copyright = u'2011, Adrian Sampson' -version = '1.0b13' -release = '1.0b13' +version = '1.0b14' +release = '1.0b14' pygments_style = 'sphinx' diff --git a/setup.py b/setup.py index d047aeb47..a4e3dcfb7 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ if 'sdist' in sys.argv: shutil.copytree(os.path.join(docdir, '_build', 'man'), mandir) setup(name='beets', - version='1.0b13', + version='1.0b14', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', From c0c80cd576fb33323553e890c937df5a9badc455 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sat, 17 Mar 2012 12:28:27 -0700 Subject: [PATCH 11/29] docs typo: missing $ in path formats --- docs/reference/config.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 1ce5c443e..73feda105 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -161,7 +161,7 @@ artist, and ``singleton`` for non-album tracks. The defaults look like this:: [paths] default: $albumartist/$album/$track $title singleton: Non-Album/$artist/$title - comp: Compilations/$album/$track title + comp: Compilations/$album/$track $title Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums will be well-organized. For more about these format strings, see @@ -174,7 +174,7 @@ template string, the ``_`` character is substituted for ``:`` in these queries. This means that a config file like this:: [paths] - albumtype_soundtrack: Soundtracks/$album/$track title + albumtype_soundtrack: Soundtracks/$album/$track $title will place soundtrack albums in a separate directory. The queries are tested in the order they appear in the configuration file, meaning that if an item matches From fca346c5d7a45ed55c35d64b756b0a7df834ffd9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 18 Mar 2012 15:02:24 -0700 Subject: [PATCH 12/29] add Travis CI configuration --- .travis.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..c7ff4f280 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: python +python: + - "2.5" + - "2.6" + - "2.7" +install: + - pip install . --use-mirrors +script: nosetests From 99e0edd3a1e6974ec748d4f36b836eca31feb2a9 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 18 Mar 2012 15:13:08 -0700 Subject: [PATCH 13/29] Travis CI: only 2.7 until tests fixed; plugin deps --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c7ff4f280..5193a680d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ language: python python: - - "2.5" - - "2.6" - "2.7" install: - pip install . --use-mirrors + - pip install pylast flask --use-mirrors script: nosetests From af0da7d1b69e66f5b21b4a78c7508e4bff495ee3 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 18 Mar 2012 16:42:12 -0700 Subject: [PATCH 14/29] m3uupdate docs, changelog, and cleanup (#23) --- beetsplug/m3uupdate.py | 65 ++++++++++++++++++++++++-------------- docs/changelog.rst | 2 ++ docs/plugins/index.rst | 1 + docs/plugins/m3uupdate.rst | 20 ++++++++++++ 4 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 docs/plugins/m3uupdate.rst diff --git a/beetsplug/m3uupdate.py b/beetsplug/m3uupdate.py index a0192cba2..32740ef84 100644 --- a/beetsplug/m3uupdate.py +++ b/beetsplug/m3uupdate.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2012, Adrian Sampson. +# Copyright 2012, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -12,41 +12,60 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Write paths of imported files in a m3u file to ease later import in a music -player. +"""Write paths of imported files in a m3u file to ease later import in a +music player. """ -from __future__ import with_statement +from __future__ import with_statement import os from beets import ui from beets.plugins import BeetsPlugin from beets.util import normpath +DEFAULT_FILENAME = 'imported.m3u' +_m3u_path = None # If unspecified, use file in library directory. + class m3uPlugin(BeetsPlugin): def configure(self, config): - global M3U_FILENAME - M3U_FILENAME = ui.config_val(config, 'm3uupdate', 'm3u', None) + global _m3u_path + _m3u_path = ui.config_val(config, 'm3uupdate', 'm3u', None) + if _m3u_path: + _m3u_path = normpath(_m3u_path) - if not M3U_FILENAME: - M3U_FILENAME = os.path.join( - ui.config_val(config, 'beets', 'directory', '.'), - 'imported.m3u') - M3U_FILENAME = normpath(M3U_FILENAME) - m3u_dir = os.path.dirname(M3U_FILENAME) - if not os.path.exists(m3u_dir): - os.makedirs(m3u_dir) +def _get_m3u_path(lib): + """Given a Library object, return the path to the M3U file to be + used (either in the library directory or an explicitly configured + path. Ensures that the containing directory exists. + """ + if _m3u_path: + # Explicitly specified. + path = _m3u_path + else: + # Inside library directory. + path = os.path.join(lib.directory, DEFAULT_FILENAME) + + # Ensure containing directory exists. + m3u_dir = os.path.dirname(path) + if not os.path.exists(m3u_dir): + os.makedirs(m3u_dir) + + return path + +def _record_items(lib, items): + """Records relative paths to the given items in the appropriate M3U + file. + """ + m3u_path = _get_m3u_path(lib) + with open(m3u_path, 'a') as f: + for item in items: + path = os.path.relpath(item.path, os.path.dirname(m3u_path)) + f.write(path + '\n') @m3uPlugin.listen('album_imported') -def album_imported(lib, album, config): - with open(M3U_FILENAME, 'a') as f: - for item in album.items(): - f.write(os.path.relpath(item.path, - os.path.dirname(M3U_FILENAME)) + '\n') +def album_imported(lib, album, config): + _record_items(lib, album.items()) @m3uPlugin.listen('item_imported') def item_imported(lib, item, config): - with open(M3U_FILENAME, 'a') as f: - f.write(os.path.relpath(item.path, - os.path.dirname(M3U_FILENAME)) + '\n') - + _record_items(lib, [item]) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a2bd0ba8..4589adbbe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,8 @@ Changelog 1.0b14 (in development) ----------------------- +* New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist + file for easy importing to other systems. Thanks to Fabrice Laporte. 1.0b13 (March 16, 2012) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index ecd5c5eff..2d36bd468 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -46,6 +46,7 @@ disabled by default, but you can turn them on as described above: inline scrub rewrite + m3uupdate .. _other-plugins: diff --git a/docs/plugins/m3uupdate.rst b/docs/plugins/m3uupdate.rst new file mode 100644 index 000000000..6d5730ec2 --- /dev/null +++ b/docs/plugins/m3uupdate.rst @@ -0,0 +1,20 @@ +m3uUpdate Plugin +================ + +The ``m3uupdate`` plugin keeps track of newly imported music in a central +``.m3u`` playlist file. This file can be used to add new music to other players, +such as iTunes. + +To use the plugin, just put ``m3uupdate`` on the ``plugins`` line in your +:doc:`/reference/config`:: + + [beets] + plugins: m3uupdate + +Every time an album or singleton item is imported, new paths will be written to +the playlist file. By default, the plugin uses a file called ``imported.m3u`` +inside your beets library directory. To use a different file, just set the +``m3u`` parameter inside the ``m3uupdate`` config section, like so:: + + [m3uupdate] + m3u: ~/music.m3u From 6bfbb899bc486a2d7992d644b0295a7168ac559c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 18 Mar 2012 16:58:02 -0700 Subject: [PATCH 15/29] docs: more readable list of included plugins --- docs/plugins/index.rst | 43 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 2d36bd468..f0736b177 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -30,10 +30,10 @@ Plugins Included With Beets --------------------------- There are a few plugins that are included with the beets distribution. They're -disabled by default, but you can turn them on as described above: +disabled by default, but you can turn them on as described above. .. toctree:: - :maxdepth: 1 + :hidden: chroma lyrics @@ -48,6 +48,45 @@ disabled by default, but you can turn them on as described above: rewrite m3uupdate +Autotagger Extensions +'''''''''''''''''''''' + +* :doc:`chroma`: Use acoustic fingerprinting to identify audio files with + missing or incorrect metadata. + +Metadata +'''''''' + +* :doc:`lyrics`: Automatically fetch song lyrics. +* :doc:`lastgenre`: Fetch genres based on Last.fm tags. +* :doc:`embedart`: Embed album art images into files' metadata. (By default, + beets uses image files "on the side" instead of embedding images.) +* :doc:`replaygain`: Calculate volume normalization for players that support it. +* :doc:`scrub`: Clean extraneous metadata from music files. + +Path Formats +'''''''''''' + +* :doc:`inline`: Use Python snippets to customize path format strings. +* :doc:`rewrite`: Substitute values in path formats. + +Interoperability +'''''''''''''''' + +* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library + changes. +* :doc:`m3uupdate`: Catalog imported files in an ``.m3u`` playlist file. + +Miscellaneous +''''''''''''' + +* :doc:`web`: An experimental Web-based GUI for beets. +* :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is + compatible with `MPD clients`_. + +.. _MPD: http://mpd.wikia.com/ +.. _MPD clients: http://mpd.wikia.com/wiki/Clients + .. _other-plugins: Other Plugins From 19b08f8e9958d2827409abdaef97162dd68791a1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 19 Mar 2012 15:32:53 -0700 Subject: [PATCH 16/29] duplicate resolution callback function (#164) --- beets/importer.py | 9 ++++----- beets/ui/commands.py | 9 +++++++++ test/_common.py | 1 + test/test_importer.py | 1 + 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/beets/importer.py b/beets/importer.py index f8d4794c4..82f60da4b 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -275,7 +275,8 @@ class ImportConfig(object): 'quiet_fallback', 'copy', 'write', 'art', 'delete', 'choose_match_func', 'should_resume_func', 'threaded', 'autot', 'singletons', 'timid', 'choose_item_func', - 'query', 'incremental', 'ignore'] + 'query', 'incremental', 'ignore', + 'resolve_duplicate_func'] def __init__(self, **kwargs): for slot in self._fields: setattr(self, slot, kwargs[slot]) @@ -577,8 +578,7 @@ def user_query(config): # Check for duplicates if we have a match (or ASIS). if _duplicate_check(lib, task, recent): tag_log(config.logfile, 'duplicate', task.path) - log.warn("This album is already in the library!") - task.set_choice(action.SKIP) + config.resolve_duplicate_func(task, config) def show_progress(config): """This stage replaces the initial_lookup and user_query stages @@ -777,8 +777,7 @@ def item_query(config): # Duplicate check. if _item_duplicate_check(lib, task, recent): tag_log(config.logfile, 'duplicate', task.item.path) - log.warn("This item is already in the library!") - task.set_choice(action.SKIP) + config.resolve_duplicate_func(task, config) def item_progress(config): """Skips the lookup and query stages in a non-autotagged singleton diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 769966fab..9f77b5826 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -564,6 +564,14 @@ def choose_item(task, config): assert not isinstance(choice, importer.action) return choice +def resolve_duplicate(task, config): + """Decide what to do when a new album or item seems similar to one + that's already in the library. + """ + log.warn("This %s is already in the library!" % + ("album" if task.is_album else "item")) + task.set_choice(importer.action.SKIP) + # The import command. def import_files(lib, paths, copy, write, autot, logpath, art, threaded, @@ -636,6 +644,7 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded, query = query, incremental = incremental, ignore = ignore, + resolve_duplicate_func = resolve_duplicate, ) finally: diff --git a/test/_common.py b/test/_common.py index aaccccb2f..d632df041 100644 --- a/test/_common.py +++ b/test/_common.py @@ -95,6 +95,7 @@ def iconfig(lib, **kwargs): query = None, incremental = False, ignore = [], + resolve_duplicate_func = lambda x, y: None, ) for k, v in kwargs.items(): setattr(config, k, v) diff --git a/test/test_importer.py b/test/test_importer.py index c04a12296..d4c41d0cc 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -105,6 +105,7 @@ class NonAutotaggedImportTest(unittest.TestCase): query = None, incremental = False, ignore = [], + resolve_duplicate_func = None, ) return paths From ced4e1ace83f04e5462a47b30c42c0a792f39890 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Mon, 19 Mar 2012 15:46:05 -0700 Subject: [PATCH 17/29] duplicate resolution prompt (#164) "Skip" and "keep both" work already, but "remove old" does not. It sets a flag on the import task object; this flag should then be read by the apply_choices coroutine, where appropriate action should be taken as part of the same transaction that adds stuff to the DB. Unresolved questions: - Should old files be deleted? (How is this decided? Based on config parameters, or with another prompt option?) - Logging details. - Folder naming. If I have two albums with the same name, I want them in different directories. - Quiet mode. Currently, there are no checks -- the prompt should not be made in quiet mode. (In that case, what should it do?) --- beets/importer.py | 1 + beets/ui/commands.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/beets/importer.py b/beets/importer.py index 82f60da4b..e8e5334a8 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -308,6 +308,7 @@ class ImportTask(object): self.path = path self.items = items self.sentinel = False + self.remove_duplicates = False @classmethod def done_sentinel(cls, toppath): diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 9f77b5826..865afb55e 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -570,7 +570,21 @@ def resolve_duplicate(task, config): """ log.warn("This %s is already in the library!" % ("album" if task.is_album else "item")) - task.set_choice(importer.action.SKIP) + sel = ui.input_options( + ('Skip new', 'Keep both', 'Remove old'), + color=config.color + ) + if sel == 's': + # Skip new. + task.set_choice(importer.action.SKIP) + elif sel == 'k': + # Keep both. Do nothing; leave the choice intact. + pass + elif sel == 'r': + # Remove old. + task.remove_duplictes = True + else: + assert False # The import command. From 71762375c9f0922158d08ad3f3318619f0562c89 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 20 Mar 2012 13:02:02 -0700 Subject: [PATCH 18/29] skip duplicate prompt in quiet mode In quiet mode, duplicates are skipped. We can add a new config option if people really want to customize this. --- beets/ui/commands.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 865afb55e..d5d0111af 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -570,10 +570,17 @@ def resolve_duplicate(task, config): """ log.warn("This %s is already in the library!" % ("album" if task.is_album else "item")) - sel = ui.input_options( - ('Skip new', 'Keep both', 'Remove old'), - color=config.color - ) + + if config.quiet: + # In quiet mode, don't prompt -- just skip. + log.info('Skipping.') + sel = 's' + else: + sel = ui.input_options( + ('Skip new', 'Keep both', 'Remove old'), + color=config.color + ) + if sel == 's': # Skip new. task.set_choice(importer.action.SKIP) From 34ec25e6426a2694e1149c4df162a3805f0f84c7 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 20 Mar 2012 13:13:52 -0700 Subject: [PATCH 19/29] appropriate logging for duplicate resolution --- beets/importer.py | 28 ++++++++++++++++++++-------- beets/ui/commands.py | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/beets/importer.py b/beets/importer.py index e8e5334a8..f9d583055 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -59,14 +59,26 @@ def tag_log(logfile, status, path): print >>logfile, '%s %s' % (status, path) logfile.flush() -def log_choice(config, task): - """Logs the task's current choice if it should be logged. +def log_choice(config, task, duplicate=False): + """Logs the task's current choice if it should be logged. If + ``duplicate``, then this is a secondary choice after a duplicate was + detected and a decision was made. """ path = task.path if task.is_album else task.item.path - if task.choice_flag is action.ASIS: - tag_log(config.logfile, 'asis', path) - elif task.choice_flag is action.SKIP: - tag_log(config.logfile, 'skip', path) + if duplicate: + # Duplicate: log all three choices (skip, keep both, and trump). + if task.remove_duplicates: + tag_log(config.logfile, 'duplicate-replace', path) + elif task.choice_flag in (action.ASIS, action.APPLY): + tag_log(config.logfile, 'duplicate-keep', path) + elif task.choice_flag is (action.SKIP): + tag_log(config.logfile, 'duplicate-skip', path) + else: + # Non-duplicate: log "skip" and "asis" choices. + if task.choice_flag is action.ASIS: + tag_log(config.logfile, 'asis', path) + elif task.choice_flag is action.SKIP: + tag_log(config.logfile, 'skip', path) def _reopen_lib(lib): """Because of limitations in SQLite, a given Library is bound to @@ -578,8 +590,8 @@ def user_query(config): # Check for duplicates if we have a match (or ASIS). if _duplicate_check(lib, task, recent): - tag_log(config.logfile, 'duplicate', task.path) config.resolve_duplicate_func(task, config) + log_choice(config, task, True) def show_progress(config): """This stage replaces the initial_lookup and user_query stages @@ -777,8 +789,8 @@ def item_query(config): # Duplicate check. if _item_duplicate_check(lib, task, recent): - tag_log(config.logfile, 'duplicate', task.item.path) config.resolve_duplicate_func(task, config) + log_choice(config, task, True) def item_progress(config): """Skips the lookup and query stages in a non-autotagged singleton diff --git a/beets/ui/commands.py b/beets/ui/commands.py index d5d0111af..201d414cf 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -589,7 +589,7 @@ def resolve_duplicate(task, config): pass elif sel == 'r': # Remove old. - task.remove_duplictes = True + task.remove_duplicates = True else: assert False From 24cdf2a72e020a36695b2564fdb5d2ba34624d60 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 20 Mar 2012 14:23:44 -0700 Subject: [PATCH 20/29] duplicate trumping: remove items & delete files Based on the "remove_duplicates" flag on ImportTask, the apply_choices coroutine now looks for duplicates (using an extended version of the _duplicate_check functions) and removes items from the library. It also *deletes* files associated with those items when they are located inside the beets library directory. Files outside of the directory are left on disk (but their DB entry is still removed). This should "do the right thing" in most cases -- again, this is something we can add a config option for if it comes up. --- beets/importer.py | 132 +++++++++++++++++++++++++----------------- test/test_importer.py | 20 ------- 2 files changed, 80 insertions(+), 72 deletions(-) diff --git a/beets/importer.py b/beets/importer.py index f9d583055..3d7f9b1ac 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -97,32 +97,18 @@ def _reopen_lib(lib): else: return lib -def _duplicate_check(lib, task, recent=None): - """Check whether an album already exists in the library. `recent` - should be a set of (artist, album) pairs that will be built up - with every call to this function and checked along with the - library. +def _duplicate_check(lib, task): + """Check whether an album already exists in the library. Returns a + list of Album objects (empty if no duplicates are found). """ - if task.choice_flag is action.ASIS: - artist = task.cur_artist - album = task.cur_album - elif task.choice_flag is action.APPLY: - artist = task.info.artist - album = task.info.album - else: - return False + assert task.choice_flag in (action.ASIS, action.APPLY) + artist, album = task.chosen_ident() if artist is None: # As-is import with no artist. Skip check. - return False + return [] - # Try the recent albums. - if recent is not None: - if (artist, album) in recent: - return True - recent.add((artist, album)) - - # Look in the library. + found_albums = [] cur_paths = set(i.path for i in task.items if i) for album_cand in lib.albums(artist=artist): if album_cand.album == album: @@ -131,34 +117,23 @@ def _duplicate_check(lib, task, recent=None): other_paths = set(i.path for i in album_cand.items()) if other_paths == cur_paths: continue - return True + found_albums.append(album_cand) + return found_albums - return False +def _item_duplicate_check(lib, task): + """Check whether an item already exists in the library. Returns a + list of Item objects. + """ + assert task.choice_flag in (action.ASIS, action.APPLY) + artist, title = task.chosen_ident() -def _item_duplicate_check(lib, task, recent=None): - """Check whether an item already exists in the library.""" - if task.choice_flag is action.ASIS: - artist = task.item.artist - title = task.item.title - elif task.choice_flag is action.APPLY: - artist = task.info.artist - title = task.info.title - else: - return False - - # Try recent items. - if recent is not None: - if (artist, title) in recent: - return True - recent.add((artist, title)) - - # Check the library. + found_items = [] for other_item in lib.items(artist=artist, title=title): # Existing items not considered duplicates. if other_item.path == task.item.path: continue - return True - return False + found_items.append(other_item) + return found_items def _infer_album_fields(task): """Given an album and an associated import task, massage the @@ -436,6 +411,26 @@ class ImportTask(object): """ return self.sentinel or self.choice_flag == action.SKIP + # Useful data. + def chosen_ident(self): + """Returns identifying metadata about the current choice. For + albums, this is an (artist, album) pair. For items, this is + (artist, title). May only be called when the choice flag is ASIS + (in which case the data comes from the files' current metadata) + or APPLY (data comes from the choice). + """ + assert self.choice_flag in (action.ASIS, action.APPLY) + if self.is_album: + if self.choice_flag is action.ASIS: + return (self.cur_artist, self.cur_album) + elif self.choice_flag is action.APPLY: + return (self.info.artist, self.info.album) + else: + if self.choice_flag is action.ASIS: + return (self.item.artist, self.item.title) + elif self.choice_flag is action.APPLY: + return (self.info.artist, self.info.title) + # Full-album pipeline stages. @@ -589,9 +584,15 @@ def user_query(config): continue # Check for duplicates if we have a match (or ASIS). - if _duplicate_check(lib, task, recent): - config.resolve_duplicate_func(task, config) - log_choice(config, task, True) + if task.choice_flag in (action.ASIS, action.APPLY): + ident = task.chosen_ident() + # The "recent" set keeps track of identifiers for recently + # imported albums -- those that haven't reached the database + # yet. + if ident in recent or _duplicate_check(lib, task): + config.resolve_duplicate_func(task, config) + log_choice(config, task, True) + recent.add(ident) def show_progress(config): """This stage replaces the initial_lookup and user_query stages @@ -638,9 +639,9 @@ def apply_choices(config): if task.is_album: _infer_album_fields(task) - # Find existing item entries that these are replacing. Old - # album structures are automatically cleaned up when the - # last item is removed. + # Find existing item entries that these are replacing (for + # re-imports). Old album structures are automatically cleaned up + # when the last item is removed. replaced_items = defaultdict(list) for item in items: dup_items = lib.items(library.MatchQuery('path', item.path)) @@ -651,6 +652,28 @@ def apply_choices(config): log.debug('%i of %i items replaced' % (len(replaced_items), len(items))) + # Find old items that should be replaced as part of a duplicate + # resolution. + duplicate_items = [] + if task.remove_duplicates: + if task.is_album: + for album in _duplicate_check(lib, task): + duplicate_items += album.items() + else: + duplicate_items = _item_duplicate_check(lib, task) + log.debug('removing %i old duplicated items' % + len(duplicate_items)) + + # Delete duplicate files that are located inside the library + # directory. + for duplicate_path in [i.path for i in duplicate_items]: + if lib.directory in util.ancestry(duplicate_path): + log.debug(u'deleting replaced duplicate %s' % + util.displayable_path(duplicate_path)) + util.soft_remove(duplicate_path) + util.prune_dirs(os.path.dirname(duplicate_path), + lib.directory) + # Move/copy files. task.old_paths = [item.path for item in items] for item in items: @@ -674,6 +697,8 @@ def apply_choices(config): for replaced in replaced_items.itervalues(): for item in replaced: lib.remove(item) + for item in duplicate_items: + lib.remove(item) # Add new ones. if task.is_album: @@ -788,9 +813,12 @@ def item_query(config): log_choice(config, task) # Duplicate check. - if _item_duplicate_check(lib, task, recent): - config.resolve_duplicate_func(task, config) - log_choice(config, task, True) + if task.choice_flag in (action.ASIS, action.APPLY): + ident = task.chosen_ident() + if ident in recent or _item_duplicate_check(lib, task): + config.resolve_duplicate_func(task, config) + log_choice(config, task, True) + recent.add(ident) def item_progress(config): """Skips the lookup and query stages in a non-autotagged singleton diff --git a/test/test_importer.py b/test/test_importer.py index d4c41d0cc..935ba65df 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -678,26 +678,6 @@ class DuplicateCheckTest(unittest.TestCase): self._item_task(True, 'xxx', 'yyy')) self.assertFalse(res) - def test_recent_item(self): - recent = set() - importer._item_duplicate_check(self.lib, - self._item_task(False, 'xxx', 'yyy'), - recent) - res = importer._item_duplicate_check(self.lib, - self._item_task(False, 'xxx', 'yyy'), - recent) - self.assertTrue(res) - - def test_recent_album(self): - recent = set() - importer._duplicate_check(self.lib, - self._album_task(False, 'xxx', 'yyy'), - recent) - res = importer._duplicate_check(self.lib, - self._album_task(False, 'xxx', 'yyy'), - recent) - self.assertTrue(res) - def test_duplicate_album_existing(self): res = importer._duplicate_check(self.lib, self._album_task(False, existing=True)) From c7840bbbf0cab48bb989fe305f9c2a391cd497a1 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Tue, 20 Mar 2012 14:36:11 -0700 Subject: [PATCH 21/29] docs/changelog for duplicate resolution (#164) --- docs/changelog.rst | 7 +++++++ docs/guides/tagger.rst | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4589adbbe..96f8e0723 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,13 @@ Changelog 1.0b14 (in development) ----------------------- +* The importer now gives you **choices when duplicates are detected**. + Previously, when beets found an existing album or item in your library + matching the metadata on a newly-imported one, it would just skip the new + music to avoid introducing duplicates into your library. Now, you have three + choices: skip the new music (the previous behavior), keep both, or remove the + old music. See the :ref:`guide-duplicates` section in the autotagging guide + for details. * New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist file for easy importing to other systems. Thanks to Fabrice Laporte. diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index d9155d979..985805196 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -195,6 +195,26 @@ guessing---beets will show you the proposed changes and ask you to confirm them, just like the earlier example. As the prompt suggests, you can just hit return to select the first candidate. +.. _guide-duplicates: + +Duplicates +---------- + +If beets finds an album or item in your library that seems to be the same as the +one you're importing, you may see a prompt like this:: + + This album is already in the library! + [S]kip new, Keep both, Remove old? + +Beets wants to keep you safe from duplicates, which can be a real pain, so you +have three choices in this situation. You can skip importing the new music, +choosing to keep the stuff you already have in your library; you can keep both +the old and the new music; or you can remove the existing music and choose the +new stuff. If you choose that last "trump" option, any duplicates will be +removed from your library database---and, if the corresponding files are located +inside of your beets library directory, the files themselves will be deleted as +well. + Fingerprinting -------------- From bdef5ef152da40c0aa337ed041702d2c2add4643 Mon Sep 17 00:00:00 2001 From: Philippe Mongeau Date: Tue, 20 Mar 2012 22:07:18 -0400 Subject: [PATCH 22/29] add random plugin --- beetsplug/rdm.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 beetsplug/rdm.py diff --git a/beetsplug/rdm.py b/beetsplug/rdm.py new file mode 100644 index 000000000..f7efa2bf3 --- /dev/null +++ b/beetsplug/rdm.py @@ -0,0 +1,53 @@ +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, decargs, print_ +from beets.util.functemplate import Template +import random + + +""" +Get a random song or album from the library +""" + + +def random_item(lib, config, opts, args): + query = decargs(args) + path = opts.path + fmt = opts.format + + + if fmt is None: + # If no specific template is supplied, use a default + if opts.album: + fmt = u'$albumartist - $album' + else: + fmt = u'$artist - $album - $title' + template = Template(fmt) + + + if opts.album: + items = list(lib.albums(query)) + item = random.choice(items) + if path: + print_(item.item_dir()) + else: + print_(template.substitute(item._record)) + else: + items = list(lib.items(query)) + item = random.choice(items) + if path: + print_(item.path) + else: + print_(template.substitute(item.record)) + +random_cmd = Subcommand('random', help='chose a random track, album, artist, etc.') +random_cmd.parser.add_option('-a', '--album', action='store_true', + help='choose an album instead of track') +random_cmd.parser.add_option('-p', '--path', action='store_true', + help='print the path of the matched item') +random_cmd.parser.add_option('-f', '--format', action='store', + help='print with custom format', default=None) +random_cmd.func = random_item + +class Random(BeetsPlugin): + def commands(self): + return [random_cmd] From 22e8695172e0f40a4b44753b153206c6675b530c Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Wed, 21 Mar 2012 14:25:01 -0700 Subject: [PATCH 23/29] BPD: use playbin2 instead of playbin (#364) --- beetsplug/bpd/gstplayer.py | 2 +- docs/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 2c77087b2..6094f2c5e 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -50,7 +50,7 @@ class GstPlayer(object): # Set up the Gstreamer player. From the pygst tutorial: # http://pygstdocs.berlios.de/pygst-tutorial/playbin.html - self.player = gst.element_factory_make("playbin", "player") + self.player = gst.element_factory_make("playbin2", "player") fakesink = gst.element_factory_make("fakesink", "fakesink") self.player.set_property("video-sink", fakesink) bus = self.player.get_bus() diff --git a/docs/changelog.rst b/docs/changelog.rst index 96f8e0723..bb2049177 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,6 +12,8 @@ Changelog for details. * New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist file for easy importing to other systems. Thanks to Fabrice Laporte. +* :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the + deprecated ``playbin``. 1.0b13 (March 16, 2012) From 8fad15263b7bb34a65844c11e6c4c5f5c1f504d8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 22 Mar 2012 17:08:22 -0700 Subject: [PATCH 24/29] reorganize default template functions into class Using a class wrapper allows additional context to be provided to the functions. Namely, we can now provide the Item itself to the function, so %foo{} could conceivably do something useful even without arguments. This will be used for the upcoming %unique function. --- beets/library.py | 108 +++++++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 41 deletions(-) diff --git a/beets/library.py b/beets/library.py index 98655e11b..9775a52f8 100644 --- a/beets/library.py +++ b/beets/library.py @@ -860,7 +860,7 @@ class Library(BaseLibrary): mapping[key] = util.sanitize_for_path(value, pathmod, key) # Perform substitution. - funcs = dict(TEMPLATE_FUNCTIONS) + funcs = DefaultTemplateFunctions(item).functions() funcs.update(plugins.template_funcs()) subpath = subpath_tmpl.substitute(mapping, funcs) @@ -1315,44 +1315,70 @@ def _int_arg(s): function. May raise a ValueError. """ return int(s.strip()) -def _tmpl_lower(s): - """Convert a string to lower case.""" - return s.lower() -def _tmpl_upper(s): - """Covert a string to upper case.""" - return s.upper() -def _tmpl_title(s): - """Convert a string to title case.""" - return s.title() -def _tmpl_left(s, chars): - """Get the leftmost characters of a string.""" - return s[0:_int_arg(chars)] -def _tmpl_right(s, chars): - """Get the rightmost characters of a string.""" - return s[-_int_arg(chars):] -def _tmpl_if(condition, trueval, falseval=u''): - """If ``condition`` is nonempty and nonzero, emit ``trueval``; - otherwise, emit ``falseval`` (if provided). - """ - try: - condition = _int_arg(condition) - except ValueError: - condition = condition.strip() - if condition: - return trueval - else: - return falseval -def _tmpl_asciify(s): - """Translate non-ASCII characters to their ASCII equivalents. - """ - return unidecode(s) -TEMPLATE_FUNCTIONS = { - 'lower': _tmpl_lower, - 'upper': _tmpl_upper, - 'title': _tmpl_title, - 'left': _tmpl_left, - 'right': _tmpl_right, - 'if': _tmpl_if, - 'asciify': _tmpl_asciify, -} +class DefaultTemplateFunctions(object): + """A container class for the default functions provided to path + templates. These functions are contained in an object to provide + additional context to the functions -- specifically, the Item being + evaluated. + """ + def __init__(self, item): + self.item = item + + _prefix = 'tmpl_' + + def functions(self): + """Returns a dictionary containing the functions defined in this + object. The keys are function names (as exposed in templates) + and the values are Python functions. + """ + out = {} + for key in dir(self): + if key.startswith(self._prefix): + out[key[len(self._prefix):]] = getattr(self, key) + return out + + @staticmethod + def tmpl_lower(s): + """Convert a string to lower case.""" + return s.lower() + + @staticmethod + def tmpl_upper(s): + """Covert a string to upper case.""" + return s.upper() + + @staticmethod + def tmpl_title(s): + """Convert a string to title case.""" + return s.title() + + @staticmethod + def tmpl_left(s, chars): + """Get the leftmost characters of a string.""" + return s[0:_int_arg(chars)] + + @staticmethod + def tmpl_right(s, chars): + """Get the rightmost characters of a string.""" + return s[-_int_arg(chars):] + + @staticmethod + def tmpl_if(condition, trueval, falseval=u''): + """If ``condition`` is nonempty and nonzero, emit ``trueval``; + otherwise, emit ``falseval`` (if provided). + """ + try: + condition = _int_arg(condition) + except ValueError: + condition = condition.strip() + if condition: + return trueval + else: + return falseval + + @staticmethod + def tmpl_asciify(s): + """Translate non-ASCII characters to their ASCII equivalents. + """ + return unidecode(s) From 06f137bff984b47f799d5b5d599667a678fbba34 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Thu, 22 Mar 2012 18:04:06 -0700 Subject: [PATCH 25/29] %unique path function Generates disambiguating strings to distinguish albums from one another. To be used as the basis for a simpler path field, $unique, as a default disambiguator. --- beets/library.py | 99 ++++++++++++++++++++++++++++++++++++++---------- test/test_db.py | 55 +++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 28 deletions(-) diff --git a/beets/library.py b/beets/library.py index 9775a52f8..e09c94aa5 100644 --- a/beets/library.py +++ b/beets/library.py @@ -353,7 +353,7 @@ class CollectionQuery(Query): """An abstract query class that aggregates other queries. Can be indexed like a list to access the sub-queries. """ - def __init__(self, subqueries = ()): + def __init__(self, subqueries=()): self.subqueries = subqueries # is there a better way to do this? @@ -789,10 +789,10 @@ class Library(BaseLibrary): if table == 'albums' and 'artist' in current_fields and \ 'albumartist' not in current_fields: setup_sql += "UPDATE ALBUMS SET albumartist=artist;\n" - + self.conn.executescript(setup_sql) self.conn.commit() - + def destination(self, item, pathmod=None, in_album=False, fragment=False, basedir=None): """Returns the path in the library directory designated for item @@ -804,7 +804,7 @@ class Library(BaseLibrary): directory for the destination. """ pathmod = pathmod or os.path - + # Use a path format based on a query, falling back on the # default. for query, path_format in self.path_formats: @@ -831,10 +831,10 @@ class Library(BaseLibrary): else: assert False, "no default path format" subpath_tmpl = Template(path_format) - + # Get the item's Album if it has one. album = self.get_album(item) - + # Build the mapping for substitution in the path template, # beginning with the values from the database. mapping = {} @@ -847,7 +847,7 @@ class Library(BaseLibrary): # From Item. value = getattr(item, key) mapping[key] = util.sanitize_for_path(value, pathmod, key) - + # Use the album artist if the track artist is not set and # vice-versa. if not mapping['artist']: @@ -858,24 +858,24 @@ class Library(BaseLibrary): # Get values from plugins. for key, value in plugins.template_values(item).iteritems(): mapping[key] = util.sanitize_for_path(value, pathmod, key) - + # Perform substitution. - funcs = DefaultTemplateFunctions(item).functions() + funcs = DefaultTemplateFunctions(self, item).functions() funcs.update(plugins.template_funcs()) subpath = subpath_tmpl.substitute(mapping, funcs) - + # Encode for the filesystem, dropping unencodable characters. if isinstance(subpath, unicode) and not fragment: encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() subpath = subpath.encode(encoding, 'replace') - + # Truncate components and remove forbidden characters. subpath = util.sanitize_path(subpath, pathmod, self.replacements) - + # Preserve extension. _, extension = pathmod.splitext(item.path) subpath += extension.lower() - + if fragment: return subpath else: @@ -886,7 +886,6 @@ class Library(BaseLibrary): # Item manipulation. def add(self, item, copy=False): - #FIXME make a deep copy of the item? item.library = self if copy: self.move(item, copy=True) @@ -901,18 +900,18 @@ class Library(BaseLibrary): if key == 'path' and isinstance(value, str): value = buffer(value) subvars.append(value) - + # issue query c = self.conn.cursor() query = 'INSERT INTO items (' + columns + ') VALUES (' + values + ')' c.execute(query, subvars) new_id = c.lastrowid c.close() - + item._clear_dirty() item.id = new_id return new_id - + def save(self, event=True): """Writes the library to disk (completing an sqlite transaction). @@ -924,7 +923,7 @@ class Library(BaseLibrary): def load(self, item, load_id=None): if load_id is None: load_id = item.id - + c = self.conn.execute( 'SELECT * FROM items WHERE id=?', (load_id,) ) item._fill_record(c.fetchone()) @@ -934,7 +933,7 @@ class Library(BaseLibrary): def store(self, item, store_id=None, store_all=False): if store_id is None: store_id = item.id - + # build assignments for query assignments = '' subvars = [] @@ -947,7 +946,7 @@ class Library(BaseLibrary): if key == 'path' and isinstance(value, str): value = buffer(value) subvars.append(value) - + if not assignments: # nothing to store (i.e., nothing was dirty) return @@ -1322,7 +1321,8 @@ class DefaultTemplateFunctions(object): additional context to the functions -- specifically, the Item being evaluated. """ - def __init__(self, item): + def __init__(self, lib, item): + self.lib = lib self.item = item _prefix = 'tmpl_' @@ -1382,3 +1382,60 @@ class DefaultTemplateFunctions(object): """Translate non-ASCII characters to their ASCII equivalents. """ return unidecode(s) + + def tmpl_unique(self, keys, disam): + """Generate a string that is guaranteed to be unique among all + albums in the library who share the same set of keys. Fields + from "disam" are used in the string if they are sufficient to + disambiguate the albums. Otherwise, a fallback opaque value is + used. Both "keys" and "disam" should be given as + whitespace-separated lists of field names. + """ + keys = keys.split() + disam = disam.split() + + album = self.lib.get_album(self.item) + if not album: + # Do nothing for singletons. + return u'' + + # Find matching albums to disambiguate with. + subqueries = [] + for key in keys: + value = getattr(album, key) + subqueries.append(MatchQuery(key, value)) + albums = self.lib.albums(query=AndQuery(subqueries)) + + # If there's only one album to matching these details, then do + # nothing. + if len(albums) == 1: + return u'' + + # Find the minimum number of fields necessary to disambiguate + # the set of albums. + disambiguators = [] + for field in disam: + disambiguators.append(field) + + # Get the value tuple for each album for these + # disambiguators. + disam_values = set() + for a in albums: + values = [getattr(a, f) for f in disambiguators] + disam_values.add(tuple(values)) + + # If the set of unique tuples is equal to the number of + # albums in the disambiguation set, we're done -- this is + # sufficient disambiguation. + if len(disam_values) == len(albums): + break + + else: + # Even when using all of the disambiguating fields, we + # could not separate all the albums. Fall back to the unique + # album ID. + return u' {}'.format(album.id) + + # Flatten disambiguation values into a string. + values = [unicode(getattr(album, f)) for f in disambiguators] + return u' [{}]'.format(u' '.join(values)) diff --git a/test/test_db.py b/test/test_db.py index b8d824d0d..06cca6b48 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -404,7 +404,17 @@ class DestinationTest(unittest.TestCase): ]) self.assertEqual(p, 'bar/bar') -class DestinationFunctionTest(unittest.TestCase): +class PathFormattingMixin(object): + """Utilities for testing path formatting.""" + def _setf(self, fmt): + self.lib.path_formats.insert(0, ('default', fmt)) + def _assert_dest(self, dest, i=None): + if i is None: + i = self.i + self.assertEqual(self.lib.destination(i, pathmod=posixpath), + dest) + +class DestinationFunctionTest(unittest.TestCase, PathFormattingMixin): def setUp(self): self.lib = beets.library.Library(':memory:') self.lib.directory = '/base' @@ -413,12 +423,6 @@ class DestinationFunctionTest(unittest.TestCase): def tearDown(self): self.lib.conn.close() - def _setf(self, fmt): - self.lib.path_formats.insert(0, ('default', fmt)) - def _assert_dest(self, dest): - self.assertEqual(self.lib.destination(self.i, pathmod=posixpath), - dest) - def test_upper_case_literal(self): self._setf(u'%upper{foo}') self._assert_dest('/base/FOO') @@ -459,6 +463,43 @@ class DestinationFunctionTest(unittest.TestCase): self._setf(u'%foo{bar}') self._assert_dest('/base/%foo{bar}') +class DisambiguationTest(unittest.TestCase, PathFormattingMixin): + def setUp(self): + self.lib = beets.library.Library(':memory:') + self.lib.directory = '/base' + self.lib.path_formats = [('default', u'path')] + + self.i1 = item() + self.i1.year = 2001 + self.lib.add_album([self.i1]) + self.i2 = item() + self.i2.year = 2002 + self.lib.add_album([self.i2]) + self.lib.save() + + self._setf(u'foo%unique{albumartist album,year}/$title') + + def tearDown(self): + self.lib.conn.close() + + def test_unique_expands_to_disambiguating_year(self): + self._assert_dest('/base/foo [2001]/the title', self.i1) + + def test_unique_expands_to_nothing_for_distinct_albums(self): + album2 = self.lib.get_album(self.i2) + album2.album = 'different album' + self.lib.save() + + self._assert_dest('/base/foo/the title', self.i1) + + def test_use_fallback_numbers_when_identical(self): + album2 = self.lib.get_album(self.i2) + album2.year = 2001 + self.lib.save() + + self._assert_dest('/base/foo 1/the title', self.i1) + self._assert_dest('/base/foo 2/the title', self.i2) + class PluginDestinationTest(unittest.TestCase): # Mock the plugins.template_values(item) function. def _template_values(self, item): From 7e6cdd75d6d96d5585452af0b04904b150707dcb Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 23 Mar 2012 10:26:50 -0700 Subject: [PATCH 26/29] docs & changelog for rdm plugin --- beetsplug/rdm.py | 24 +++++++++++++++++------- docs/changelog.rst | 1 + docs/plugins/index.rst | 1 + docs/plugins/rdm.rst | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 docs/plugins/rdm.rst diff --git a/beetsplug/rdm.py b/beetsplug/rdm.py index f7efa2bf3..8131cdf58 100644 --- a/beetsplug/rdm.py +++ b/beetsplug/rdm.py @@ -1,20 +1,30 @@ +# This file is part of beets. +# Copyright 2011, Philippe Mongeau. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_ from beets.util.functemplate import Template import random - +"""Get a random song or album from the library. """ -Get a random song or album from the library -""" - def random_item(lib, config, opts, args): query = decargs(args) path = opts.path fmt = opts.format - if fmt is None: # If no specific template is supplied, use a default if opts.album: @@ -23,7 +33,6 @@ def random_item(lib, config, opts, args): fmt = u'$artist - $album - $title' template = Template(fmt) - if opts.album: items = list(lib.albums(query)) item = random.choice(items) @@ -39,7 +48,8 @@ def random_item(lib, config, opts, args): else: print_(template.substitute(item.record)) -random_cmd = Subcommand('random', help='chose a random track, album, artist, etc.') +random_cmd = Subcommand('random', + help='chose a random track or album') random_cmd.parser.add_option('-a', '--album', action='store_true', help='choose an album instead of track') random_cmd.parser.add_option('-p', '--path', action='store_true', diff --git a/docs/changelog.rst b/docs/changelog.rst index bb2049177..708fccbcc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -10,6 +10,7 @@ Changelog choices: skip the new music (the previous behavior), keep both, or remove the old music. See the :ref:`guide-duplicates` section in the autotagging guide for details. +* New :doc:`/plugins/rdm`: Randomly select albums and tracks from your library. * New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist file for easy importing to other systems. Thanks to Fabrice Laporte. * :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index f0736b177..990197b58 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -81,6 +81,7 @@ Miscellaneous ''''''''''''' * :doc:`web`: An experimental Web-based GUI for beets. +* :doc:`rdm`: Randomly choose albums and tracks from your library. * :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is compatible with `MPD clients`_. diff --git a/docs/plugins/rdm.rst b/docs/plugins/rdm.rst new file mode 100644 index 000000000..1ced7fecf --- /dev/null +++ b/docs/plugins/rdm.rst @@ -0,0 +1,17 @@ +Random Plugin +============= + +The ``rdm`` plugin provides a command that randomly selects tracks or albums +from your library. This can be helpful if you need some help deciding what to +listen to. + +First, enable the plugin named ``rdm`` (see :doc:`/plugins/index`). You'll then +be able to use the ``beet random`` command:: + + $ beet random + Aesop Rock - None Shall Pass - The Harbor Is Yours + +The command has several options that resemble those for the ``beet list`` +command (see :doc:`/reference/cli`). To choose an album instead of a single +track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and +to use a custom format for printing, use ``-f FORMAT``. From 9befb9456121b9bd919ddb4f1aef9a1efaa7fb5f Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 23 Mar 2012 10:33:44 -0700 Subject: [PATCH 27/29] -n option for random command (#24) --- beetsplug/rdm.py | 31 +++++++++++++++++++------------ docs/plugins/rdm.rst | 4 ++++ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/beetsplug/rdm.py b/beetsplug/rdm.py index 8131cdf58..d081c2a73 100644 --- a/beetsplug/rdm.py +++ b/beetsplug/rdm.py @@ -34,19 +34,24 @@ def random_item(lib, config, opts, args): template = Template(fmt) if opts.album: - items = list(lib.albums(query)) - item = random.choice(items) - if path: - print_(item.item_dir()) - else: - print_(template.substitute(item._record)) + objs = list(lib.albums(query=query)) else: - items = list(lib.items(query)) - item = random.choice(items) - if path: - print_(item.path) - else: - print_(template.substitute(item.record)) + objs = list(lib.items(query=query)) + number = min(len(objs), opts.number) + objs = random.sample(objs, number) + + if opts.album: + for album in objs: + if path: + print_(album.item_dir()) + else: + print_(template.substitute(album._record)) + else: + for item in objs: + if path: + print_(item.path) + else: + print_(template.substitute(item.record)) random_cmd = Subcommand('random', help='chose a random track or album') @@ -56,6 +61,8 @@ random_cmd.parser.add_option('-p', '--path', action='store_true', help='print the path of the matched item') random_cmd.parser.add_option('-f', '--format', action='store', help='print with custom format', default=None) +random_cmd.parser.add_option('-n', '--number', action='store', type="int", + help='number of objects to choose', default=1) random_cmd.func = random_item class Random(BeetsPlugin): diff --git a/docs/plugins/rdm.rst b/docs/plugins/rdm.rst index 1ced7fecf..4d8eb279e 100644 --- a/docs/plugins/rdm.rst +++ b/docs/plugins/rdm.rst @@ -15,3 +15,7 @@ The command has several options that resemble those for the ``beet list`` command (see :doc:`/reference/cli`). To choose an album instead of a single track, use ``-a``; to print paths to items instead of metadata, use ``-p``; and to use a custom format for printing, use ``-f FORMAT``. + +The ``-n NUMBER`` option controls the number of objects that are selected and +printed (default 1). To select 5 tracks from your library, type ``beet random +-n5``. From ca29bd6dd11ffc34c8417796f8d78d5418ef70f2 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 23 Mar 2012 10:36:14 -0700 Subject: [PATCH 28/29] credit for random plugin (#24) --- docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 708fccbcc..d6d9cbd07 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Changelog * New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist file for easy importing to other systems. Thanks to Fabrice Laporte. * :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the - deprecated ``playbin``. + deprecated ``playbin``. Thanks to Philippe Mongeau. 1.0b13 (March 16, 2012) From 2466305d7e61bf62d590514a00247762d06f731e Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Fri, 23 Mar 2012 11:34:57 -0700 Subject: [PATCH 29/29] add MusicBrainz collection plugin by @jeffayle --- beetsplug/mbcollection.py | 62 +++++++++++++++++++++++++++++++++++ docs/changelog.rst | 5 ++- docs/plugins/index.rst | 7 ++-- docs/plugins/mbcollection.rst | 20 +++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 beetsplug/mbcollection.py create mode 100644 docs/plugins/mbcollection.rst diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py new file mode 100644 index 000000000..2e4e1a713 --- /dev/null +++ b/beetsplug/mbcollection.py @@ -0,0 +1,62 @@ +#Copyright (c) 2011, Jeffrey Aylesworth +# +#Permission to use, copy, modify, and/or distribute this software for any +#purpose with or without fee is hereby granted, provided that the above +#copyright notice and this permission notice appear in all copies. +# +#THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +#WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +#MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +#ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +#WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +#ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +#OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand +from beets import ui +import musicbrainzngs +from musicbrainzngs import musicbrainz + +SUBMISSION_CHUNK_SIZE = 350 + +def submit_albums(collection_id, release_ids): + """Add all of the release IDs to the indicated collection. Multiple + requests are made if there are many release IDs to submit. + """ + for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE): + chunk = release_ids[i:i+SUBMISSION_CHUNK_SIZE] + releaselist = ";".join(chunk) + musicbrainz._mb_request( + "collection/%s/releases/%s" % (collection_id, releaselist), + 'PUT', True, True, body='foo') + # A non-empty request body is required to avoid a 411 "Length + # Required" error from the MB server. + +def update_collection(lib, config, opts, args): + # Get the collection to modify. + collections = musicbrainz._mb_request('collection', 'GET', True, True) + if not collections['collection-list']: + raise ui.UserError('no collections exist for user') + collection_id = collections['collection-list'][0]['id'] + + # Get a list of all the albums. + albums = [a.mb_albumid for a in lib.albums() if a.mb_albumid] + + # Submit to MusicBrainz. + print 'Updating MusicBrainz collection {}...'.format(collection_id) + submit_albums(collection_id, albums) + print '...MusicBrainz collection updated.' + +update_mb_collection_cmd = Subcommand('mbupdate', + help='Update MusicBrainz collection') +update_mb_collection_cmd.func = update_collection + +class MusicBrainzCollectionPlugin(BeetsPlugin): + def configure(self, config): + username = ui.config_val(config, 'musicbrainz', 'user', '') + password = ui.config_val(config, 'musicbrainz', 'pass', '') + musicbrainzngs.auth(username, password) + + def commands(self): + return [update_mb_collection_cmd] diff --git a/docs/changelog.rst b/docs/changelog.rst index d6d9cbd07..0db9df4bb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,10 +11,13 @@ Changelog old music. See the :ref:`guide-duplicates` section in the autotagging guide for details. * New :doc:`/plugins/rdm`: Randomly select albums and tracks from your library. + Thanks to Philippe Mongeau. +* The :doc:`/plugins/mbcollection` by Jeffrey Aylesworth was added to the core + beets distribution. * New :doc:`/plugins/m3uupdate`: Catalog imported files in an ``m3u`` playlist file for easy importing to other systems. Thanks to Fabrice Laporte. * :doc:`/plugins/bpd`: Use Gstreamer's ``playbin2`` element instead of the - deprecated ``playbin``. Thanks to Philippe Mongeau. + deprecated ``playbin``. 1.0b13 (March 16, 2012) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 990197b58..dd7b09112 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -47,6 +47,8 @@ disabled by default, but you can turn them on as described above. scrub rewrite m3uupdate + rdm + mbcollection Autotagger Extensions '''''''''''''''''''''' @@ -82,6 +84,7 @@ Miscellaneous * :doc:`web`: An experimental Web-based GUI for beets. * :doc:`rdm`: Randomly choose albums and tracks from your library. +* :doc:`mbcollection`: Maintain your MusicBrainz collection list. * :doc:`bpd`: A music player for your beets library that emulates `MPD`_ and is compatible with `MPD clients`_. @@ -98,15 +101,11 @@ Here are a few of the plugins written by the beets community: * `beetFs`_ is a FUSE filesystem for browsing the music in your beets library. (Might be out of date.) -* `Beet-MusicBrainz-Collection`_ lets you add albums from your library to your - MusicBrainz `"music collection"`_. - * `A cmus plugin`_ integrates with the `cmus`_ console music player. .. _beetFs: http://code.google.com/p/beetfs/ .. _Beet-MusicBrainz-Collection: https://github.com/jeffayle/Beet-MusicBrainz-Collection/ -.. _"music collection": http://musicbrainz.org/show/collection/ .. _A cmus plugin: https://github.com/coolkehon/beets/blob/master/beetsplug/cmus.py .. _cmus: http://cmus.sourceforge.net/ diff --git a/docs/plugins/mbcollection.rst b/docs/plugins/mbcollection.rst new file mode 100644 index 000000000..579cb2d2a --- /dev/null +++ b/docs/plugins/mbcollection.rst @@ -0,0 +1,20 @@ +MusicBrainz Collection Plugin +============================= + +The ``mbcollection`` plugin lets you submit your catalog to MusicBrainz to +maintain your `music collection`_ list there. + +.. _music collection: http://musicbrainz.org/show/collection/ + +To begin, just enable the ``mbcollection`` plugin (see :doc:`/plugins/index`). +Then, add your MusicBrainz username and password to your +:doc:`/reference/config` in a ``musicbrainz`` section:: + + [musicbrainz] + user: USERNAME + pass: PASSWORD + +Then, use the ``beet mbupdate`` command to send your albums to MusicBrainz. The +command automatically adds all of your albums to the first collection it finds. +If you don't have a MusicBrainz collection yet, you may need to add one to your +profile first.