diff --git a/beets/config_default.yaml b/beets/config_default.yaml index ba58debe7..545fa9638 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -65,6 +65,7 @@ ui: format_item: $artist - $album - $title format_album: $albumartist - $album time_format: '%Y-%m-%d %H:%M:%S' +format_raw_length: no sort_album: albumartist+ album+ sort_item: artist+ album+ disc+ track+ diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index d54e3a0d3..202cf006f 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -467,6 +467,11 @@ class Model(object): return cls._type(key).parse(string) + def set_parse(self, key, string): + """Set the object's key to a value represented by a string. + """ + self[key] = self._parse(key, string) + # Database controller and supporting interfaces. diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index f0adac665..d8a3a0ea8 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -653,6 +653,33 @@ class DateQuery(FieldQuery): return clause, subvals +class DurationQuery(NumericQuery): + """NumericQuery that allow human-friendly (M:SS) time interval formats. + + Converts the range(s) to a float value, and delegates on NumericQuery. + + Raises InvalidQueryError when the pattern does not represent an int, float + or M:SS time interval. + """ + def _convert(self, s): + """Convert a M:SS or numeric string to a float. + + Return None if `s` is empty. + Raise an InvalidQueryError if the string cannot be converted. + """ + if not s: + return None + try: + return util.raw_seconds_short(s) + except ValueError: + try: + return float(s) + except ValueError: + raise InvalidQueryArgumentTypeError( + s, + "a M:SS string or a float") + + # Sorting. class Sort(object): diff --git a/beets/library.py b/beets/library.py index 870c46856..f8d226dbe 100644 --- a/beets/library.py +++ b/beets/library.py @@ -195,6 +195,28 @@ class MusicalKey(types.String): return self.parse(key) +class DurationType(types.Float): + """Human-friendly (M:SS) representation of a time interval.""" + query = dbcore.query.DurationQuery + + def format(self, value): + if not beets.config['format_raw_length'].get(bool): + return beets.ui.human_seconds_short(value or 0.0) + else: + return value + + def parse(self, string): + try: + # Try to format back hh:ss to seconds. + return util.raw_seconds_short(string) + except ValueError: + # Fall back to a plain float. + try: + return float(string) + except ValueError: + return self.null + + # Library-specific sort types. class SmartArtistSort(dbcore.query.Sort): @@ -426,7 +448,7 @@ class Item(LibModel): 'original_day': types.PaddedInt(2), 'initial_key': MusicalKey(), - 'length': types.FLOAT, + 'length': DurationType(), 'bitrate': types.ScaledInt(1000, u'kbps'), 'format': types.STRING, 'samplerate': types.ScaledInt(1000, u'kHz'), diff --git a/beets/util/__init__.py b/beets/util/__init__.py index e2d09c3ab..55c599a05 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -843,3 +843,16 @@ def case_sensitive(path): lower = _windows_long_path_name(path.lower()) upper = _windows_long_path_name(path.upper()) return lower != upper + + +def raw_seconds_short(string): + """Formats a human-readable M:SS string as a float (number of seconds). + + Raises ValueError if the conversion cannot take place due to `string` not + being in the right format. + """ + match = re.match('^(\d+):([0-5]\d)$', string) + if not match: + raise ValueError('String not in M:SS format') + minutes, seconds = map(int, match.groups()) + return float(minutes * 60 + seconds) diff --git a/beetsplug/edit.py b/beetsplug/edit.py new file mode 100644 index 000000000..8d6b168f5 --- /dev/null +++ b/beetsplug/edit.py @@ -0,0 +1,326 @@ +# This file is part of beets. +# Copyright 2015 +# +# 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. + +"""Open metadata information in a text editor to let the user edit it. +""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +from beets import plugins +from beets import util +from beets import ui +from beets.dbcore import types +from beets.ui.commands import _do_query +import subprocess +import yaml +from tempfile import NamedTemporaryFile +import os + + +# These "safe" types can avoid the format/parse cycle that most fields go +# through: they are safe to edit with native YAML types. +SAFE_TYPES = (types.Float, types.Integer, types.Boolean) + + +class ParseError(Exception): + """The modified file is unreadable. The user should be offered a chance to + fix the error. + """ + + +def edit(filename): + """Open `filename` in a text editor. + """ + cmd = util.shlex_split(util.editor_command()) + cmd.append(filename) + subprocess.call(cmd) + + +def dump(arg): + """Dump a sequence of dictionaries as YAML for editing. + """ + return yaml.safe_dump_all( + arg, + allow_unicode=True, + default_flow_style=False, + ) + + +def load(s): + """Read a sequence of YAML documents back to a list of dictionaries + with string keys. + + Can raise a `ParseError`. + """ + try: + out = [] + for d in yaml.load_all(s): + if not isinstance(d, dict): + raise ParseError( + 'each entry must be a dictionary; found {}'.format( + type(d).__name__ + ) + ) + + # Convert all keys to strings. They started out as strings, + # but the user may have inadvertently messed this up. + out.append({unicode(k): v for k, v in d.items()}) + + except yaml.YAMLError as e: + raise ParseError('invalid YAML: {}'.format(e)) + return out + + +def _safe_value(obj, key, value): + """Check whether the `value` is safe to represent in YAML and trust as + returned from parsed YAML. + + This ensures that values do not change their type when the user edits their + YAML representation. + """ + typ = obj._type(key) + return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) + + +def flatten(obj, fields): + """Represent `obj`, a `dbcore.Model` object, as a dictionary for + serialization. Only include the given `fields` if provided; + otherwise, include everything. + + The resulting dictionary's keys are strings and the values are + safely YAML-serializable types. + """ + # Format each value. + d = {} + for key in obj.keys(): + value = obj[key] + if _safe_value(obj, key, value): + # A safe value that is faithfully representable in YAML. + d[key] = value + else: + # A value that should be edited as a string. + d[key] = obj.formatted()[key] + + # Possibly filter field names. + if fields: + return {k: v for k, v in d.items() if k in fields} + else: + return d + + +def apply(obj, data): + """Set the fields of a `dbcore.Model` object according to a + dictionary. + + This is the opposite of `flatten`. The `data` dictionary should have + strings as values. + """ + for key, value in data.items(): + if _safe_value(obj, key, value): + # A safe value *stayed* represented as a safe type. Assign it + # directly. + obj[key] = value + else: + # Either the field was stringified originally or the user changed + # it from a safe type to an unsafe one. Parse it as a string. + obj.set_parse(key, unicode(value)) + + +class EditPlugin(plugins.BeetsPlugin): + + def __init__(self): + super(EditPlugin, self).__init__() + + self.config.add({ + # The default fields to edit. + 'albumfields': 'album albumartist', + 'itemfields': 'track title artist album', + + # Silently ignore any changes to these fields. + 'ignore_fields': 'id path', + }) + + def commands(self): + edit_command = ui.Subcommand( + 'edit', + help='interactively edit metadata' + ) + edit_command.parser.add_option( + '-f', '--field', + metavar='FIELD', + action='append', + help='edit this field also', + ) + edit_command.parser.add_option( + '--all', + action='store_true', dest='all', + help='edit all fields', + ) + edit_command.parser.add_album_option() + edit_command.func = self._edit_command + return [edit_command] + + def _edit_command(self, lib, opts, args): + """The CLI command function for the `beet edit` command. + """ + # Get the objects to edit. + query = ui.decargs(args) + items, albums = _do_query(lib, query, opts.album, False) + objs = albums if opts.album else items + if not objs: + ui.print_('Nothing to edit.') + return + + # Get the fields to edit. + if opts.all: + fields = None + else: + fields = self._get_fields(opts.album, opts.field) + self.edit(opts.album, objs, fields) + + def _get_fields(self, album, extra): + """Get the set of fields to edit. + """ + # Start with the configured base fields. + if album: + fields = self.config['albumfields'].as_str_seq() + else: + fields = self.config['itemfields'].as_str_seq() + + # Add the requested extra fields. + if extra: + fields += extra + + # Ensure we always have the `id` field for identification. + fields.append('id') + + return set(fields) + + def edit(self, album, objs, fields): + """The core editor function. + + - `album`: A flag indicating whether we're editing Items or Albums. + - `objs`: The `Item`s or `Album`s to edit. + - `fields`: The set of field names to edit (or None to edit + everything). + """ + # Present the YAML to the user and let her change it. + success = self.edit_objects(objs, fields) + + # Save the new data. + if success: + self.save_write(objs) + + def edit_objects(self, objs, fields): + """Dump a set of Model objects to a file as text, ask the user + to edit it, and apply any changes to the objects. + + Return a boolean indicating whether the edit succeeded. + """ + # Get the content to edit as raw data structures. + old_data = [flatten(o, fields) for o in objs] + + # Set up a temporary file with the initial data for editing. + new = NamedTemporaryFile(suffix='.yaml', delete=False) + old_str = dump(old_data) + new.write(old_str) + new.close() + + # Loop until we have parseable data and the user confirms. + try: + while True: + # Ask the user to edit the data. + edit(new.name) + + # Read the data back after editing and check whether anything + # changed. + with open(new.name) as f: + new_str = f.read() + if new_str == old_str: + ui.print_("No changes; aborting.") + return False + + # Parse the updated data. + try: + new_data = load(new_str) + except ParseError as e: + ui.print_("Could not read data: {}".format(e)) + if ui.input_yn("Edit again to fix? (Y/n)", True): + continue + else: + return False + + # Show the changes. + self.apply_data(objs, old_data, new_data) + changed = False + for obj in objs: + changed |= ui.show_model_changes(obj) + if not changed: + ui.print_('No changes to apply.') + return False + + # Confirm the changes. + choice = ui.input_options( + ('continue Editing', 'apply', 'cancel') + ) + if choice == 'a': # Apply. + return True + elif choice == 'c': # Cancel. + return False + elif choice == 'e': # Keep editing. + # Reset the temporary changes to the objects. + for obj in objs: + obj.read() + continue + + # Remove the temporary file before returning. + finally: + os.remove(new.name) + + def apply_data(self, objs, old_data, new_data): + """Take potentially-updated data and apply it to a set of Model + objects. + + The objects are not written back to the database, so the changes + are temporary. + """ + if len(old_data) != len(new_data): + self._log.warn('number of objects changed from {} to {}', + len(old_data), len(new_data)) + + obj_by_id = {o.id: o for o in objs} + ignore_fields = self.config['ignore_fields'].as_str_seq() + for old_dict, new_dict in zip(old_data, new_data): + # Prohibit any changes to forbidden fields to avoid + # clobbering `id` and such by mistake. + forbidden = False + for key in ignore_fields: + if old_dict.get(key) != new_dict.get(key): + self._log.warn('ignoring object whose {} changed', key) + forbidden = True + break + if forbidden: + continue + + id = int(old_dict['id']) + apply(obj_by_id[id], new_dict) + + def save_write(self, objs): + """Save a list of updated Model objects to the database. + """ + # Save to the database and possibly write tags. + for ob in objs: + if ob._dirty: + self._log.debug('saving changes to {}', ob) + ob.try_sync(ui.should_write()) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 1b2089b58..a56f9f95a 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -157,34 +157,6 @@ class AlbumArtOrg(ArtSource): self._log.debug(u'no image found on page') -class GoogleImages(ArtSource): - URL = 'https://ajax.googleapis.com/ajax/services/search/images' - - def get(self, album): - """Return art URL from google.org given an album title and - interpreter. - """ - if not (album.albumartist and album.album): - return - search_string = (album.albumartist + ',' + album.album).encode('utf-8') - response = self.request(self.URL, params={ - 'v': '1.0', - 'q': search_string, - 'start': '0', - }) - - # Get results using JSON. - try: - results = response.json() - data = results['responseData'] - dataInfo = data['results'] - for myUrl in dataInfo: - yield myUrl['unescapedUrl'] - except: - self._log.debug(u'error scraping art page') - return - - class ITunesStore(ArtSource): # Art from the iTunes Store. def get(self, album): @@ -196,11 +168,19 @@ class ITunesStore(ArtSource): try: # Isolate bugs in the iTunes library while searching. try: - itunes_album = itunes.search_album(search_string)[0] + results = itunes.search_album(search_string) except Exception as exc: self._log.debug('iTunes search failed: {0}', exc) return + # Get the first match. + if results: + itunes_album = results[0] + else: + self._log.debug('iTunes search for {:r} got no results', + search_string) + return + if itunes_album.get_artwork()['100']: small_url = itunes_album.get_artwork()['100'] big_url = small_url.replace('100x100', '1200x1200') @@ -380,7 +360,7 @@ class FileSystem(ArtSource): # Try each source in turn. -SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'google', +SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'wikipedia'] ART_SOURCES = { @@ -388,7 +368,6 @@ ART_SOURCES = { u'itunes': ITunesStore, u'albumart': AlbumArtOrg, u'amazon': Amazon, - u'google': GoogleImages, u'wikipedia': Wikipedia, } diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 0bd49589c..1af34df99 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -454,28 +454,32 @@ class Google(Backend): BY_TRANS = ['by', 'par', 'de', 'von'] LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte'] - def is_page_candidate(self, urlLink, urlTitle, title, artist): + def is_page_candidate(self, url_link, url_title, title, artist): """Return True if the URL title makes it a good candidate to be a page that contains lyrics of title by artist. """ title = self.slugify(title.lower()) artist = self.slugify(artist.lower()) sitename = re.search(u"//([^/]+)/.*", - self.slugify(urlLink.lower())).group(1) - urlTitle = self.slugify(urlTitle.lower()) + self.slugify(url_link.lower())).group(1) + url_title = self.slugify(url_title.lower()) + # Check if URL title contains song title (exact match) - if urlTitle.find(title) != -1: + if url_title.find(title) != -1: return True + # or try extracting song title from URL title and check if # they are close enough tokens = [by + '_' + artist for by in self.BY_TRANS] + \ [artist, sitename, sitename.replace('www.', '')] + \ self.LYRICS_TRANS - songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle) - songTitle = songTitle.strip('_|') - typoRatio = .9 - ratio = difflib.SequenceMatcher(None, songTitle, title).ratio() - return ratio >= typoRatio + tokens = [re.escape(t) for t in tokens] + song_title = re.sub(u'(%s)' % u'|'.join(tokens), u'', url_title) + + song_title = song_title.strip('_|') + typo_ratio = .9 + ratio = difflib.SequenceMatcher(None, song_title, title).ratio() + return ratio >= typo_ratio def fetch(self, artist, title): query = u"%s %s" % (artist, title) @@ -492,12 +496,12 @@ class Google(Backend): if 'items' in data.keys(): for item in data['items']: - urlLink = item['link'] - urlTitle = item.get('title', u'') - if not self.is_page_candidate(urlLink, urlTitle, + url_link = item['link'] + url_title = item.get('title', u'') + if not self.is_page_candidate(url_link, url_title, title, artist): continue - html = self.fetch_url(urlLink) + html = self.fetch_url(url_link) lyrics = scrape_lyrics_from_html(html) if not lyrics: continue diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 4636b477d..4e37ad7ff 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -45,9 +45,6 @@ _MUTAGEN_FORMATS = { } -scrubbing = False - - class ScrubPlugin(BeetsPlugin): """Removes extraneous metadata from files' tags.""" def __init__(self): @@ -55,44 +52,17 @@ class ScrubPlugin(BeetsPlugin): self.config.add({ 'auto': True, }) - self.register_listener("write", self.write_item) + + if self.config['auto']: + self.register_listener("import_task_files", self.import_task_files) def commands(self): def scrub_func(lib, opts, args): - # This is a little bit hacky, but we set a global flag to - # avoid autoscrubbing when we're also explicitly scrubbing. - global scrubbing - scrubbing = True - # Walk through matching files and remove tags. for item in lib.items(ui.decargs(args)): self._log.info(u'scrubbing: {0}', util.displayable_path(item.path)) - - # Get album art if we need to restore it. - if opts.write: - try: - mf = mediafile.MediaFile(util.syspath(item.path), - config['id3v23'].get(bool)) - except IOError as exc: - self._log.error(u'could not open file to scrub: {0}', - exc) - art = mf.art - - # Remove all tags. - self._scrub(item.path) - - # Restore tags, if enabled. - if opts.write: - self._log.debug(u'writing new tags after scrub') - item.try_write() - if art: - self._log.info(u'restoring art') - mf = mediafile.MediaFile(util.syspath(item.path)) - mf.art = art - mf.save() - - scrubbing = False + self._scrub_item(item, opts.write) scrub_cmd = ui.Subcommand('scrub', help='clean audio tags') scrub_cmd.parser.add_option('-W', '--nowrite', dest='write', @@ -140,8 +110,36 @@ class ScrubPlugin(BeetsPlugin): self._log.error(u'could not scrub {0}: {1}', util.displayable_path(path), exc) - def write_item(self, item, path, tags): - """Automatically embed art into imported albums.""" - if not scrubbing and self.config['auto']: - self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path)) - self._scrub(path) + def _scrub_item(self, item, restore=True): + """Remove tags from an Item's associated file and, if `restore` + is enabled, write the database's tags back to the file. + """ + # Get album art if we need to restore it. + if restore: + try: + mf = mediafile.MediaFile(util.syspath(item.path), + config['id3v23'].get(bool)) + except IOError as exc: + self._log.error(u'could not open file to scrub: {0}', + exc) + art = mf.art + + # Remove all tags. + self._scrub(item.path) + + # Restore tags, if enabled. + if restore: + self._log.debug(u'writing new tags after scrub') + item.try_write() + if art: + self._log.debug(u'restoring art') + mf = mediafile.MediaFile(util.syspath(item.path)) + mf.art = art + mf.save() + + def import_task_files(self, session, task): + """Automatically scrub imported files.""" + for item in task.imported_items(): + self._log.debug(u'auto-scrubbing {0}', + util.displayable_path(item.path)) + self._scrub_item(item) diff --git a/docs/changelog.rst b/docs/changelog.rst index eceda093c..7f07bbb1c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,10 @@ Changelog 1.3.16 (in development) ----------------------- +* A new plugin edit helps you manually edit fields from items. + You search for items in the normal beets way.Then edit opens a texteditor + with the items and the fields of the items you want to edit. Afterwards you can + review your changes save them back into the items. New: @@ -26,6 +30,11 @@ New: singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` * :doc:`/plugins/info`: The plugin now accepts the ``-f/--format`` option for customizing how items are displayed. :bug:`1737` +* Track length is now displayed as ``M:SS`` by default, instead of displaying + the raw number of seconds. Queries on track length also accept this format: + for example, ``beet list length:5:30..`` will find all your tracks that have + a duration over 5 minutes and 30 seconds. You can turn off this new behavior + using the ``format_raw_length`` configuration option. :bug:`1749` For developers: @@ -73,6 +82,16 @@ Fixes: ImageMagick on Windows. :bug:`1721` * Fix a crash when writing some Unicode comment strings to MP3s that used older encodings. The encoding is now always updated to UTF-8. :bug:`879` +* :doc:`/plugins/fetchart`: The Google Images backend has been removed. It + used an API that has been shut down. :bug:`1760` +* :doc:`/plugins/lyrics`: Fix a crash in the Google backend when searching for + bands with regular-expression characters in their names, like Sunn O))). + :bug:`1673` +* :doc:`/plugins/scrub`: In ``auto`` mode, the plugin now *actually* only + scrubs files on import---not every time files were written, as it previously + did. :bug:`1657` +* :doc:`/plugins/scrub`: Also in ``auto`` mode, album art is now correctly + restored. :bug:`1657` .. _Emby Server: http://emby.media diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst new file mode 100644 index 000000000..507d56950 --- /dev/null +++ b/docs/plugins/edit.rst @@ -0,0 +1,36 @@ +Edit Plugin +=========== + +The ``edit`` plugin lets you modify music metadata using your favorite text +editor. + +Enable the ``edit`` plugin in your configuration (see :ref:`using-plugins`) and +then type:: + + beet edit QUERY + +Your text editor (i.e., the command in your ``$EDITOR`` environment variable) +will open with a list of tracks to edit. Make your changes and exit your text +editor to apply them to your music. + +Command-Line Options +-------------------- + +The ``edit`` command has these command-line options: + +- ``-a`` or ``--album``: Edit albums instead of individual items. +- ``-f FIELD`` or ``--field FIELD``: Specify an additional field to edit + (in addition to the defaults set in the configuration). +- ``--all``: Edit *all* available fields. + +Configuration +------------- + +To configure the plugin, make an ``edit:`` section in your configuration +file. The available options are: + +- **itemfields**: A space-separated list of item fields to include in the + editor by default. + Default: ``track title artist album`` +- **albumfields**: The same when editing albums (with the ``-a`` option). + Default: ``album albumartist`` diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index c04517b11..80149d478 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -50,7 +50,7 @@ file. The available options are: - **sources**: List of sources to search for images. An asterisk `*` expands to all available sources. Default: ``coverart itunes amazon albumart``, i.e., everything but - ``wikipedia`` and ``google``. Enable those two sources for more matches at + ``wikipedia``. Enable those two sources for more matches at the cost of some speed. Note: ``minwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ @@ -94,7 +94,7 @@ no resizing is performed for album art found on the filesystem---only downloaded art is resized. Server-side resizing can also be slower than local resizing, so consider installing one of the two backends for better performance. -When using ImageMagic, beets looks for the ``convert`` executable in your path. +When using ImageMagick, beets looks for the ``convert`` executable in your path. On some versions of Windows, the program can be shadowed by a system-provided ``convert.exe``. On these systems, you may need to modify your ``%PATH%`` environment variable so that ImageMagick comes first or use Pillow instead. @@ -106,8 +106,9 @@ Album Art Sources ----------------- By default, this plugin searches for art in the local filesystem as well as on -the Cover Art Archive, the iTunes Store, Amazon, AlbumArt.org, -and Google Image Search, and Wikipedia, in that order. You can reorder the sources or remove +the Cover Art Archive, the iTunes Store, Amazon, and AlbumArt.org, in that +order. +You can reorder the sources or remove some to speed up the process using the ``sources`` configuration option. When looking for local album art, beets checks for image files located in the @@ -126,23 +127,16 @@ iTunes Store To use the iTunes Store as an art source, install the `python-itunes`_ library. You can do this using `pip`_, like so:: - $ pip install python-itunes + $ pip install https://github.com/ocelma/python-itunes/archive/master.zip +(There's currently `a problem`_ that prevents a plain ``pip install +python-itunes`` from working.) Once the library is installed, the plugin will use it to search automatically. +.. _a problem: https://github.com/ocelma/python-itunes/issues/9 .. _python-itunes: https://github.com/ocelma/python-itunes .. _pip: http://pip.openplans.org/ -Google Image Search -''''''''''''''''''' - -You can optionally search for cover art on `Google Images`_. This option uses -the first hit for a search query consisting of the artist and album name. It -is therefore approximate: "incorrect" image matches are possible (although -unlikely). - -.. _Google Images: http://images.google.com/ - Embedding Album Art ------------------- diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index d09139837..5da5ee0be 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -40,6 +40,7 @@ Each plugin has its own set of options that can be defined in a section bearing discogs duplicates echonest + edit embedart embyupdate fetchart @@ -96,6 +97,7 @@ Metadata * :doc:`bpm`: Measure tempo using keystrokes. * :doc:`echonest`: Automatically fetch `acoustic attributes`_ from `the Echo Nest`_ (tempo, energy, danceability, ...). +* :doc:`edit`: Edit metadata from a texteditor. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 1349d755e..82a16869d 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -144,6 +144,11 @@ and this command finds MP3 files with bitrates of 128k or lower:: $ beet list format:MP3 bitrate:..128000 +The ``length`` field also lets you use a "M:SS" format. For example, this +query finds tracks that are less than four and a half minutes in length:: + + $ beet list length:..4:30 + .. _datequery: diff --git a/test/rsrc/unicode’d.mp3 b/test/rsrc/unicode’d.mp3 index ef732eba6..a14181941 100644 Binary files a/test/rsrc/unicode’d.mp3 and b/test/rsrc/unicode’d.mp3 differ diff --git a/test/test_art.py b/test/test_art.py index 04bfe3eed..cb29f3769 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -226,38 +226,6 @@ class AAOTest(_common.TestCase): self.assertEqual(list(res), []) -class GoogleImageTest(_common.TestCase): - - _google_url = 'https://ajax.googleapis.com/ajax/services/search/images' - - def setUp(self): - super(GoogleImageTest, self).setUp() - self.source = fetchart.GoogleImages(logger) - - @responses.activate - def run(self, *args, **kwargs): - super(GoogleImageTest, self).run(*args, **kwargs) - - def mock_response(self, url, json): - responses.add(responses.GET, url, body=json, - content_type='application/json') - - def test_google_art_finds_image(self): - album = _common.Bag(albumartist="some artist", album="some album") - json = b"""{"responseData": {"results": - [{"unescapedUrl": "url_to_the_image"}]}}""" - self.mock_response(self._google_url, json) - result_url = self.source.get(album) - self.assertEqual(list(result_url)[0], 'url_to_the_image') - - def test_google_art_dont_finds_image(self): - album = _common.Bag(albumartist="some artist", album="some album") - json = b"""bla blup""" - self.mock_response(self._google_url, json) - result_url = self.source.get(album) - self.assertEqual(list(result_url), []) - - class ArtImporterTest(UseThePlugin): def setUp(self): super(ArtImporterTest, self).setUp() diff --git a/test/test_edit.py b/test/test_edit.py new file mode 100644 index 000000000..ae0500029 --- /dev/null +++ b/test/test_edit.py @@ -0,0 +1,260 @@ +# This file is part of beets. +# Copyright 2015, Adrian Sampson and Diego Moreda. +# +# 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 __future__ import (division, absolute_import, print_function, + unicode_literals) +import codecs + +from mock import patch +from test._common import unittest +from test.helper import TestHelper, control_stdin + + +class ModifyFileMocker(object): + """Helper for modifying a file, replacing or editing its contents. Used for + mocking the calls to the external editor during testing.""" + + def __init__(self, contents=None, replacements=None): + """ `self.contents` and `self.replacements` are initalized here, in + order to keep the rest of the functions of this class with the same + signature as `EditPlugin.get_editor()`, making mocking easier. + - `contents`: string with the contents of the file to be used for + `overwrite_contents()` + - `replacement`: dict with the in-place replacements to be used for + `replace_contents()`, in the form {'previous string': 'new string'} + + TODO: check if it can be solved more elegantly with a decorator + """ + self.contents = contents + self.replacements = replacements + self.action = self.overwrite_contents + if replacements: + self.action = self.replace_contents + + def overwrite_contents(self, filename): + """Modify `filename`, replacing its contents with `self.contents`. If + `self.contents` is empty, the file remains unchanged. + """ + if self.contents: + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(self.contents) + + def replace_contents(self, filename): + """Modify `filename`, reading its contents and replacing the strings + specified in `self.replacements`. + """ + with codecs.open(filename, 'r', encoding='utf8') as f: + contents = f.read() + for old, new_ in self.replacements.iteritems(): + contents = contents.replace(old, new_) + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(contents) + + +class EditCommandTest(unittest.TestCase, TestHelper): + """ Black box tests for `beetsplug.edit`. Command line interaction is + simulated using `test.helper.control_stdin()`, and yaml editing via an + external editor is simulated using `ModifyFileMocker`. + """ + ALBUM_COUNT = 1 + TRACK_COUNT = 10 + + def setUp(self): + self.setup_beets() + self.load_plugins('edit') + # make sure that we avoid invoking the editor except for making changes + self.config['edit']['diff_method'] = '' + # add an album, storing the original fields for comparison + self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) + self.album_orig = {f: self.album[f] for f in self.album._fields} + self.items_orig = [{f: item[f] for f in item._fields} for + item in self.album.items()] + + # keep track of write()s + self.write_patcher = patch('beets.library.Item.write') + self.mock_write = self.write_patcher.start() + + def tearDown(self): + self.write_patcher.stop() + self.teardown_beets() + self.unload_plugins() + + def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): + """Run the edit command, with mocked stdin and yaml writing, and + passing `args` to `run_command`.""" + m = ModifyFileMocker(**modify_file_args) + with patch('beetsplug.edit.edit', side_effect=m.action): + with control_stdin('\n'.join(stdin)): + self.run_command('edit', *args) + + def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, + write_call_count=TRACK_COUNT, title_starts_with=''): + """Several common assertions on Album, Track and call counts.""" + self.assertEqual(len(self.lib.albums()), album_count) + self.assertEqual(len(self.lib.items()), track_count) + self.assertEqual(self.mock_write.call_count, write_call_count) + self.assertTrue(all(i.title.startswith(title_starts_with) + for i in self.lib.items())) + + def assertItemFieldsModified(self, library_items, items, fields=[]): + """Assert that items in the library (`lib_items`) have different values + on the specified `fields` (and *only* on those fields), compared to + `items`. + An empty `fields` list results in asserting that no modifications have + been performed. + """ + changed_fields = [] + for lib_item, item in zip(library_items, items): + changed_fields.append([field for field in lib_item._fields + if lib_item[field] != item[field]]) + self.assertTrue(all(diff_fields == fields for diff_fields in + changed_fields)) + + def test_title_edit_discard(self): + """Edit title for all items in the library, then discard changes-""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # Cancel. + ['c']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) + + def test_title_edit_apply(self): + """Edit title for all items in the library, then apply changes.""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=self.TRACK_COUNT, + title_starts_with=u'modified t\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['title']) + + def test_single_title_edit_apply(self): + """Edit title for one item in the library, then apply changes.""" + # edit title + self.run_mocked_command({'replacements': {u't\u00eftle 9': + u'modified t\u00eftle 9'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=1,) + # no changes except on last item + self.assertItemFieldsModified(list(self.album.items())[:-1], + self.items_orig[:-1], []) + self.assertEqual(list(self.album.items())[-1].title, + u'modified t\u00eftle 9') + + def test_noedit(self): + """Do not edit anything.""" + # do not edit anything + self.run_mocked_command({'contents': None}, + # no stdin + []) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) + + def test_album_edit_apply(self): + """Edit the album field for all items in the library, apply changes. + By design, the album should not be updated."" + """ + # edit album + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) + # ensure album is *not* modified + self.album.load() + self.assertEqual(self.album.album, u'\u00e4lbum') + + def test_single_edit_add_field(self): + """Edit the yaml file appending an extra field to the first item, then + apply changes.""" + # append "foo: bar" to item with id == 1 + self.run_mocked_command({'replacements': {u"id: 1": + u"id: 1\nfoo: bar"}}, + # Apply changes. + ['a']) + + self.assertEqual(self.lib.items('id:1')[0].foo, 'bar') + self.assertCounts(write_call_count=1, + title_starts_with=u't\u00eftle') + + def test_a_album_edit_apply(self): + """Album query (-a), edit album field, apply changes.""" + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # Apply changes. + ['a'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.album, u'modified \u00e4lbum') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) + + def test_a_albumartist_edit_apply(self): + """Album query (-a), edit albumartist field, apply changes.""" + self.run_mocked_command({'replacements': {u'album artist': + u'modified album artist'}}, + # Apply changes. + ['a'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.albumartist, u'the modified album artist') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['albumartist']) + + def test_malformed_yaml(self): + """Edit the yaml file incorrectly (resulting in a malformed yaml + document).""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': '!MALFORMED'}, + # Edit again to fix? No. + ['n']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + def test_invalid_yaml(self): + """Edit the yaml file incorrectly (resulting in a well-formed but + invalid yaml document).""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, + # no stdin + []) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_info.py b/test/test_info.py index aaabed980..4a85b6dc9 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -111,7 +111,7 @@ class InfoTest(unittest.TestCase, TestHelper): self.add_item_fixtures() out = self.run_with_output('--library', '--format', '$track. $title - $artist ($length)') - self.assertEqual(u'02. tïtle 0 - the artist (1.1)\n', out) + self.assertEqual(u'02. tïtle 0 - the artist (0:01)\n', out) def suite(): diff --git a/test/test_library.py b/test/test_library.py index c544e007e..9aeaad719 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -25,6 +25,7 @@ import shutil import re import unicodedata import sys +import time from test import _common from test._common import unittest @@ -1127,6 +1128,58 @@ class ParseQueryTest(unittest.TestCase): beets.library.parse_query_string(b"query", None) +class LibraryFieldTypesTest(unittest.TestCase): + """Test format() and parse() for library-specific field types""" + def test_datetype(self): + t = beets.library.DateType() + + # format + time_local = time.strftime(beets.config['time_format'].get(unicode), + time.localtime(123456789)) + self.assertEqual(time_local, t.format(123456789)) + # parse + self.assertEqual(123456789.0, t.parse(time_local)) + self.assertEqual(123456789.0, t.parse('123456789.0')) + self.assertEqual(t.null, t.parse('not123456789.0')) + self.assertEqual(t.null, t.parse('1973-11-29')) + + def test_pathtype(self): + t = beets.library.PathType() + + # format + self.assertEqual('/tmp', t.format('/tmp')) + self.assertEqual(u'/tmp/\xe4lbum', t.format(u'/tmp/\u00e4lbum')) + # parse + self.assertEqual(b'/tmp', t.parse('/tmp')) + self.assertEqual(b'/tmp/\xc3\xa4lbum', t.parse(u'/tmp/\u00e4lbum/')) + + def test_musicalkey(self): + t = beets.library.MusicalKey() + + # parse + self.assertEqual('C#m', t.parse('c#m')) + self.assertEqual('Gm', t.parse('g minor')) + self.assertEqual('Not c#m', t.parse('not C#m')) + + def test_durationtype(self): + t = beets.library.DurationType() + + # format + self.assertEqual('1:01', t.format(61.23)) + self.assertEqual('60:01', t.format(3601.23)) + self.assertEqual('0:00', t.format(None)) + # parse + self.assertEqual(61.0, t.parse('1:01')) + self.assertEqual(61.23, t.parse('61.23')) + self.assertEqual(3601.0, t.parse('60:01')) + self.assertEqual(t.null, t.parse('1:00:01')) + self.assertEqual(t.null, t.parse('not61.23')) + # config format_raw_length + beets.config['format_raw_length'] = True + self.assertEqual(61.23, t.format(61.23)) + self.assertEqual(3601.23, t.format(3601.23)) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_lyrics.py b/test/test_lyrics.py index 33b8c6bb5..515e96587 100644 --- a/test/test_lyrics.py +++ b/test/test_lyrics.py @@ -376,6 +376,17 @@ class LyricsGooglePluginTest(unittest.TestCase): self.assertEqual(google.is_page_candidate(url, urlTitle, s['title'], s['artist']), False, url) + def test_is_page_candidate_special_chars(self): + """Ensure that `is_page_candidate` doesn't crash when the artist + and such contain special regular expression characters. + """ + # https://github.com/sampsyo/beets/issues/1673 + s = self.source + url = s['url'] + s['path'] + url_title = u'foo' + + google.is_page_candidate(url, url_title, s['title'], 'Sunn O)))') + def suite(): return unittest.TestLoader().loadTestsFromName(__name__)