mirror of
https://github.com/beetbox/beets.git
synced 2025-12-14 20:43:41 +01:00
Merge remote-tracking branch 'upstream/master' into prompthook
This commit is contained in:
commit
03dea7459e
20 changed files with 855 additions and 132 deletions
|
|
@ -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+
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
326
beetsplug/edit.py
Normal 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())
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
36
docs/plugins/edit.rst
Normal 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``
|
||||
|
|
@ -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
|
||||
-------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
260
test/test_edit.py
Normal 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')
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
Loading…
Reference in a new issue