diff --git a/.travis.yml b/.travis.yml index ebb7162fb..746f430ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,8 @@ python: - "2.7" - "2.6" install: - - pip install . --use-mirrors - - pip install pylast flask --use-mirrors + - travis_retry pip install . --use-mirrors + - travis_retry pip install pylast flask --use-mirrors - "if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2 --use-mirrors; fi" script: nosetests branches: diff --git a/beets/__init__.py b/beets/__init__.py index ebc3eedcd..151b46994 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -20,4 +20,4 @@ from beets.util import confit Library = beets.library.Library -config = confit.Configuration('beets', __name__, False) +config = confit.LazyConfig('beets', __name__) diff --git a/beets/library.py b/beets/library.py index 54b2c68e4..8a1687914 100644 --- a/beets/library.py +++ b/beets/library.py @@ -196,12 +196,12 @@ def format_for_path(value, key=None, pathmod=None): pathmod = pathmod or os.path if isinstance(value, basestring): + if isinstance(value, str): + value = value.decode('utf8', 'ignore') + sep_repl = beets.config['path_sep_replace'].get(unicode) for sep in (pathmod.sep, pathmod.altsep): if sep: - value = value.replace( - sep, - beets.config['path_sep_replace'].get(unicode), - ) + value = value.replace(sep, sep_repl) elif key in ('track', 'tracktotal', 'disc', 'disctotal'): # Pad indices with zeros. value = u'%02i' % (value or 0) @@ -1113,8 +1113,6 @@ class Library(BaseLibrary): directory='~/Music', path_formats=((PF_KEY_DEFAULT, '$artist/$album/$track $title'),), - art_filename='cover', - timeout=5.0, replacements=None, item_fields=ITEM_FIELDS, album_fields=ALBUM_FIELDS): @@ -1124,12 +1122,10 @@ class Library(BaseLibrary): self.path = bytestring_path(normpath(path)) self.directory = bytestring_path(normpath(directory)) self.path_formats = path_formats - self.art_filename = art_filename self.replacements = replacements self._memotable = {} # Used for template substitution performance. - self.timeout = timeout self._connections = {} self._tx_stacks = defaultdict(list) # A lock to protect the _connections and _tx_stacks maps, which @@ -1210,7 +1206,10 @@ class Library(BaseLibrary): return self._connections[thread_id] else: # Make a new connection. - conn = sqlite3.connect(self.path, timeout=self.timeout) + conn = sqlite3.connect( + self.path, + timeout=beets.config['timeout'].as_number(), + ) # Access SELECT results like dictionaries. conn.row_factory = sqlite3.Row @@ -1703,12 +1702,10 @@ class Album(BaseAlbum): image = bytestring_path(image) item_dir = item_dir or self.item_dir() - if not isinstance(self._library.art_filename,Template): - self._library.art_filename = Template(self._library.art_filename) - - subpath = util.sanitize_path(format_for_path( - self.evaluate_template(self._library.art_filename) - )) + filename_tmpl = Template(beets.config['art_filename'].get(unicode)) + subpath = format_for_path(self.evaluate_template(filename_tmpl)) + subpath = util.sanitize_path(subpath, + replacements=self._library.replacements) subpath = bytestring_path(subpath) _, ext = os.path.splitext(image) @@ -1754,6 +1751,10 @@ class Album(BaseAlbum): mapping['artpath'] = displayable_path(mapping['artpath']) mapping['path'] = displayable_path(self.item_dir()) + # Get values from plugins. + for key, value in plugins.template_values(self).iteritems(): + mapping[key] = value + # Get template functions. funcs = DefaultTemplateFunctions().functions() funcs.update(plugins.template_funcs()) diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 9e92ce4a4..9dd568689 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -689,18 +689,13 @@ class SubcommandsOptionParser(optparse.OptionParser): # The root parser and its main function. -def _raw_main(args, load_config=True): +def _raw_main(args): """A helper function for `main` without top-level exception handling. """ - # Load global configuration files. - if load_config: - config.read() - # Temporary: Migrate from 1.0-style configuration. from beets.ui import migrate - if load_config: - migrate.automigrate() + migrate.automigrate() # Get the default subcommands. from beets.ui.commands import default_commands @@ -734,8 +729,6 @@ def _raw_main(args, load_config=True): dbpath, config['directory'].as_filename(), get_path_formats(), - Template(config['art_filename'].get(unicode)), - config['timeout'].as_number(), get_replacements(), ) except sqlite3.OperationalError: diff --git a/beets/ui/commands.py b/beets/ui/commands.py index c679a4cd2..e4222b631 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -91,12 +91,21 @@ def _showdiff(field, oldval, newval): fields_cmd = ui.Subcommand('fields', help='show fields available for queries and format strings') + def fields_func(lib, opts, args): - print("Available item fields:") + print("Item fields:") print(" " + "\n ".join([key for key in library.ITEM_KEYS])) - print("\nAvailable album fields:") + + print("\nAlbum fields:") print(" " + "\n ".join([key for key in library.ALBUM_KEYS])) + plugin_fields = [] + for plugin in plugins.find_plugins(): + plugin_fields += plugin.template_fields.keys() + if plugin_fields: + print("\nTemplate fields from plugins:") + print(" " + "\n ".join(plugin_fields)) + fields_cmd.func = fields_func default_commands.append(fields_cmd) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index cb1c12eaf..d94b14e22 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -468,14 +468,11 @@ def sanitize_path(path, pathmod=None, replacements=None): reliably on Windows when a path begins with a drive letter. Path separators (including altsep!) should already be cleaned from the path components. If replacements is specified, it is used *instead* - of the default set of replacements for the platform; it must be a - list of (compiled regex, replacement string) pairs. + of the default set of replacements; it must be a list of (compiled + regex, replacement string) pairs. """ pathmod = pathmod or os.path - - # Choose the appropriate replacements. - if not replacements: - replacements = list(CHAR_REPLACE) + replacements = replacements or CHAR_REPLACE comps = components(path, pathmod) if not comps: diff --git a/beets/util/confit.py b/beets/util/confit.py index b7af4ea40..6499f146d 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -677,3 +677,40 @@ class Configuration(RootView): if not os.path.isdir(appdir): os.makedirs(appdir) return appdir + +class LazyConfig(Configuration): + """A Configuration at reads files on demand when it is first + accessed. This is appropriate for using as a global config object at + the module level. + """ + def __init__(self, appname, modname=None): + super(LazyConfig, self).__init__(appname, modname, False) + self._materialized = False # Have we read the files yet? + self._lazy_prefix = [] # Pre-materialization calls to set(). + self._lazy_suffix = [] # Calls to add(). + + def read(self, user=True, defaults=True): + self._materialized = True + super(LazyConfig, self).read(user, defaults) + + def resolve(self): + if not self._materialized: + # Read files and unspool buffers. + self.read() + self.sources += self._lazy_suffix + self.sources[:0] = self._lazy_prefix + return super(LazyConfig, self).resolve() + + def add(self, value): + super(LazyConfig, self).add(value) + if not self._materialized: + # Buffer additions to end. + self._lazy_suffix += self.sources + del self.sources[:] + + def set(self, value): + super(LazyConfig, self).set(value) + if not self._materialized: + # Buffer additions to beginning. + self._lazy_prefix[:0] = self.sources + del self.sources[:] diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py new file mode 100644 index 000000000..34f1ec846 --- /dev/null +++ b/beetsplug/duplicates.py @@ -0,0 +1,110 @@ +# This file is part of beets. +# Copyright 2013, Pedro Silva. +# +# 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. + +"""List duplicate tracks or albums. +""" +import logging + +from beets.plugins import BeetsPlugin +from beets.ui import decargs, print_obj, Subcommand + +PLUGIN = 'duplicates' +log = logging.getLogger('beets') + + +def _group_by_id(objs): + """Return a dictionary whose keys are MBIDs and whose values are + lists of objects (Albums or Items) with that ID. + """ + import collections + counts = collections.defaultdict(list) + for obj in objs: + mbid = getattr(obj, 'mb_trackid', obj.mb_albumid) + counts[mbid].append(obj) + return counts + + +def _duplicates(objs, full): + """Generate triples of MBIDs, duplicate counts, and constituent + objects. + """ + offset = 0 if full else 1 + for mbid, objs in _group_by_id(objs).iteritems(): + if len(objs) > 1: + yield (mbid, len(objs) - offset, objs[offset:]) + + +class DuplicatesPlugin(BeetsPlugin): + """List duplicate tracks or albums + """ + def __init__(self): + super(DuplicatesPlugin, self).__init__() + + self.config.add({'format': ''}) + self.config.add({'count': False}) + self.config.add({'album': False}) + self.config.add({'full': False}) + + self._command = Subcommand('duplicates', + help=__doc__, + aliases=['dup']) + + self._command.parser.add_option('-f', '--format', dest='format', + action='store', type='string', + help='print with custom FORMAT', + metavar='FORMAT') + + self._command.parser.add_option('-c', '--count', dest='count', + action='store_true', + help='count duplicate tracks or\ + albums') + + self._command.parser.add_option('-a', '--album', dest='album', + action='store_true', + help='show duplicate albums instead\ + of tracks') + + self._command.parser.add_option('-F', '--full', dest='full', + action='store_true', + help='show all versions of duplicate\ + tracks or albums') + + def commands(self): + def _dup(lib, opts, args): + self.config.set_args(opts) + fmt = self.config['format'].get() + count = self.config['count'].get() + album = self.config['album'].get() + full = self.config['full'].get() + + if album: + items = lib.albums(decargs(args)) + else: + items = lib.items(decargs(args)) + + # Default format string for count mode. + if count and not fmt: + if album: + fmt = '$albumartist - $album' + else: + fmt = '$albumartist - $album - $title' + fmt += ': {}' + + for obj_id, obj_count, objs in _duplicates(items, full): + if obj_id: # Skip empty IDs. + for o in objs: + print_obj(o, lib, fmt=fmt.format(obj_count)) + + self._command.func = _dup + return [self._command] diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 81a003cb7..086ed7160 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -18,6 +18,7 @@ import logging import traceback from beets.plugins import BeetsPlugin +from beets.library import Item, Album from beets import config log = logging.getLogger('beets') @@ -46,6 +47,14 @@ def _compile_func(body): eval(code, env) return env[FUNC_NAME] +def _record(obj): + """Get a dictionary of values for an Item or Album object. + """ + if isinstance(obj, Item): + return dict(obj.record) + else: + return dict(obj._record) + def compile_inline(python_code): """Given a Python expression or function body, compile it as a path field function. The returned function takes a single argument, an @@ -70,8 +79,8 @@ def compile_inline(python_code): if is_expr: # For expressions, just evaluate and return the result. - def _expr_func(item): - values = dict(item.record) + def _expr_func(obj): + values = _record(obj) try: return eval(code, values) except Exception as exc: @@ -80,8 +89,8 @@ def compile_inline(python_code): else: # For function bodies, invoke the function with values as global # variables. - def _func_func(item): - func.__globals__.update(item.record) + def _func_func(obj): + func.__globals__.update(_record(obj)) try: return func() except Exception as exc: diff --git a/beetsplug/missing.py b/beetsplug/missing.py index ac394d8de..1fcb0d68c 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -17,7 +17,7 @@ import logging from beets.autotag import hooks -from beets.library import Item +from beets.library import Item, Album from beets.plugins import BeetsPlugin from beets.ui import decargs, print_obj, Subcommand @@ -135,16 +135,29 @@ class MissingPlugin(BeetsPlugin): print(sum([_missing_count(a) for a in albums])) return + # Default format string for count mode. + if count and not fmt: + fmt = '$albumartist - $album: $missing' + for album in albums: if count: missing = _missing_count(album) if missing: - fmt = "$album: {}".format(missing) print_obj(album, lib, fmt=fmt) - continue - for item in _missing(album): - print_obj(item, lib, fmt=fmt) + else: + for item in _missing(album): + print_obj(item, lib, fmt=fmt) self._command.func = _miss return [self._command] + + +@MissingPlugin.template_field('missing') +def _tmpl_missing(album): + """Return number of missing items in 'album'. + """ + if isinstance(album, Album): + return _missing_count(album) + else: + return '' diff --git a/beetsplug/random.py b/beetsplug/random.py index dbf1c0b73..f50061bfb 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -19,6 +19,9 @@ from beets.plugins import BeetsPlugin from beets.ui import Subcommand, decargs, print_obj from beets.util.functemplate import Template import random +from operator import attrgetter +from itertools import groupby +import collections def random_item(lib, opts, args): query = decargs(args) @@ -32,8 +35,33 @@ def random_item(lib, opts, args): objs = list(lib.albums(query=query)) else: objs = list(lib.items(query=query)) - number = min(len(objs), opts.number) - objs = random.sample(objs, number) + + if opts.equal_chance: + # Group the objects by artist so we can sample from them. + key = attrgetter('albumartist') + objs.sort(key=key) + objs_by_artists = {artist: list(v) for artist, v in groupby(objs, key)} + + objs = [] + for _ in range(opts.number): + # Terminate early if we're out of objects to select. + if not objs_by_artists: + break + + # Choose an artist and an object for that artist, removing + # this choice from the pool. + artist = random.choice(objs_by_artists.keys()) + objs_from_artist = objs_by_artists[artist] + i = random.randint(0, len(objs_from_artist) - 1) + objs.append(objs_from_artist.pop(i)) + + # Remove the artist if we've used up all of its objects. + if not objs_from_artist: + del objs_by_artists[artist] + + else: + number = min(len(objs), opts.number) + objs = random.sample(objs, number) for item in objs: print_obj(item, lib, template) @@ -48,6 +76,8 @@ 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.parser.add_option('-e', '--equal-chance', action='store_true', + help='each artist has the same chance') random_cmd.func = random_item class Random(BeetsPlugin): diff --git a/docs/changelog.rst b/docs/changelog.rst index 700936cdc..611169e85 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,8 +4,10 @@ Changelog 1.1.1 (in development) ---------------------- +* New :doc:`/plugins/duplicates`: Find tracks or albums in your + library that are **duplicated**. Thanks to Pedro Silva. * New :doc:`/plugins/missing`: Find albums in your library that are **missing - tracks**. Thanks to Pedro Silva. + tracks**. Thanks once more to Pedro Silva. * New :doc:`/plugins/discogs`: Extends the autotagger to include matches from the `discogs`_ database. * Your library now keeps track of **when music was added** to it. The new @@ -16,6 +18,9 @@ Changelog **numeric ranges**. For example, you can get a list of albums from the '90s by typing ``beet ls year:1990..1999`` or find high-bitrate music with ``bitrate:128000..``. See :ref:`numericquery`. Thanks to Michael Schuerig. +* :doc:`/plugins/random`: A new ``-e`` option gives an equal chance to each + artist in your collection to avoid biasing random samples to prolific + artists. Thanks to Georges Dubus. * The :ref:`modify-cmd` now correctly converts types when modifying non-string fields. You can now safely modify the "comp" flag and the "year" field, for example. Thanks to Lucas Duailibe. @@ -23,6 +28,11 @@ Changelog Thanks to jayme on GitHub. * :doc:`/plugins/lyrics`: Lyrics searches should now turn up more results due to some fixes in dealing with special characters. +* Plugin-provided template fields now work for both Albums and Items. Thanks + to Pedro Silva. +* The :ref:`fields-cmd` command shows template fields provided by plugins. + Thanks again to Pedro Silva. +* Album art filenames now respect the :ref:`replace` configuration. .. _discogs: http://discogs.com/ diff --git a/docs/guides/advanced.rst b/docs/guides/advanced.rst new file mode 100644 index 000000000..fec105e4d --- /dev/null +++ b/docs/guides/advanced.rst @@ -0,0 +1,106 @@ +Advanced Awesomeness +==================== + +So you have beets up and running and you've started :doc:`importing your +music `. There's a lot more that beets can do now that it has +cataloged your collection. Here's a few features to get you started. + +Most of these tips involve :doc:`plugins ` and fiddling with +beets' :doc:`configuration `. So use your favorite text +editor create a config file before you continue. + + +Fetch album art, genres, and lyrics +----------------------------------- + +Beets can help you fill in more than just the basic taxonomy metadata that +comes from MusicBrainz. Plugins can provide :doc:`album art +`, :doc:`lyrics `, and +:doc:`genres ` from databases around the Web. + +If you want beets to get any of this data automatically during the import +process, just enable any of the three relevant plugins (see +:ref:`using-plugins`). For example, put this line in your :doc:`config file +` to enable all three:: + + plugins: fetchart lyrics lastgenre + +Each plugin also has a command you can run to fetch data manually. For +example, if you want to get lyrics for all the Beatles tracks in your +collection, just type ``beet lyrics beatles`` after enabling the plugin. + +Read more about using each of these plugins: + +* :doc:`/plugins/fetchart` (and its accompanying :doc:`/plugins/embedart`) +* :doc:`/plugins/lyrics` +* :doc:`/plugins/lastgenre` + + +Customize your file and folder names +------------------------------------ + +Beets uses an extremely flexible template system to name the folders and files +that organize your music in your filesystem. Take a look at +:ref:`path-format-config` for the basics: use fields like ``$year`` and +``$title`` to build up a naming scheme. But if you need more flexibility, +there are two features you need to know about: + +* :ref:`Template functions ` are simple expressions you + can use in your path formats to add logic to your names. For example, you + can get an artist's first initial using ``%upper{%left{$albumartist,1}}``. +* If you need more flexibility, the :doc:`/plugins/inline` lets you write + snippets of Python code that generate parts of your filenames. The + equivalent code for getting an artist initial with the *inline* plugin looks + like ``initial: albumartist[0].upper()``. + +If you already have music in your library and want to update their names +according to a new scheme, just run the :ref:`move-cmd` command to rename +everything. + + +Stream your music to another computer +------------------------------------- + +Sometimes it can be really convenient to store your music on one machine and +play it on another. For example, I like to keep my music on a server at home +but play it at work (without copying my whole library locally). The +:doc:`/plugins/web` makes streaming your music easy---it's sort of like having +your own personal Spotify. + +First, enable the ``web`` plugin (see :ref:`using-plugins`). Run the server by +typing ``beet web`` and head to http://localhost:8337 in a browser. You can +browse your collection with queries and, if your browser supports it, play +music using HTML5 audio. + +But for a great listening experience, pair beets with the `Tomahawk`_ music +player. Tomahawk lets you listen to music from many different sources, +including a beets server. Just download Tomahawk and open its settings to +connect it to beets. `A post on the beets blog`_ has a more detailed guide. + +.. _A post on the beets blog: + http://beets.radbox.org/blog/tomahawk-resolver.html +.. _Tomahawk: http://www.tomahawk-player.org + + +Transcode music files for media players +--------------------------------------- + +Do you ever find yourself transcoding high-quality rips to a lower-bitrate, +lossy format for your phone or music player? Beets can help with that. + +You'll first need to install `ffmpeg`_. Then, enable beets' +:doc:`/plugins/convert`. Set a destination directory in your +:doc:`config file ` like so:: + + convert: + dest: ~/converted_music + +Then, use the command ``beet convert QUERY`` to transcode everything matching +the query and drop the resulting files in that directory, named according to +your path formats. For example, ``beet convert long winters`` will move over +everything by the Long Winters for listening on the go. + +The plugin has many more dials you can fiddle with to get your conversions how +you like them. Check out :doc:`its documentation `. + +.. _ffmpeg: http://www.ffmpeg.org diff --git a/docs/guides/index.rst b/docs/guides/index.rst index d9ec06d9b..9269b249e 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -10,4 +10,5 @@ guide. main tagger + advanced migration diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 84124add1..4a1b55191 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -152,9 +152,8 @@ metadata for every album you import. Option (a) is really fast, but option (b) makes sure all your songs' tags are exactly right from the get-go. The point about speed bears repeating: using the autotagger on a large library can take a very long time, and it's an interactive process. So set aside a good chunk of -time if you're going to go that route. (I'm working on improving the -autotagger's performance and automation.) For more information on the -interactive tagging process, see :doc:`tagger`. +time if you're going to go that route. For more on the interactive +tagging process, see :doc:`tagger`. If you've got time and want to tag all your music right once and for all, do this:: @@ -228,35 +227,14 @@ you have:: Artists: 548 Albums: 1094 -Playing Music -------------- - -Beets is primarily intended as a music organizer, not a player. It's designed to -be used in conjunction with other players (consider `Decibel`_ or `cmus`_; -there's even :ref:`a cmus plugin for beets `). However, it does -include a simple music player---it doesn't have a ton of features, but it gets -the job done. - -.. _Decibel: http://decibel.silent-blade.org/ -.. _cmus: http://cmus.sourceforge.net/ - -The player, called BPD, is a clone of an excellent music player called `MPD`_. -Like MPD, it runs as a daemon (i.e., without a user interface). Another program, -called an MPD client, controls the player and provides the user with an -interface. You'll need to enable the BPD plugin before you can use it. Check out -:doc:`/plugins/bpd`. - -.. _MPD: http://mpd.wikia.com/ - -You can, of course, use the bona fide MPD server with your beets library. MPD is -a great player and has more features than BPD. BPD just provides a convenient, -built-in player that integrates tightly with your beets database. - Keep Playing ------------ -The :doc:`/reference/cli` page has more detailed description of all of beets' -functionality. (Like deleting music! That's important.) Start exploring! +This is only the beginning of your long and prosperous journey with beets. To +keep learning, take a look at :doc:`advanced` for a sampling of what else +is possible. You'll also want to glance over the :doc:`/reference/cli` page +for a more detailed description of all of beets' functionality. (Like +deleting music! That's important.) Also, check out :ref:`included-plugins` as well as :ref:`other-plugins`. The real power of beets is in its extensibility---with plugins, beets can do almost diff --git a/docs/plugins/duplicates.rst b/docs/plugins/duplicates.rst new file mode 100644 index 000000000..68edbf325 --- /dev/null +++ b/docs/plugins/duplicates.rst @@ -0,0 +1,105 @@ +Duplicates Plugin +================= + +This plugin adds a new command, ``duplicates`` or ``dup``, which finds +and lists duplicate tracks or albums in your collection. + +Installation +------------ + +Enable the plugin by putting ``duplicates`` on your ``plugins`` line in +your :doc:`config file `:: + + plugins: duplicates + +Configuration +------------- + +By default, the ``beet duplicates`` command lists the names of tracks +in your library that are duplicates. It assumes that Musicbrainz track +and album ids are unique to each track or album. That is, it lists +every track or album with an ID that has been seen before in the +library. + +You can customize the output format, count the number of duplicate +tracks or albums, and list all tracks that have duplicates or just the +duplicates themselves. These options can either be specified in the +config file:: + + duplicates: + format: $albumartist - $album - $title + count: no + album: no + full: no + +or on the command-line:: + + -f FORMAT, --format=FORMAT + print with custom FORMAT + -c, --count count duplicate tracks or + albums + -a, --album show duplicate albums instead + of tracks + -F, --full show all versions of duplicate + tracks or albums + +format +~~~~~~ + +The ``format`` option (default: :ref:`list_format_item`) lets you +specify a specific format with which to print every track or +album. This uses the same template syntax as beets’ :doc:`path formats +`. The usage is inspired by, and therefore +similar to, the :ref:`list ` command. + +count +~~~~~ + +The ``count`` option (default: false) prints a count of duplicate +tracks or albums, with ``format`` hard-coded to ``$albumartist - +$album - $title: $count`` or ``$albumartist - $album: $count`` (for +the ``-a`` option). + +album +~~~~~ + +The ``album`` option (default: false) lists duplicate albums instead +of tracks. + +full +~~~~ + +The ``full`` option (default: false) lists every track or album that +has duplicates, not just the duplicates themselves. + +Examples +-------- + +List all duplicate tracks in your collection:: + + beet duplicates + +List all duplicate tracks from 2008:: + + beet duplicates year:2008 + +Print out a unicode histogram of duplicate track years using `spark`_:: + + beet duplicates -f '$year' | spark + ▆▁▆█▄▇▇▄▇▇▁█▇▆▇▂▄█▁██▂█▁▁██▁█▂▇▆▂▇█▇▇█▆▆▇█▇█▇▆██▂▇ + +Print out a listing of all albums with duplicate tracks, and respective counts:: + + beet duplicates -ac + +The same as the above but include the original album, and show the path:: + + beet duplicates -acf '$path' + + +TODO +---- + +- Allow deleting duplicates. + +.. _spark: https://github.com/holman/spark diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 6f7aacc20..5db83e8e1 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -5,6 +5,8 @@ Plugins can extend beets' core functionality. Plugins can add new commands to the command-line interface, respond to events in beets, augment the autotagger, or provide new path template functions. +.. _using-plugins: + Using Plugins ------------- @@ -62,8 +64,9 @@ disabled by default, but you can turn them on as described above. smartplaylist mbsync missing + duplicates discogs - + Autotagger Extensions '''''''''''''''''''''' @@ -114,7 +117,8 @@ Miscellaneous a different directory. * :doc:`info`: Print music files' tags to the console. * :doc:`missing`: List missing tracks. - +* :doc:`duplicates`: List duplicate tracks or albums. + .. _MPD: http://mpd.wikia.com/ .. _MPD clients: http://mpd.wikia.com/wiki/Clients @@ -149,5 +153,5 @@ plugins `. .. toctree:: :hidden: - + writing diff --git a/docs/plugins/missing.rst b/docs/plugins/missing.rst index 8789d143f..c128b84b8 100644 --- a/docs/plugins/missing.rst +++ b/docs/plugins/missing.rst @@ -2,9 +2,10 @@ Missing Plugin ============== This plugin adds a new command, ``missing`` or ``miss``, which finds -and lists, for every album in your collection, which tracks are -missing. Listing missing files requires one network call to -MusicBrainz. +and lists, for every album in your collection, which or how many +tracks are missing. Listing missing files requires one network call to +MusicBrainz. Merely counting missing files avoids any network calls. + Installation ------------ @@ -49,8 +50,9 @@ inspired by, and therefore similar to, the :ref:`list ` command. count ~~~~~ -The ``count`` option (default: false) prints a count of missing -tracks per album, with ``format`` hard-coded to ``'$album: $count'``. +The ``count`` option (default: false) prints a count of missing tracks +per album, with ``format`` defaulting to ``$albumartist - $album: +$missing``. total ~~~~~ @@ -58,6 +60,11 @@ total The ``total`` option (default: false) prints a single count of missing tracks in all albums +Template Fields +--------------- + +With this plugin enabled, the ``$missing`` template field expands to the +number of tracks missing from each album. Examples -------- @@ -83,6 +90,9 @@ Print out a count of the total number of missing tracks:: beet missing -t +Call this plugin from other beet commands:: + + beet ls -a -f '$albumartist - $album: $missing' TODO ---- diff --git a/docs/plugins/random.rst b/docs/plugins/random.rst index a7fac2579..59e4b9483 100644 --- a/docs/plugins/random.rst +++ b/docs/plugins/random.rst @@ -16,6 +16,10 @@ 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``. +If the ``-e`` option is passed, the random choice will be even among +artists (the albumartist field). This makes sure that your anthology +of Bob Dylan won't make you listen to Bob Dylan 50% of the time. + 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``. diff --git a/docs/plugins/writing.rst b/docs/plugins/writing.rst index d26e64cd8..7c6b20245 100644 --- a/docs/plugins/writing.rst +++ b/docs/plugins/writing.rst @@ -243,24 +243,28 @@ This plugin provides a function ``%initial`` to path templates where ``%initial{$artist}`` expands to the artist's initial (its capitalized first character). -Plugins can also add template *fields*, which are computed values referenced as -``$name`` in templates. To add a new field, decorate a function taking a single -parameter, ``item``, with ``MyPlugin.template_field("name")``. Here's an example -that adds a ``$disc_and_track`` field:: +Plugins can also add template *fields*, which are computed values referenced +as ``$name`` in templates. To add a new field, decorate a function taking a +single parameter, which may be an Item or an Album, with +``MyPlugin.template_field("name")``. Here's an example that adds a +``$disc_and_track`` field:: @MyPlugin.template_field('disc_and_track') - def _tmpl_disc_and_track(item): + def _tmpl_disc_and_track(obj): """Expand to the disc number and track number if this is a multi-disc release. Otherwise, just exapnds to the track number. """ - if item.disctotal > 1: - return u'%02i.%02i' % (item.disc, item.track) + if isinstance(obj, beets.library.Album): + return u'' + if obj.disctotal > 1: + return u'%02i.%02i' % (obj.disc, obj.track) else: - return u'%02i' % (item.track) + return u'%02i' % (obj.track) With this plugin enabled, templates can reference ``$disc_and_track`` as they -can any standard metadata field. +can any standard metadata field. Since the field is only meaningful for Items, +it expands to the empty string when used in an Album context. Extend MediaFile ^^^^^^^^^^^^^^^^ diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 575c92968..ff8d0f8a8 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -199,6 +199,8 @@ overridden with ``-w`` (write tags, the default) and ``-W`` (don't write tags). Finally, this command politely asks for your permission before making any changes, but you can skip that prompt with the ``-y`` switch. +.. _move-cmd: + move ```` :: @@ -246,6 +248,8 @@ Show some statistics on your entire library (if you don't provide a The ``-e`` (``--exact``) option makes the calculation of total file size more accurate but slower. +.. _fields-cmd: + fields `````` :: @@ -253,8 +257,7 @@ fields beet fields Show the item and album metadata fields available for use in :doc:`query` and -:doc:`pathformat`. - +:doc:`pathformat`. Includes any template fields provided by plugins. Global Flags ------------ diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 723819182..f037b4395 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -43,6 +43,8 @@ probably don't want that! So use ``$albumartist``. As a convenience, however, beets allows ``$albumartist`` to fall back to the value for ``$artist`` and vice-versa if one tag is present but the other is not. +.. _template-functions: + Functions --------- @@ -71,7 +73,7 @@ These functions are built in to beets: * ``%aunique{identifiers,disambiguators}``: Provides a unique string to disambiguate similar albums in the database. See :ref:`aunique`, below. * ``%time{date_time,format}``: Return the date and time in any format accepted - by `strfime`_. For example, to get the year some music was added to your + by `strftime`_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. .. _unidecode module: http://pypi.python.org/pypi/Unidecode diff --git a/test/test_db.py b/test/test_db.py index 6dc822e63..285a136be 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -29,6 +29,7 @@ from _common import item import beets.library from beets import util from beets import plugins +from beets import config TEMP_LIB = os.path.join(_common.RSRC, 'test_copy.blb') @@ -835,10 +836,13 @@ class BaseAlbumTest(_common.TestCase): class ArtDestinationTest(_common.TestCase): def setUp(self): super(ArtDestinationTest, self).setUp() - self.lib = beets.library.Library(':memory:') + config['art_filename'] = u'artimage' + config['replace'] = {u'X': u'Y'} + self.lib = beets.library.Library( + ':memory:', replacements=[(re.compile(u'X'), u'Y')] + ) self.i = item() self.i.path = self.lib.destination(self.i) - self.lib.art_filename = 'artimage' self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): @@ -850,6 +854,11 @@ class ArtDestinationTest(_common.TestCase): track = self.lib.destination(self.i) self.assertEqual(os.path.dirname(art), os.path.dirname(track)) + def test_art_path_sanitized(self): + config['art_filename'] = u'artXimage' + art = self.ai.art_destination('something.jpg') + self.assert_('artYimage' in art) + class PathStringTest(_common.TestCase): def setUp(self): super(PathStringTest, self).setUp() diff --git a/test/test_ui.py b/test/test_ui.py index 0a286d125..c9d57a466 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -478,7 +478,7 @@ class ConfigTest(_common.TestCase): if config_yaml: config_data = yaml.load(config_yaml, Loader=confit.Loader) config.set(config_data) - ui._raw_main(args + ['test'], False) + ui._raw_main(args + ['test']) def test_paths_section_respected(self): def func(lib, opts, args):