Merge remote-tracking branch 'upstream/master' into prompthook

This commit is contained in:
Diego Moreda 2015-12-15 16:30:51 +01:00
commit 03dea7459e
20 changed files with 855 additions and 132 deletions

View file

@ -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+

View file

@ -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.

View file

@ -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):

View file

@ -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'),

View file

@ -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)

326
beetsplug/edit.py Normal file
View file

@ -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())

View file

@ -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,
}

View file

@ -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

View file

@ -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)

View file

@ -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

36
docs/plugins/edit.rst Normal file
View file

@ -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``

View file

@ -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
-------------------

View file

@ -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

View file

@ -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:

Binary file not shown.

View file

@ -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()

260
test/test_edit.py Normal file
View file

@ -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')

View file

@ -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():

View file

@ -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__)

View file

@ -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__)