diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..0f5481f0c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,31 @@ +### Problem + +(Describe your problem, feature request, or discussion topic here. If you're reporting a bug, please fill out this and the "Setup" section below. Otherwise, you can delete them.) + +Running this command in verbose (`-vv`) mode: + +```sh +$ beet -vv (... paste here ...) +``` + +Led to this problem: + +``` +(paste here) +``` + +Here's a link to the music files that trigger the bug (if relevant): + + +### Setup + +* OS: +* Python version: +* beets version: +* Turning off plugins made problem go away (yes/no): + +My configuration (output of `beet config`) is: + +```yaml +(paste here) +``` diff --git a/.hgignore b/.hgignore deleted file mode 100644 index 33acb4da3b..0000000000 --- a/.hgignore +++ /dev/null @@ -1,7 +0,0 @@ -^dist/ -^beets\.egg-info/ -^build/ -^MANIFEST$ -^docs/_build/ -^\.tox/ -^\.idea/ diff --git a/.hgtags b/.hgtags deleted file mode 100644 index 91ecdd73da..0000000000 --- a/.hgtags +++ /dev/null @@ -1,29 +0,0 @@ -86681cf6e45cf8f8de36ffe4e543b8bdd0e92c70 1.0b2 -c367859a82b9f4db0a4e693db6e0cd9724f8f86d 1.0b1 -a5e6430ece5a806aae7e692bf274f700ba01c665 1.0b3 -127b1ca77a2b4d1bdff0fa2eac168684d7dfe29d 1.0b4 -7b14e7caeaeb15ccd3f280b8b745c9c8f1487b14 1.0b5 -fad79b29263e86815799d3049aaa2da5d4cd3a04 1.0b6 -5986215a08c24eea3eb48e1b13a4748b34040aa5 1.0b7 -cea5dcc51b047fa6535d535624363b50a991fb5b 1.0b8 -a256ec5b0b2de500305fd6656db0a195df273acc 1.0b9 -88807657483a916200296165933529da9a682528 1.0b10 -4ca1475821742002962df439f71f51d67640b91e 1.0b11 -284b58a9f9ce3a79f7d2bcc48819f2bb77773818 1.0b12 -b6c10981014a5b3a963460fca3b31cc62bf7ed2c 1.0b13 -d3dbc6df2b96f8ba5704305a893e3e63b7f9cd77 1.0b14 -618201431990382474829cf96ea58e3669159f9a 1.0b15 -c84744f4519be7416dc1653142f1763f406d6896 1.0rc1 -f3cd4c138c6f40dc324a23bf01c4c7d97766477e 1.0rc2 -6f29c0f4dc7025e8d8216ea960000c353886c4f4 v1.1.0-beta.1 -f28ea9e2ef8d39913d79dbba73db280ff0740c50 v1.1.0-beta.2 -8f070ce28a7b33d8509b29a8dbe937109bbdbd21 v1.1.0-beta.3 -97f04ce252332dbda013cbc478d702d54a8fc1bd v1.1.0 -b3f7b5267a2f7b46b826d087421d7f4569211240 v1.2.0 -b3f7b5267a2f7b46b826d087421d7f4569211240 v1.2.0 -ecff182221ec32a9f6549ad3ce8d2ab4c3e5568a v1.2.0 -bd7259ac13b54caecb1403f625688eb3eeeba8d6 v1.2.1 -c6af5962e25b915ce538af1c0b53a89ceb340b04 v1.2.2 -87945a0e217591a842307fa11e161d4912598c32 v1.3.0 -78ce7bd4a1d1ddcbbd2b624cdfcd70946640ae46 v1.3.1 -d3bb90ef6bc7f663f96dcc1bb5e46498662f16e7 v1.3.2 diff --git a/LICENSE b/LICENSE index e817666503..b45de0fcf3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2010-2015 Adrian Sampson +Copyright (c) 2010-2016 Adrian Sampson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index e040a9c34a..3e64d27816 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,8 @@ # Include tests (but avoid including *.pyc, etc.) prune test recursive-include test/rsrc * +recursive-exclude test/rsrc *.pyc +recursive-exclude test/rsrc *.pyo include test/*.py # Include relevant text files. diff --git a/README.rst b/README.rst index eec817f947..1d865ec793 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,8 @@ -.. image:: https://travis-ci.org/sampsyo/beets.svg?branch=master - :target: https://travis-ci.org/sampsyo/beets +.. image:: https://travis-ci.org/beetbox/beets.svg?branch=master + :target: https://travis-ci.org/beetbox/beets -.. image:: http://img.shields.io/codecov/c/github/sampsyo/beets.svg - :target: https://codecov.io/github/sampsyo/beets +.. image:: http://img.shields.io/codecov/c/github/beetbox/beets.svg + :target: https://codecov.io/github/beetbox/beets .. image:: http://img.shields.io/pypi/v/beets.svg :target: https://pypi.python.org/pypi/beets @@ -79,7 +79,7 @@ news and updates. You can install beets by typing ``pip install beets``. Then check out the `Getting Started`_ guide. -.. _its Web site: http://beets.radbox.org/ +.. _its Web site: http://beets.io/ .. _Getting Started: http://beets.readthedocs.org/page/guides/main.html .. _@b33ts: http://twitter.com/b33ts/ diff --git a/beet b/beet index 1c49295a16..99dbd8cf5d 100755 --- a/beet +++ b/beet @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/__init__.py b/beets/__init__.py index 0b64ac3886..89ab747082 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,7 +20,7 @@ import beets.library from beets.util import confit -__version__ = '1.3.16' +__version__ = '1.3.18' __author__ = 'Adrian Sampson ' Library = beets.library.Library diff --git a/beets/art.py b/beets/art.py index 9f280ca01e..60f9dce88a 100644 --- a/beets/art.py +++ b/beets/art.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 9ad02b8eb2..924eb85f28 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 6b654573fb..303ce34188 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/autotag/match.py b/beets/autotag/match.py index f9146d027b..3565dba3fe 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -370,7 +370,7 @@ def _add_candidate(items, results, info): def tag_album(items, search_artist=None, search_album=None, - search_id=None): + search_ids=[]): """Return a tuple of a artist name, an album name, a list of `AlbumMatch` candidates from the metadata backend, and a `Recommendation`. @@ -380,8 +380,11 @@ def tag_album(items, search_artist=None, search_album=None, The `AlbumMatch` objects are generated by searching the metadata backends. By default, the metadata of the items is used for the - search. This can be customized by setting the parameters. The - `mapping` field of the album has the matched `items` as keys. + search. This can be customized by setting the parameters. + `search_ids` is a list of metadata backend IDs: if specified, + it will restrict the candidates to those IDs, ignoring + `search_artist` and `search album`. The `mapping` field of the + album has the matched `items` as keys. The recommendation is calculated from the match quality of the candidates. @@ -397,9 +400,11 @@ def tag_album(items, search_artist=None, search_album=None, candidates = {} # Search by explicit ID. - if search_id is not None: - log.debug(u'Searching for album ID: {0}', search_id) - search_cands = hooks.albums_for_id(search_id) + if search_ids: + search_cands = [] + for search_id in search_ids: + log.debug(u'Searching for album ID: {0}', search_id) + search_cands.extend(hooks.albums_for_id(search_id)) # Use existing metadata or text search. else: @@ -444,35 +449,38 @@ def tag_album(items, search_artist=None, search_album=None, def tag_item(item, search_artist=None, search_title=None, - search_id=None): + search_ids=[]): """Attempts to find metadata for a single track. Returns a `(candidates, recommendation)` pair where `candidates` is a list of TrackMatch objects. `search_artist` and `search_title` may be used to override the current metadata for the purposes of the MusicBrainz - title; likewise `search_id`. + title. `search_ids` may be used for restricting the search to a list + of metadata backend IDs. """ # Holds candidates found so far: keys are MBIDs; values are # (distance, TrackInfo) pairs. candidates = {} # First, try matching by MusicBrainz ID. - trackid = search_id or item.mb_trackid - if trackid: - log.debug(u'Searching for track ID: {0}', trackid) - for track_info in hooks.tracks_for_id(trackid): - dist = track_distance(item, track_info, incl_artist=True) - candidates[track_info.track_id] = \ - hooks.TrackMatch(dist, track_info) - # If this is a good match, then don't keep searching. - rec = _recommendation(candidates.values()) - if rec == Recommendation.strong and not config['import']['timid']: - log.debug(u'Track ID match.') - return candidates.values(), rec + trackids = search_ids or filter(None, [item.mb_trackid]) + if trackids: + for trackid in trackids: + log.debug(u'Searching for track ID: {0}', trackid) + for track_info in hooks.tracks_for_id(trackid): + dist = track_distance(item, track_info, incl_artist=True) + candidates[track_info.track_id] = \ + hooks.TrackMatch(dist, track_info) + # If this is a good match, then don't keep searching. + rec = _recommendation(sorted(candidates.itervalues())) + if rec == Recommendation.strong and \ + not config['import']['timid']: + log.debug(u'Track ID match.') + return sorted(candidates.itervalues()), rec # If we're searching by ID, don't proceed. - if search_id is not None: + if search_ids: if candidates: - return candidates.values(), rec + return sorted(candidates.itervalues()), rec else: return [], Recommendation.none diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index f823f72111..cc17803659 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -33,7 +33,7 @@ BASE_URL = 'http://musicbrainz.org/' musicbrainzngs.set_useragent('beets', beets.__version__, - 'http://beets.radbox.org/') + 'http://beets.io/') class MusicBrainzAPIError(util.HumanReadableException): diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 545fa96385..5bc9211676 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -22,6 +22,7 @@ import: flat: no group_albums: no pretend: false + search_ids: [] clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information"] diff --git a/beets/dbcore/__init__.py b/beets/dbcore/__init__.py index 059f1fc4f1..78b53c1924 100644 --- a/beets/dbcore/__init__.py +++ b/beets/dbcore/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 202cf006f6..d784ddcae9 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index d8a3a0ea89..f919927126 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/dbcore/queryparse.py b/beets/dbcore/queryparse.py index 53b6a026ed..bc9cc77ecf 100644 --- a/beets/dbcore/queryparse.py +++ b/beets/dbcore/queryparse.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -39,32 +39,50 @@ def parse_query_part(part, query_classes={}, prefixes={}, default_class=query.SubstringQuery): - """Take a query in the form of a key/value pair separated by a - colon and return a tuple of `(key, value, cls, negate)`. `key` may be None, - indicating that any field may be matched. `cls` is a subclass of - `FieldQuery`. `negate` is a boolean indicating if the query is negated. - - The optional `query_classes` parameter maps field names to default - query types; `default_class` is the fallback. `prefixes` is a map - from query prefix markers and query types. Prefix-indicated queries - take precedence over type-based queries. - - To determine the query class, two factors are used: prefixes and - field types. For example, the colon prefix denotes a regular - expression query and a type map might provide a special kind of - query for numeric values. If neither a prefix nor a specific query - class is available, `default_class` is used. - - For instance, - 'stapler' -> (None, 'stapler', SubstringQuery, False) - 'color:red' -> ('color', 'red', SubstringQuery, False) - ':^Quiet' -> (None, '^Quiet', RegexpQuery, False) - 'color::b..e' -> ('color', 'b..e', RegexpQuery, False) - '-color:red' -> ('color', 'red', SubstringQuery, True) - - Prefixes may be "escaped" with a backslash to disable the keying - behavior. + """Parse a single *query part*, which is a chunk of a complete query + string representing a single criterion. + + A query part is a string consisting of: + - A *pattern*: the value to look for. + - Optionally, a *field name* preceding the pattern, separated by a + colon. So in `foo:bar`, `foo` is the field name and `bar` is the + pattern. + - Optionally, a *query prefix* just before the pattern (and after the + optional colon) indicating the type of query that should be used. For + example, in `~foo`, `~` might be a prefix. (The set of prefixes to + look for is given in the `prefixes` parameter.) + - Optionally, a negation indicator, `-` or `^`, at the very beginning. + + Both prefixes and the separating `:` character may be escaped with a + backslash to avoid their normal meaning. + + The function returns a tuple consisting of: + - The field name: a string or None if it's not present. + - The pattern, a string. + - The query class to use, which inherits from the base + :class:`Query` type. + - A negation flag, a bool. + + The three optional parameters determine which query class is used (i.e., + the third return value). They are: + - `query_classes`, which maps field names to query classes. These + are used when no explicit prefix is present. + - `prefixes`, which maps prefix strings to query classes. + - `default_class`, the fallback when neither the field nor a prefix + indicates a query class. + + So the precedence for determining which query class to return is: + prefix, followed by field, and finally the default. + + For example, assuming the `:` prefix is used for `RegexpQuery`: + - `'stapler'` -> `(None, 'stapler', SubstringQuery, False)` + - `'color:red'` -> `('color', 'red', SubstringQuery, False)` + - `':^Quiet'` -> `(None, '^Quiet', RegexpQuery, False)`, because + the `^` follows the `:` + - `'color::b..e'` -> `('color', 'b..e', RegexpQuery, False)` + - `'-color:red'` -> `('color', 'red', SubstringQuery, True)` """ + # Apply the regular expression and extract the components. part = part.strip() match = PARSE_QUERY_PART_REGEX.match(part) @@ -73,25 +91,36 @@ class is available, `default_class` is used. key = match.group(2) term = match.group(3).replace('\:', ':') - # Match the search term against the list of prefixes. + # Check whether there's a prefix in the query and use the + # corresponding query type. for pre, query_class in prefixes.items(): if term.startswith(pre): return key, term[len(pre):], query_class, negate - # No matching prefix: use type-based or fallback/default query. + # No matching prefix, so use either the query class determined by + # the field or the default as a fallback. query_class = query_classes.get(key, default_class) return key, term, query_class, negate def construct_query_part(model_cls, prefixes, query_part): - """Create a query from a single query component, `query_part`, for - querying instances of `model_cls`. Return a `Query` instance. + """Parse a *query part* string and return a :class:`Query` object. + + :param model_cls: The :class:`Model` class that this is a query for. + This is used to determine the appropriate query types for the + model's fields. + :param prefixes: A map from prefix strings to :class:`Query` types. + :param query_part: The string to parse. + + See the documentation for `parse_query_part` for more information on + query part syntax. """ - # Shortcut for empty query parts. + # A shortcut for empty query parts. if not query_part: return query.TrueQuery() - # Get the query classes for each possible field. + # Use `model_cls` to build up a map from field names to `Query` + # classes. query_classes = {} for k, t in itertools.chain(model_cls._fields.items(), model_cls._types.items()): @@ -101,7 +130,8 @@ def construct_query_part(model_cls, prefixes, query_part): key, pattern, query_class, negate = \ parse_query_part(query_part, query_classes, prefixes) - # No key specified. + # If there's no key (field name) specified, this is a "match + # anything" query. if key is None: if issubclass(query_class, query.FieldQuery): # The query type matches a specific field, but none was @@ -114,12 +144,14 @@ def construct_query_part(model_cls, prefixes, query_part): else: return q else: - # Other query type. + # Non-field query type. if negate: return query.NotQuery(query_class(pattern)) else: return query_class(pattern) + # Otherwise, this must be a `FieldQuery`. Use the field name to + # construct the query object. key = key.lower() q = query_class(key.lower(), pattern, key in model_cls._fields) if negate: diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 40183c0598..e90fd9d9b0 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/importer.py b/beets/importer.py index c26fbcbecb..f4dd4853da 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -45,7 +45,10 @@ action = Enum('action', ['SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'APPLY', 'MANUAL_ID', - 'ALBUMS']) + 'ALBUMS', 'RETAG']) +# The RETAG action represents "don't apply any match, but do record +# new metadata". It's not reachable via the standard command prompt but +# can be used by plugins. QUEUE_SIZE = 128 SINGLE_ARTIST_THRESH = 0.25 @@ -434,6 +437,7 @@ def __init__(self, toppath, paths, items): self.rec = None self.should_remove_duplicates = False self.is_album = True + self.search_ids = [] # user-supplied candidate IDs. def set_choice(self, choice): """Given an AlbumMatch or TrackMatch object or an action constant, @@ -442,7 +446,8 @@ def set_choice(self, choice): # Not part of the task structure: assert choice not in (action.MANUAL, action.MANUAL_ID) assert choice != action.APPLY # Only used internally. - if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS): + if choice in (action.SKIP, action.ASIS, action.TRACKS, action.ALBUMS, + action.RETAG): self.choice_flag = choice self.match = None else: @@ -478,10 +483,10 @@ def chosen_ident(self): """Returns identifying metadata about the current choice. For albums, this is an (artist, album) pair. For items, this is (artist, title). May only be called when the choice flag is ASIS - (in which case the data comes from the files' current metadata) - or APPLY (data comes from the choice). + or RETAG (in which case the data comes from the files' current + metadata) or APPLY (data comes from the choice). """ - if self.choice_flag is action.ASIS: + if self.choice_flag in (action.ASIS, action.RETAG): return (self.cur_artist, self.cur_album) elif self.choice_flag is action.APPLY: return (self.match.info.artist, self.match.info.album) @@ -492,7 +497,7 @@ def imported_items(self): If the tasks applies an album match the method only returns the matched items. """ - if self.choice_flag == action.ASIS: + if self.choice_flag in (action.ASIS, action.RETAG): return list(self.items) elif self.choice_flag == action.APPLY: return self.match.mapping.keys() @@ -579,10 +584,12 @@ def handle_created(self, session): return tasks def lookup_candidates(self): - """Retrieve and store candidates for this album. + """Retrieve and store candidates for this album. User-specified + candidate IDs are stored in self.search_ids: if present, the + initial lookup is restricted to only those IDs. """ artist, album, candidates, recommendation = \ - autotag.tag_album(self.items) + autotag.tag_album(self.items, search_ids=self.search_ids) self.cur_artist = artist self.cur_album = album self.candidates = candidates @@ -614,7 +621,10 @@ def find_duplicates(self, lib): return duplicates def align_album_level_fields(self): - """Make some album fields equal across `self.items`. + """Make some album fields equal across `self.items`. For the + RETAG action, we assume that the responsible for returning it + (ie. a plugin) always ensures that the first item contains + valid data on the relevant fields. """ changes = {} @@ -634,7 +644,7 @@ def align_album_level_fields(self): changes['albumartist'] = config['va_name'].get(unicode) changes['comp'] = True - elif self.choice_flag == action.APPLY: + elif self.choice_flag in (action.APPLY, action.RETAG): # Applying autotagged metadata. Just get AA from the first # item. if not self.items[0].albumartist: @@ -669,7 +679,7 @@ def manipulate_files(self, move=False, copy=False, write=False, # old paths. item.move(copy, link) - if write and self.apply: + if write and (self.apply or self.choice_flag == action.RETAG): item.try_write() with session.lib.transaction(): @@ -804,8 +814,8 @@ def __init__(self, toppath, item): self.paths = [item.path] def chosen_ident(self): - assert self.choice_flag in (action.ASIS, action.APPLY) - if self.choice_flag is action.ASIS: + assert self.choice_flag in (action.ASIS, action.APPLY, action.RETAG) + if self.choice_flag in (action.ASIS, action.RETAG): return (self.item.artist, self.item.title) elif self.choice_flag is action.APPLY: return (self.match.info.artist, self.match.info.title) @@ -821,7 +831,8 @@ def _emit_imported(self, lib): plugins.send('item_imported', lib=lib, item=item) def lookup_candidates(self): - candidates, recommendation = autotag.tag_item(self.item) + candidates, recommendation = autotag.tag_item( + self.item, search_ids=self.search_ids) self.candidates = candidates self.rec = recommendation @@ -1246,6 +1257,11 @@ def lookup_candidates(session, task): plugins.send('import_task_start', session=session, task=task) log.debug(u'Looking up: {0}', displayable_path(task.paths)) + + # Restrict the initial lookup to IDs specified by the user via the -m + # option. Currently all the IDs are passed onto the tasks directly. + task.search_ids = session.config['search_ids'].as_str_seq() + task.lookup_candidates() @@ -1306,7 +1322,7 @@ def resolve_duplicates(session, task): """Check if a task conflicts with items or albums already imported and ask the session to resolve this. """ - if task.choice_flag in (action.ASIS, action.APPLY): + if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): found_duplicates = task.find_duplicates(session.lib) if found_duplicates: log.debug('found duplicates: {}'.format( diff --git a/beets/library.py b/beets/library.py index f8d226dbe3..1c2fac9440 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -86,8 +86,8 @@ def is_path_query(cls, query_part): colon = query_part.find(':') if colon != -1: query_part = query_part[:colon] - return (os.sep in query_part - and os.path.exists(syspath(normpath(query_part)))) + return (os.sep in query_part and + os.path.exists(syspath(normpath(query_part)))) def match(self, item): path = item.path if self.case_sensitive else item.path.lower() @@ -186,6 +186,7 @@ def parse(self, key): for flat, sharp in self.ENHARMONIC.items(): key = re.sub(flat, sharp, key) key = re.sub(r'[\W\s]+minor', 'm', key) + key = re.sub(r'[\W\s]+major', '', key) return key.capitalize() def normalize(self, key): @@ -629,20 +630,26 @@ def try_write(self, path=None, tags=None): log.error("{0}", exc) return False - def try_sync(self, write=None): - """Synchronize the item with the database and the media file - tags, updating them with this object's current state. + def try_sync(self, write, move, with_album=True): + """Synchronize the item with the database and, possibly, updates its + tags on disk and its path (by moving the file). - By default, the current `path` for the item is used to write - tags. If `write` is `False`, no tags are written. If `write` is - a path, tags are written to that file instead. + `write` indicates whether to write new tags into the file. Similarly, + `move` controls whether the path should be updated. In the + latter case, files are *only* moved when they are inside their + library's directory (if any). - Similar to calling :meth:`write` and :meth:`store`. + Similar to calling :meth:`write`, :meth:`move`, and :meth:`store` + (conditionally). """ - if write is True: - write = None - if write is not False: - self.try_write(path=write) + if write: + self.try_write() + if move: + # Check whether this file is inside the library directory. + if self._db and self._db.directory in util.ancestry(self.path): + log.debug('moving {0} to synchronize path', + util.displayable_path(self.path)) + self.move(with_album=with_album) self.store() # Files themselves. @@ -1107,15 +1114,18 @@ def store(self): item[key] = value item.store() - def try_sync(self, write=True): - """Synchronize the album and its items with the database and - their files by updating them with this object's current state. + def try_sync(self, write, move): + """Synchronize the album and its items with the database. + Optionally, also write any new tags into the files and update + their paths. - `write` indicates whether to write tags to the item files. + `write` indicates whether to write tags to the item files, and + `move` controls whether files (both audio and album art) are + moved. """ self.store() for item in self.items(): - item.try_sync(bool(write)) + item.try_sync(write, move) # Query construction helpers. @@ -1421,7 +1431,7 @@ def tmpl_aunique(self, keys=None, disam=None): # Find matching albums to disambiguate with. subqueries = [] for key in keys: - value = getattr(album, key) + value = album.get(key, '') subqueries.append(dbcore.MatchQuery(key, value)) albums = self.lib.albums(dbcore.AndQuery(subqueries)) @@ -1434,7 +1444,7 @@ def tmpl_aunique(self, keys=None, disam=None): # Find the first disambiguator that distinguishes the albums. for disambiguator in disam: # Get the value for each album for the current field. - disam_values = set([getattr(a, disambiguator) for a in albums]) + disam_values = set([a.get(disambiguator, '') for a in albums]) # If the set of unique values is equal to the number of # albums in the disambiguation set, we're done -- this is diff --git a/beets/logging.py b/beets/logging.py index b1b0ad73c9..40748bc680 100644 --- a/beets/logging.py +++ b/beets/logging.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/mediafile.py b/beets/mediafile.py index 85f3d2e67c..c6d265b405 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -211,9 +211,14 @@ def _pack_asf_image(mime, data, type=3, description=""): # iTunes Sound Check encoding. def _sc_decode(soundcheck): - """Convert a Sound Check string value to a (gain, peak) tuple as + """Convert a Sound Check bytestring value to a (gain, peak) tuple as used by ReplayGain. """ + # We decode binary data. If one of the formats gives us a text + # string, interpret it as UTF-8. + if isinstance(soundcheck, unicode): + soundcheck = soundcheck.encode('utf8') + # SoundCheck tags consist of 10 numbers, each represented by 8 # characters of ASCII hex preceded by a space. try: diff --git a/beets/plugins.py b/beets/plugins.py index cf4c76036b..4233003eae 100755 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index c51c3acb61..d395fe465c 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -367,6 +367,35 @@ def input_yn(prompt, require=False): return sel == 'y' +def input_select_objects(prompt, objs, rep): + """Prompt to user to choose all, none, or some of the given objects. + Return the list of selected objects. + + `prompt` is the prompt string to use for each question (it should be + phrased as an imperative verb). `rep` is a function to call on each + object to print it out when confirming objects individually. + """ + choice = input_options( + ('y', 'n', 's'), False, + '%s? (Yes/no/select)' % prompt) + print() # Blank line. + + if choice == 'y': # Yes. + return objs + + elif choice == 's': # Select. + out = [] + for obj in objs: + rep(obj) + if input_yn('%s? (yes/no)' % prompt, True): + out.append(obj) + print() # go to a new line + return out + + else: # No. + return [] + + # Human output formatting. def human_bytes(size): @@ -1172,11 +1201,11 @@ def _raw_main(args, lib=None): parser.add_option('-d', '--directory', dest='directory', help="destination music directory") parser.add_option('-v', '--verbose', dest='verbose', action='count', - help='print debugging information') + help='log more details (use twice for even more)') parser.add_option('-c', '--config', dest='config', help='path to configuration file') parser.add_option('-h', '--help', dest='help', action='store_true', - help='how this help message and exit') + help='show this help message and exit') parser.add_option('--version', dest='version', action='store_true', help=optparse.SUPPRESS_HELP) diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 2d943eee15..641b1186fc 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -22,6 +22,8 @@ import os import re +from collections import namedtuple, Counter +from itertools import chain import beets from beets import ui @@ -39,6 +41,7 @@ from beets.util.confit import _package_path VARIOUS_ARTISTS = u'Various Artists' +PromptChoice = namedtuple('ExtraChoice', ['short', 'long', 'callback']) # Global logger. log = logging.getLogger('beets') @@ -78,6 +81,14 @@ def _do_query(lib, query, album, also_items=True): # fields: Shows a list of available fields for queries and format strings. +def _print_keys(query): + """Given a SQLite query result, print the `key` field of each + returned row, with identation of 2 spaces. + """ + for row in query: + print_(' ' * 2 + row[b'key']) + + def fields_func(lib, opts, args): def _print_rows(names): names.sort() @@ -89,6 +100,15 @@ def _print_rows(names): print_("Album fields:") _print_rows(library.Album.all_keys()) + with lib.transaction() as tx: + # The SQL uses the DISTINCT to get unique values from the query + unique_fields = 'SELECT DISTINCT key FROM (%s)' + + print_("Item flexible attributes:") + _print_keys(tx.query(unique_fields % library.Item._flex_table)) + + print_("Album flexible attributes:") + _print_keys(tx.query(unique_fields % library.Album._flex_table)) fields_cmd = ui.Subcommand( 'fields', @@ -437,7 +457,7 @@ def summarize_items(items, singleton): return ', '.join(summary_parts) -def _summary_judment(rec): +def _summary_judgment(rec): """Determines whether a decision should be made without even asking the user. This occurs in quiet mode and when an action is chosen for NONE recommendations. Return an action or None if the user should be @@ -471,7 +491,8 @@ def _summary_judment(rec): def choose_candidate(candidates, singleton, rec, cur_artist=None, - cur_album=None, item=None, itemcount=None): + cur_album=None, item=None, itemcount=None, + extra_choices=[]): """Given a sorted list of candidates, ask the user for a selection of which candidate to use. Applies to both full albums and singletons (tracks). Candidates are either AlbumMatch or TrackMatch @@ -479,8 +500,16 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, `cur_album`, and `itemcount` must be provided. For singletons, `item` must be provided. - Returns the result of the choice, which may SKIP, ASIS, TRACKS, or - MANUAL or a candidate (an AlbumMatch/TrackMatch object). + `extra_choices` is a list of `PromptChoice`s, containg the choices + appended by the plugins after receiving the `before_choose_candidate` + event. If not empty, the choices are appended to the prompt presented + to the user. + + Returns one of the following: + * the result of the choice, which may be SKIP, ASIS, TRACKS, or MANUAL + * a candidate (an AlbumMatch/TrackMatch object) + * the short letter of a `PromptChoice` (if the user selected one of + the `extra_choices`). """ # Sanity check. if singleton: @@ -489,6 +518,10 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, assert cur_artist is not None assert cur_album is not None + # Build helper variables for extra choices. + extra_opts = tuple(c.long for c in extra_choices) + extra_actions = tuple(c.short for c in extra_choices) + # Zero candidates. if not candidates: if singleton: @@ -502,7 +535,7 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, 'http://beets.readthedocs.org/en/latest/faq.html#nomatch') opts = ('Use as-is', 'as Tracks', 'Group albums', 'Skip', 'Enter search', 'enter Id', 'aBort') - sel = ui.input_options(opts) + sel = ui.input_options(opts + extra_opts) if sel == 'u': return importer.action.ASIS elif sel == 't': @@ -518,6 +551,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, return importer.action.MANUAL_ID elif sel == 'g': return importer.action.ALBUMS + elif sel in extra_actions: + return sel else: assert False @@ -571,7 +606,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, else: opts = ('Skip', 'Use as-is', 'as Tracks', 'Group albums', 'Enter search', 'enter Id', 'aBort') - sel = ui.input_options(opts, numrange=(1, len(candidates))) + sel = ui.input_options(opts + extra_opts, + numrange=(1, len(candidates))) if sel == 's': return importer.action.SKIP elif sel == 'u': @@ -589,6 +625,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, return importer.action.MANUAL_ID elif sel == 'g': return importer.action.ALBUMS + elif sel in extra_actions: + return sel else: # Numerical selection. match = candidates[sel - 1] if sel != 1: @@ -623,7 +661,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, }) if default is None: require = True - sel = ui.input_options(opts, require=require, default=default) + sel = ui.input_options(opts + extra_opts, require=require, + default=default) if sel == 'a': return match elif sel == 'g': @@ -641,6 +680,8 @@ def choose_candidate(candidates, singleton, rec, cur_artist=None, raise importer.ImportAbort() elif sel == 'i': return importer.action.MANUAL_ID + elif sel in extra_actions: + return sel def manual_search(singleton): @@ -673,7 +714,7 @@ def choose_match(self, task): u' ({0} items)'.format(len(task.items))) # Take immediate action if appropriate. - action = _summary_judment(task.rec) + action = _summary_judgment(task.rec) if action == importer.action.APPLY: match = task.candidates[0] show_change(task.cur_artist, task.cur_album, match) @@ -684,10 +725,14 @@ def choose_match(self, task): # Loop until we have a choice. candidates, rec = task.candidates, task.rec while True: + # Gather extra choices from plugins. + extra_choices = self._get_plugin_choices(task) + extra_ops = {c.short: c.callback for c in extra_choices} + # Ask for a choice from the user. choice = choose_candidate( candidates, False, rec, task.cur_artist, task.cur_album, - itemcount=len(task.items) + itemcount=len(task.items), extra_choices=extra_choices ) # Choose which tags to use. @@ -706,8 +751,14 @@ def choose_match(self, task): search_id = manual_id(False) if search_id: _, _, candidates, rec = autotag.tag_album( - task.items, search_id=search_id + task.items, search_ids=search_id.split() ) + elif choice in extra_ops.keys(): + # Allow extra ops to automatically set the post-choice. + post_choice = extra_ops[choice](self, task) + if isinstance(post_choice, importer.action): + # MANUAL and MANUAL_ID have no effect, even if returned. + return post_choice else: # We have a candidate! Finish tagging. Here, choice is an # AlbumMatch object. @@ -723,7 +774,7 @@ def choose_item(self, task): candidates, rec = task.candidates, task.rec # Take immediate action if appropriate. - action = _summary_judment(task.rec) + action = _summary_judgment(task.rec) if action == importer.action.APPLY: match = candidates[0] show_item_change(task.item, match) @@ -732,8 +783,12 @@ def choose_item(self, task): return action while True: + extra_choices = self._get_plugin_choices(task) + extra_ops = {c.short: c.callback for c in extra_choices} + # Ask for a choice. - choice = choose_candidate(candidates, True, rec, item=task.item) + choice = choose_candidate(candidates, True, rec, item=task.item, + extra_choices=extra_choices) if choice in (importer.action.SKIP, importer.action.ASIS): return choice @@ -748,8 +803,14 @@ def choose_item(self, task): # Ask for a track ID. search_id = manual_id(True) if search_id: - candidates, rec = autotag.tag_item(task.item, - search_id=search_id) + candidates, rec = autotag.tag_item( + task.item, search_ids=search_id.split()) + elif choice in extra_ops.keys(): + # Allow extra ops to automatically set the post-choice. + post_choice = extra_ops[choice](self, task) + if isinstance(post_choice, importer.action): + # MANUAL and MANUAL_ID have no effect, even if returned. + return post_choice else: # Chose a candidate. assert isinstance(choice, autotag.TrackMatch) @@ -801,6 +862,50 @@ def should_resume(self, path): "was interrupted. Resume (Y/n)?" .format(displayable_path(path))) + def _get_plugin_choices(self, task): + """Get the extra choices appended to the plugins to the ui prompt. + + The `before_choose_candidate` event is sent to the plugins, with + session and task as its parameters. Plugins are responsible for + checking the right conditions and returning a list of `PromptChoice`s, + which is flattened and checked for conflicts. + + If two or more choices have the same short letter, a warning is + emitted and all but one choices are discarded, giving preference + to the default importer choices. + + Returns a list of `PromptChoice`s. + """ + # Send the before_choose_candidate event and flatten list. + extra_choices = list(chain(*plugins.send('before_choose_candidate', + session=self, task=task))) + # Take into account default options, for duplicate checking. + all_choices = [PromptChoice('a', 'Apply', None), + PromptChoice('s', 'Skip', None), + PromptChoice('u', 'Use as-is', None), + PromptChoice('t', 'as Tracks', None), + PromptChoice('g', 'Group albums', None), + PromptChoice('e', 'Enter search', None), + PromptChoice('i', 'enter Id', None), + PromptChoice('b', 'aBort', None)] +\ + extra_choices + + short_letters = [c.short for c in all_choices] + if len(short_letters) != len(set(short_letters)): + # Duplicate short letter has been found. + duplicates = [i for i, count in Counter(short_letters).items() + if count > 1] + for short in duplicates: + # Keep the first of the choices, removing the rest. + dup_choices = [c for c in all_choices if c.short == short] + for c in dup_choices[1:]: + log.warn(u"Prompt choice '{0}' removed due to conflict " + u"with '{1}' (short letter: '{2}')", + c.long, dup_choices[0].long, c.short) + extra_choices.remove(c) + return extra_choices + + # The import command. @@ -936,6 +1041,11 @@ def import_func(lib, opts, args): '--pretend', dest='pretend', action='store_true', help='just print the files to import' ) +import_cmd.parser.add_option( + '-S', '--search-id', dest='search_ids', action='append', + metavar='BACKEND_ID', + help='restrict matching to a specific metadata backend ID' +) import_cmd.func = import_func default_commands.append(import_cmd) @@ -1146,7 +1256,10 @@ def show_stats(lib, query, exact): for item in items: if exact: - total_size += os.path.getsize(item.path) + try: + total_size += os.path.getsize(syspath(item.path)) + except OSError as exc: + log.info('could not get size of {}: {}', item.path, exc) else: total_size += int(item.length * item.bitrate / 8) total_time += item.length @@ -1236,13 +1349,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm): .format(len(objs), 'album' if album else 'item')) changed = set() for obj in objs: - obj.update(mods) - for field in dels: - try: - del obj[field] - except KeyError: - pass - if ui.show_model_changes(obj): + if print_and_modify(obj, mods, dels): changed.add(obj) # Still something to do? @@ -1261,19 +1368,31 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm): else: extra = '' - if not ui.input_yn('Really modify%s (Y/n)?' % extra): - return + changed = ui.input_select_objects( + 'Really modify%s' % extra, changed, + lambda o: print_and_modify(o, mods, dels) + ) # Apply changes to database and files with lib.transaction(): for obj in changed: - if move: - cur_path = obj.path - if lib.directory in ancestry(cur_path): # In library? - log.debug(u'moving object {0}', displayable_path(cur_path)) - obj.move() + obj.try_sync(write, move) + - obj.try_sync(write) +def print_and_modify(obj, mods, dels): + """Print the modifications to an item and return a bool indicating + whether any changes were made. + + `mods` is a dictionary of fields and values to update on the object; + `dels` is a sequence of fields to delete. + """ + obj.update(mods) + for field in dels: + try: + del obj[field] + except KeyError: + pass + return ui.show_model_changes(obj) def modify_parse_args(args): @@ -1334,7 +1453,7 @@ def modify_func(lib, opts, args): # move: Move/copy files to the library or a new base directory. -def move_items(lib, dest, query, copy, album, pretend): +def move_items(lib, dest, query, copy, album, pretend, confirm=False): """Moves or copies items to a new base directory, given by dest. If dest is None, then the library's base directory is used, making the command "consolidate" files. @@ -1342,10 +1461,19 @@ def move_items(lib, dest, query, copy, album, pretend): items, albums = _do_query(lib, query, album, False) objs = albums if album else items + # Filter out files that don't need to be moved. + isitemmoved = lambda item: item.path != item.destination(basedir=dest) + isalbummoved = lambda album: any(isitemmoved(i) for i in album.items()) + objs = [o for o in objs if (isalbummoved if album else isitemmoved)(o)] + action = 'Copying' if copy else 'Moving' + act = 'copy' if copy else 'move' entity = 'album' if album else 'item' log.info(u'{0} {1} {2}{3}.', action, len(objs), entity, - 's' if len(objs) > 1 else '') + 's' if len(objs) != 1 else '') + if not objs: + return + if pretend: if album: show_path_changes([(item.path, item.destination(basedir=dest)) @@ -1354,6 +1482,12 @@ def move_items(lib, dest, query, copy, album, pretend): show_path_changes([(obj.path, obj.destination(basedir=dest)) for obj in objs]) else: + if confirm: + objs = ui.input_select_objects( + 'Really %s' % act, objs, + lambda o: show_path_changes( + [(o.path, o.destination(basedir=dest))])) + for obj in objs: log.debug(u'moving: {0}', util.displayable_path(obj.path)) @@ -1368,7 +1502,8 @@ def move_func(lib, opts, args): if not os.path.isdir(dest): raise ui.UserError('no such directory: %s' % dest) - move_items(lib, dest, decargs(args), opts.copy, opts.album, opts.pretend) + move_items(lib, dest, decargs(args), opts.copy, opts.album, opts.pretend, + opts.timid) move_cmd = ui.Subcommand( @@ -1384,7 +1519,12 @@ def move_func(lib, opts, args): ) move_cmd.parser.add_option( '-p', '--pretend', default=False, action='store_true', - help='show how files would be moved, but don\'t touch anything') + help='show how files would be moved, but don\'t touch anything' +) +move_cmd.parser.add_option( + '-t', '--timid', dest='timid', action='store_true', + help='always confirm all actions' +) move_cmd.parser.add_album_option() move_cmd.func = move_func default_commands.append(move_cmd) @@ -1416,7 +1556,9 @@ def write_items(lib, query, pretend, force): changed = ui.show_model_changes(item, clean_item, library.Item._media_tag_fields, force) if (changed or force) and not pretend: - item.try_sync() + # We use `try_sync` here to keep the mtime up to date in the + # database. + item.try_sync(True, False) def write_func(lib, opts, args): diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 55c599a056..0da68efc75 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -776,16 +776,19 @@ def interactive_open(targets, command): Can raise `OSError`. """ + assert command + # Split the command string into its arguments. try: - command = shlex_split(command) + args = shlex_split(command) except ValueError: # Malformed shell tokens. - command = [command] - command.insert(0, command[0]) # for argv[0] + args = [command] + + args.insert(0, args[0]) # for argv[0] - command += targets + args += targets - return os.execlp(*command) + return os.execlp(*args) def _windows_long_path_name(short_path): diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index a392ee5d26..1b6a5903e0 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte +# Copyright 2016, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/confit.py b/beets/util/confit.py index 7dca300d7c..29b906cb66 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of Confit. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/enumeration.py b/beets/util/enumeration.py index c7761be893..9dddf7f646 100644 --- a/beets/util/enumeration.py +++ b/beets/util/enumeration.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index e2854e205d..68cb4cd76b 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/util/pipeline.py b/beets/util/pipeline.py index 62772540f0..deb431fa19 100644 --- a/beets/util/pipeline.py +++ b/beets/util/pipeline.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beets/vfs.py b/beets/vfs.py index e7b7970d2a..d99031c63f 100644 --- a/beets/vfs.py +++ b/beets/vfs.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/__init__.py b/beetsplug/__init__.py index 6aba6481ad..ea5e7fe401 100644 --- a/beetsplug/__init__.py +++ b/beetsplug/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/acousticbrainz.py b/beetsplug/acousticbrainz.py new file mode 100644 index 0000000000..49f01ea21c --- /dev/null +++ b/beetsplug/acousticbrainz.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2015-2016, Ohm Patel. +# +# 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. + +"""Fetch various AcousticBrainz metadata using MBID. +""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +import requests +import operator + +from beets import plugins, ui + +ACOUSTIC_BASE = "http://acousticbrainz.org/" +LEVELS = ["/low-level", "/high-level"] + + +class AcousticPlugin(plugins.BeetsPlugin): + def __init__(self): + super(AcousticPlugin, self).__init__() + + self.config.add({'auto': True}) + if self.config['auto']: + self.register_listener('import_task_files', + self.import_task_files) + + def commands(self): + cmd = ui.Subcommand('acousticbrainz', + help="fetch metadata from AcousticBrainz") + + def func(lib, opts, args): + items = lib.items(ui.decargs(args)) + fetch_info(self._log, items, ui.should_write()) + + cmd.func = func + return [cmd] + + def import_task_files(self, session, task): + """Function is called upon beet import. + """ + + items = task.imported_items() + fetch_info(self._log, items, False) + + +def fetch_info(log, items, write): + """Get data from AcousticBrainz for the items. + """ + + def get_value(*map_path): + try: + return reduce(operator.getitem, map_path, data) + except KeyError: + log.debug('Invalid Path: {}', map_path) + + for item in items: + if item.mb_trackid: + log.info('getting data for: {}', item) + + # Fetch the data from the AB API. + urls = [generate_url(item.mb_trackid, path) for path in LEVELS] + log.debug('fetching URLs: {}', ' '.join(urls)) + try: + res = [requests.get(url) for url in urls] + except requests.RequestException as exc: + log.info('request error: {}', exc) + continue + + # Check for missing tracks. + if any(r.status_code == 404 for r in res): + log.info('recording ID {} not found', item.mb_trackid) + continue + + # Parse the JSON response. + try: + data = res[0].json() + data.update(res[1].json()) + except ValueError: + log.debug('Invalid Response: {} & {}', [r.text for r in res]) + + # Get each field and assign it on the item. + item.danceable = get_value( + "highlevel", "danceability", "all", "danceable", + ) + item.gender = get_value( + "highlevel", "gender", "value", + ) + item.genre_rosamerica = get_value( + "highlevel", "genre_rosamerica", "value" + ) + item.mood_acoustic = get_value( + "highlevel", "mood_acoustic", "all", "acoustic" + ) + item.mood_aggressive = get_value( + "highlevel", "mood_aggressive", "all", "aggressive" + ) + item.mood_electronic = get_value( + "highlevel", "mood_electronic", "all", "electronic" + ) + item.mood_happy = get_value( + "highlevel", "mood_happy", "all", "happy" + ) + item.mood_party = get_value( + "highlevel", "mood_party", "all", "party" + ) + item.mood_relaxed = get_value( + "highlevel", "mood_relaxed", "all", "relaxed" + ) + item.mood_sad = get_value( + "highlevel", "mood_sad", "all", "sad" + ) + item.rhythm = get_value( + "highlevel", "ismir04_rhythm", "value" + ) + item.tonal = get_value( + "highlevel", "tonal_atonal", "all", "tonal" + ) + item.voice_instrumental = get_value( + "highlevel", "voice_instrumental", "value" + ) + item.average_loudness = get_value( + "lowlevel", "average_loudness" + ) + item.chords_changes_rate = get_value( + "tonal", "chords_changes_rate" + ) + item.chords_key = get_value( + "tonal", "chords_key" + ) + item.chords_number_rate = get_value( + "tonal", "chords_number_rate" + ) + item.chords_scale = get_value( + "tonal", "chords_scale" + ) + item.initial_key = '{} {}'.format( + get_value("tonal", "key_key"), + get_value("tonal", "key_scale") + ) + item.key_strength = get_value( + "tonal", "key_strength" + ) + + # Store the data. + item.store() + if write: + item.try_write() + + +def generate_url(mbid, level): + """Generates AcousticBrainz end point url for given MBID. + """ + return ACOUSTIC_BASE + mbid + level diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 7079fb4e88..c6877205cb 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, François-Xavier Thomas. +# Copyright 2016, François-Xavier Thomas. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bench.py b/beetsplug/bench.py index 278704aa07..41f575cd2d 100644 --- a/beetsplug/bench.py +++ b/beetsplug/bench.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -75,7 +75,7 @@ def match_benchmark(lib, prof, query=None, album_id=None): # Run the match. def _run_match(): - match.tag_album(items, search_id=album_id) + match.tag_album(items, search_ids=[album_id]) if prof: cProfile.runctx('_run_match()', {}, {'_run_match': _run_match}, 'match.prof') diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index 2b1f4a2720..7e8694bac4 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 80f6622a6c..fa22b999d3 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bpm.py b/beetsplug/bpm.py index f8074005a0..e0fc7f4f83 100644 --- a/beetsplug/bpm.py +++ b/beetsplug/bpm.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, aroquen +# Copyright 2016, aroquen # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/bucket.py b/beetsplug/bucket.py index 32c25ed93f..7ddc909209 100644 --- a/beetsplug/bucket.py +++ b/beetsplug/bucket.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/chroma.py b/beetsplug/chroma.py index 028ba5ce1c..cb0eaf24ab 100644 --- a/beetsplug/chroma.py +++ b/beetsplug/chroma.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/convert.py b/beetsplug/convert.py index a262f216dd..ce475a19d7 100644 --- a/beetsplug/convert.py +++ b/beetsplug/convert.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Jakob Schnitzer. +# Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -29,6 +29,7 @@ from beets.plugins import BeetsPlugin from beets.util.confit import ConfigTypeError from beets import art +from beets.util.artresizer import ArtResizer _fs_lock = threading.Lock() _temp_files = [] # Keep track of temporary transcoded files for deletion. @@ -132,6 +133,7 @@ def __init__(self): u'paths': {}, u'never_convert_lossy_files': False, u'copy_album_art': False, + u'album_art_maxwidth': 0, }) self.import_stages = [self.auto_convert] @@ -305,8 +307,8 @@ def convert_item(self, dest_dir, keep_new, path_formats, fmt, dest=converted, keepnew=False) def copy_album_art(self, album, dest_dir, path_formats, pretend=False): - """Copies the associated cover art of the album. Album must have at - least one track. + """Copies or converts the associated cover art of the album. Album must + have at least one track. """ if not album or not album.artpath: return @@ -336,14 +338,36 @@ def copy_album_art(self, album, dest_dir, path_formats, pretend=False): util.displayable_path(album.artpath)) return - if pretend: - self._log.info(u'cp {0} {1}', + # Decide whether we need to resize the cover-art image. + resize = False + maxwidth = None + if self.config['album_art_maxwidth']: + maxwidth = self.config['album_art_maxwidth'].get(int) + size = ArtResizer.shared.get_size(album.artpath) + self._log.debug('image size: {}', size) + if size: + resize = size[0] > maxwidth + else: + self._log.warning(u'Could not get size of image (please see ' + u'documentation for dependencies).') + + # Either copy or resize (while copying) the image. + if resize: + self._log.info(u'Resizing cover art from {0} to {1}', util.displayable_path(album.artpath), util.displayable_path(dest)) + if not pretend: + ArtResizer.shared.resize(maxwidth, album.artpath, dest) else: - self._log.info(u'Copying cover art to {0}', - util.displayable_path(dest)) - util.copy(album.artpath, dest) + if pretend: + self._log.info(u'cp {0} {1}', + util.displayable_path(album.artpath), + util.displayable_path(dest)) + else: + self._log.info(u'Copying cover art to {0}', + util.displayable_path(album.artpath), + util.displayable_path(dest)) + util.copy(album.artpath, dest) def convert_func(self, lib, opts, args): if not opts.dest: diff --git a/beetsplug/cue.py b/beetsplug/cue.py index 59e27072ef..3e545c9a81 100644 --- a/beetsplug/cue.py +++ b/beetsplug/cue.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright 2015 Bruno Cauet +# Copyright 2016 Bruno Cauet # Split an album-file in tracks thanks a cue file import subprocess diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index e9c912afbf..321fbd09ee 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -41,7 +41,7 @@ urllib3_logger = logging.getLogger('requests.packages.urllib3') urllib3_logger.setLevel(logging.CRITICAL) -USER_AGENT = u'beets/{0} +http://beets.radbox.org/'.format(beets.__version__) +USER_AGENT = u'beets/{0} +http://beets.io/'.format(beets.__version__) # Exceptions that discogs_client should really handle but does not. CONNECTION_ERRORS = (ConnectionError, socket.error, httplib.HTTPException, @@ -196,7 +196,7 @@ def get_albums(self, query): # album title. Use `re.UNICODE` flag to avoid stripping non-english # word characters. # TEMPORARY: Encode as ASCII to work around a bug: - # https://github.com/sampsyo/beets/issues/1051 + # https://github.com/beetbox/beets/issues/1051 # When the library is fixed, we should encode as UTF-8. query = re.sub(r'(?u)\W+', ' ', query).encode('ascii', "replace") # Strip medium information from query, Things like "CD1" and "disk 1" diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 83bc492e58..3a04557ca0 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Pedro Silva. +# Copyright 2016, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 0075cc8355..182c7f9a23 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -465,7 +465,7 @@ def requires_update(self, item): def commands(self): fetch_cmd = ui.Subcommand('echonest', - help='Fetch metadata from the EchoNest') + help='fetch metadata from The Echo Nest') fetch_cmd.parser.add_option( '-f', '--force', dest='force', action='store_true', default=False, help='(re-)download information from the EchoNest' diff --git a/beetsplug/edit.py b/beetsplug/edit.py index 8d6b168f5d..ee045664ea 100644 --- a/beetsplug/edit.py +++ b/beetsplug/edit.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2015 +# Copyright 2016 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -21,7 +21,9 @@ from beets import util from beets import ui from beets.dbcore import types -from beets.ui.commands import _do_query +from beets.importer import action +from beets.ui.commands import _do_query, PromptChoice +from copy import deepcopy import subprocess import yaml from tempfile import NamedTemporaryFile @@ -119,7 +121,7 @@ def flatten(obj, fields): return d -def apply(obj, data): +def apply_(obj, data): """Set the fields of a `dbcore.Model` object according to a dictionary. @@ -151,6 +153,9 @@ def __init__(self): 'ignore_fields': 'id path', }) + self.register_listener('before_choose_candidate', + self.before_choose_candidate_listener) + def commands(self): edit_command = ui.Subcommand( 'edit', @@ -220,7 +225,7 @@ def edit(self, album, objs, fields): # Save the new data. if success: - self.save_write(objs) + self.save_changes(objs) def edit_objects(self, objs, fields): """Dump a set of Model objects to a file as text, ask the user @@ -262,10 +267,14 @@ def edit_objects(self, objs, fields): return False # Show the changes. + # If the objects are not on the DB yet, we need a copy of their + # original state for show_model_changes. + objs_old = [deepcopy(obj) if not obj._db else None + for obj in objs] self.apply_data(objs, old_data, new_data) changed = False - for obj in objs: - changed |= ui.show_model_changes(obj) + for obj, obj_old in zip(objs, objs_old): + changed |= ui.show_model_changes(obj, obj_old) if not changed: ui.print_('No changes to apply.') return False @@ -313,14 +322,66 @@ def apply_data(self, objs, old_data, new_data): if forbidden: continue - id = int(old_dict['id']) - apply(obj_by_id[id], new_dict) + id_ = int(old_dict['id']) + apply_(obj_by_id[id_], new_dict) - def save_write(self, objs): + def save_changes(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()) + ob.try_sync(ui.should_write(), ui.should_move()) + + # Methods for interactive importer execution. + + def before_choose_candidate_listener(self, session, task): + """Append an "Edit" choice and an "edit Candidates" choice (if + there are candidates) to the interactive importer prompt. + """ + choices = [PromptChoice('d', 'eDit', self.importer_edit)] + if task.candidates: + choices.append(PromptChoice('c', 'edit Candidates', + self.importer_edit_candidate)) + + return choices + + def importer_edit(self, session, task): + """Callback for invoking the functionality during an interactive + import session on the *original* item tags. + """ + # Assign temporary ids to the Items. + for i, obj in enumerate(task.items): + obj.id = i + 1 + + # Present the YAML to the user and let her change it. + fields = self._get_fields(album=False, extra=[]) + success = self.edit_objects(task.items, fields) + + # Remove temporary ids. + for obj in task.items: + obj.id = None + + # Save the new data. + if success: + # Return action.RETAG, which makes the importer write the tags + # to the files if needed without re-applying metadata. + return action.RETAG + else: + # Edit cancelled / no edits made. Revert changes. + for obj in task.items: + obj.read() + + def importer_edit_candidate(self, session, task): + """Callback for invoking the functionality during an interactive + import session on a *candidate*. The candidate's metadata is + applied to the original items. + """ + # Prompt the user for a candidate. + sel = ui.input_options([], numrange=(1, len(task.candidates))) + # Force applying the candidate on the items. + task.match = task.candidates[sel - 1] + task.apply_metadata() + + return self.importer_edit(session, task) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 632952dd85..b279648742 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index a56f9f95aa..57d8e4c469 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -72,9 +72,9 @@ def _logged_get(log, *args, **kwargs): else: message = 'getting URL' - req = requests.Request('GET', *args, **req_kwargs) + req = requests.Request(b'GET', *args, **req_kwargs) with requests.Session() as s: - s.headers = {'User-Agent': 'beets'} + s.headers = {b'User-Agent': b'beets'} prepped = s.prepare_request(req) log.debug('{}: {}', message, prepped.url) return s.send(prepped, **send_kwargs) @@ -96,8 +96,9 @@ def request(self, *args, **kwargs): # ART SOURCES ################################################################ class ArtSource(RequestMixin): - def __init__(self, log): + def __init__(self, log, config): self._log = log + self._config = config def get(self, album): raise NotImplementedError() @@ -157,6 +158,41 @@ def get(self, album): self._log.debug(u'no image found on page') +class GoogleImages(ArtSource): + URL = u'https://www.googleapis.com/customsearch/v1' + + def get(self, album): + """Return art URL from google custom search engine + 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={ + 'key': self._config['google_key'].get(), + 'cx': self._config['google_engine'].get(), + 'q': search_string, + 'searchType': 'image' + }) + + # Get results using JSON. + try: + data = response.json() + except ValueError: + self._log.debug(u'google: error loading response: {}' + .format(response.text)) + return + + if 'error' in data: + reason = data['error']['errors'][0]['reason'] + self._log.debug(u'google fetchart error: {0}', reason) + return + + if 'items' in data.keys(): + for item in data['items']: + yield item['link'] + + class ITunesStore(ArtSource): # Art from the iTunes Store. def get(self, album): @@ -361,7 +397,7 @@ def get(self, path, cover_names, cautious): # Try each source in turn. SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', - u'wikipedia'] + u'wikipedia', u'google'] ART_SOURCES = { u'coverart': CoverArtArchive, @@ -369,6 +405,7 @@ def get(self, path, cover_names, cautious): u'albumart': AlbumArtOrg, u'amazon': Amazon, u'wikipedia': Wikipedia, + u'google': GoogleImages, } # PLUGIN LOGIC ############################################################### @@ -387,7 +424,10 @@ def __init__(self): 'cautious': False, 'cover_names': ['cover', 'front', 'art', 'album', 'folder'], 'sources': ['coverart', 'itunes', 'amazon', 'albumart'], + 'google_key': None, + 'google_engine': u'001442825323518660753:hrh5ch1gjzm', }) + self.config['google_key'].redact = True # Holds paths to downloaded images between fetching them and # placing them in the filesystem. @@ -405,10 +445,14 @@ def __init__(self): available_sources = list(SOURCES_ALL) if not HAVE_ITUNES and u'itunes' in available_sources: available_sources.remove(u'itunes') + if not self.config['google_key'].get() and \ + u'google' in available_sources: + available_sources.remove(u'google') sources_name = plugins.sanitize_choices( self.config['sources'].as_str_seq(), available_sources) - self.sources = [ART_SOURCES[s](self._log) for s in sources_name] - self.fs_source = FileSystem(self._log) + self.sources = [ART_SOURCES[s](self._log, self.config) + for s in sources_name] + self.fs_source = FileSystem(self._log, self.config) # Asynchronous; after music is added to the library. def fetch_art(self, session, task): diff --git a/beetsplug/filefilter.py b/beetsplug/filefilter.py index b3c26ff795..2481390f62 100644 --- a/beetsplug/filefilter.py +++ b/beetsplug/filefilter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Malte Ried. +# Copyright 2016, Malte Ried. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/freedesktop.py b/beetsplug/freedesktop.py index 7660d13fbe..33104fa13b 100644 --- a/beetsplug/freedesktop.py +++ b/beetsplug/freedesktop.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Matt Lichtenberg. +# Copyright 2016, Matt Lichtenberg. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/fromfilename.py b/beetsplug/fromfilename.py index bb3363bfee..895cab9e48 100644 --- a/beetsplug/fromfilename.py +++ b/beetsplug/fromfilename.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Jan-Erik Dahlin +# Copyright 2016, Jan-Erik Dahlin # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/ftintitle.py b/beetsplug/ftintitle.py index 71be9630a8..30892f0dc5 100644 --- a/beetsplug/ftintitle.py +++ b/beetsplug/ftintitle.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Verrus, +# Copyright 2016, Verrus, # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/fuzzy.py b/beetsplug/fuzzy.py index cf64404381..4e167217f6 100644 --- a/beetsplug/fuzzy.py +++ b/beetsplug/fuzzy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Philippe Mongeau. +# Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/ihate.py b/beetsplug/ihate.py index d795da1303..7285c84824 100644 --- a/beetsplug/ihate.py +++ b/beetsplug/ihate.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Blemjhoo Tezoulbr . +# Copyright 2016, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/importfeeds.py b/beetsplug/importfeeds.py index cdf59c1226..c3d6775720 100644 --- a/beetsplug/importfeeds.py +++ b/beetsplug/importfeeds.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/info.py b/beetsplug/info.py index 5f68336e9a..a29a6ccfc5 100644 --- a/beetsplug/info.py +++ b/beetsplug/info.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -119,6 +119,25 @@ def print_data(data, item=None, fmt=None): ui.print_(lineformat.format(field, value)) +def print_data_keys(data, item=None): + """Print only the keys (field names) for an item. + """ + path = displayable_path(item.path) if item else None + formatted = [] + for key, value in data.iteritems(): + formatted.append(key) + + if len(formatted) == 0: + return + + line_format = u'{0}{{0}}'.format(u' ' * 4) + if path: + ui.print_(displayable_path(path)) + + for field in sorted(formatted): + ui.print_(line_format.format(field)) + + class InfoPlugin(BeetsPlugin): def commands(self): @@ -131,6 +150,8 @@ def commands(self): cmd.parser.add_option('-i', '--include-keys', default=[], action='append', dest='included_keys', help='comma separated list of keys to show') + cmd.parser.add_option('-k', '--keys-only', action='store_true', + help='show only the keys') cmd.parser.add_format_option(target='item') return [cmd] @@ -173,7 +194,10 @@ def run(self, lib, opts, args): else: if not first: ui.print_() - print_data(data, item, opts.format) + if opts.keys_only: + print_data_keys(data, item) + else: + print_data(data, item, opts.format) first = False if opts.summarize: diff --git a/beetsplug/inline.py b/beetsplug/inline.py index 0f7279eccb..5c0c81b804 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py index 33bfd47648..fc9c7b148c 100644 --- a/beetsplug/keyfinder.py +++ b/beetsplug/keyfinder.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 8242d9453b..554369ced4 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index f0469c91c7..29560b7f20 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Rafael Bodill http://github.com/rafi +# Copyright 2016, Rafael Bodill http://github.com/rafi # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -16,7 +16,8 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) -import requests +import pylast +from pylast import TopItem, _extract, _number from beets import ui from beets import dbcore from beets import config @@ -31,7 +32,7 @@ def __init__(self): super(LastImportPlugin, self).__init__() config['lastfm'].add({ 'user': '', - 'api_key': '', + 'api_key': plugins.LASTFM_KEY, }) config['lastfm']['api_key'].redact = True self.config.add({ @@ -52,6 +53,63 @@ def func(lib, opts, args): return [cmd] +class CustomUser(pylast.User): + """ Custom user class derived from pylast.User, and overriding the + _get_things method to return MBID and album. Also introduces new + get_top_tracks_by_page method to allow access to more than one page of top + tracks. + """ + def __init__(self, *args, **kwargs): + super(CustomUser, self).__init__(*args, **kwargs) + + def _get_things(self, method, thing, thing_type, params=None, + cacheable=True): + """Returns a list of the most played thing_types by this thing, in a + tuple with the total number of pages of results. Includes an MBID, if + found. + """ + doc = self._request( + self.ws_prefix + "." + method, cacheable, params) + + toptracks_node = doc.getElementsByTagName('toptracks')[0] + total_pages = int(toptracks_node.getAttribute('totalPages')) + + seq = [] + for node in doc.getElementsByTagName(thing): + title = _extract(node, "name") + artist = _extract(node, "name", 1) + mbid = _extract(node, "mbid") + playcount = _number(_extract(node, "playcount")) + + thing = thing_type(artist, title, self.network) + thing.mbid = mbid + seq.append(TopItem(thing, playcount)) + + return seq, total_pages + + def get_top_tracks_by_page(self, period=pylast.PERIOD_OVERALL, limit=None, + page=1, cacheable=True): + """Returns the top tracks played by a user, in a tuple with the total + number of pages of results. + * period: The period of time. Possible values: + o PERIOD_OVERALL + o PERIOD_7DAYS + o PERIOD_1MONTH + o PERIOD_3MONTHS + o PERIOD_6MONTHS + o PERIOD_12MONTHS + """ + + params = self._get_params() + params['period'] = period + params['page'] = page + if limit: + params['limit'] = limit + + return self._get_things( + "getTopTracks", "track", pylast.Track, params, cacheable) + + def import_lastfm(lib, log): user = config['lastfm']['user'].get(unicode) per_page = config['lastimport']['per_page'].get(int) @@ -73,23 +131,19 @@ def import_lastfm(lib, log): '/{}'.format(page_total) if page_total > 1 else '') for retry in range(0, retry_limit): - page = fetch_tracks(user, page_current + 1, per_page) - if 'tracks' in page: - # Let us the reveal the holy total pages! - page_total = int(page['tracks']['@attr']['totalPages']) - if page_total < 1: - # It means nothing to us! - raise ui.UserError('Last.fm reported no data.') - - track = page['tracks']['track'] - found, unknown = process_tracks(lib, track, log) + tracks, page_total = fetch_tracks(user, page_current + 1, per_page) + if page_total < 1: + # It means nothing to us! + raise ui.UserError('Last.fm reported no data.') + + if tracks: + found, unknown = process_tracks(lib, tracks, log) found_total += found unknown_total += unknown break else: log.error('ERROR: unable to read page #{0}', page_current + 1) - log.debug('API response: {}', page) if retry < retry_limit: log.info( 'Retrying page #{0}... ({1}/{2} retry)', @@ -107,14 +161,30 @@ def import_lastfm(lib, log): def fetch_tracks(user, page, limit): - return requests.get(API_URL, params={ - 'method': 'library.gettracks', - 'user': user, - 'api_key': plugins.LASTFM_KEY, - 'page': bytes(page), - 'limit': bytes(limit), - 'format': 'json', - }).json() + """ JSON format: + [ + { + "mbid": "...", + "artist": "...", + "title": "...", + "playcount": "..." + } + ] + """ + network = pylast.LastFMNetwork(api_key=config['lastfm']['api_key']) + user_obj = CustomUser(user, network) + results, total_pages =\ + user_obj.get_top_tracks_by_page(limit=limit, page=page) + return [ + { + "mbid": track.item.mbid if track.item.mbid else '', + "artist": { + "name": track.item.artist.name + }, + "name": track.item.title, + "playcount": track.weight + } for track in results + ], total_pages def process_tracks(lib, tracks, log): @@ -124,7 +194,7 @@ def process_tracks(lib, tracks, log): log.info('Received {0} tracks in this page, processing...', total) for num in xrange(0, total): - song = '' + song = None trackid = tracks[num]['mbid'].strip() artist = tracks[num]['artist'].get('name', '').strip() title = tracks[num]['name'].strip() @@ -140,19 +210,8 @@ def process_tracks(lib, tracks, log): dbcore.query.MatchQuery('mb_trackid', trackid) ).get() - # Otherwise try artist/title/album - if not song: - log.debug(u'no match for mb_trackid {0}, trying by ' - u'artist/title/album', trackid) - query = dbcore.AndQuery([ - dbcore.query.SubstringQuery('artist', artist), - dbcore.query.SubstringQuery('title', title), - dbcore.query.SubstringQuery('album', album) - ]) - song = lib.items(query).get() - # If not, try just artist/title - if not song: + if song is None: log.debug(u'no album match, trying by artist/title') query = dbcore.AndQuery([ dbcore.query.SubstringQuery('artist', artist), @@ -161,7 +220,7 @@ def process_tracks(lib, tracks, log): song = lib.items(query).get() # Last resort, try just replacing to utf-8 quote - if not song: + if song is None: title = title.replace("'", u'\u2019') log.debug(u'no title match, trying utf-8 single quote') query = dbcore.AndQuery([ @@ -170,7 +229,7 @@ def process_tracks(lib, tracks, log): ]) song = lib.items(query).get() - if song: + if song is not None: count = int(song.get('play_count', 0)) new_count = int(tracks[num]['playcount']) log.debug(u'match: {0} - {1} ({2}) ' diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index 1af34df992..9e5e2fad63 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -237,25 +237,45 @@ def search_genius(self, artist, title): url = u'https://api.genius.com/search?q=%s' \ % (urllib.quote(query.encode('utf8'))) - data = requests.get( - url, - headers=self.headers, - allow_redirects=True - ).content + self._log.debug('genius: requesting search {}', url) + try: + req = requests.get( + url, + headers=self.headers, + allow_redirects=True + ) + req.raise_for_status() + except requests.RequestException as exc: + self._log.debug('genius: request error: {}', exc) + return None - return json.loads(data) + try: + return req.json() + except ValueError: + self._log.debug('genius: invalid response: {}', req.text) + return None def get_lyrics(self, link): url = u'http://genius-api.com/api/lyricsInfo' - data = requests.post( - url, - data={'link': link}, - headers=self.headers, - allow_redirects=True - ).content + self._log.debug('genius: requesting lyrics for link {}', link) + try: + req = requests.post( + url, + data={'link': link}, + headers=self.headers, + allow_redirects=True + ) + req.raise_for_status() + except requests.RequestException as exc: + self._log.debug('genius: request error: {}', exc) + return None - return json.loads(data) + try: + return req.json() + except ValueError: + self._log.debug('genius: invalid response: {}', req.text) + return None def build_lyric_string(self, lyrics): if 'lyrics' not in lyrics: @@ -274,6 +294,8 @@ def build_lyric_string(self, lyrics): def fetch(self, artist, title): search_data = self.search_genius(artist, title) + if not search_data: + return if not search_data['meta']['status'] == 200: return @@ -284,6 +306,8 @@ def fetch(self, artist, title): record_url = records[0]['result']['url'] lyric_data = self.get_lyrics(record_url) + if not lyric_data: + return lyrics = self.build_lyric_string(lyric_data) return lyrics @@ -513,7 +537,7 @@ def fetch(self, artist, title): class LyricsPlugin(plugins.BeetsPlugin): - SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch', 'genius'] + SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch'] SOURCE_BACKENDS = { 'google': Google, 'lyricwiki': LyricsWiki, diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py new file mode 100644 index 0000000000..947e4cfc61 --- /dev/null +++ b/beetsplug/mbsubmit.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, 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. + +"""Aid in submitting information to MusicBrainz. + +This plugin allows the user to print track information in a format that is +parseable by the MusicBrainz track parser [1]. Programmatic submitting is not +implemented by MusicBrainz yet. + +[1] http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings +""" + +from __future__ import (division, absolute_import, print_function, + unicode_literals) + + +from beets.autotag import Recommendation +from beets.plugins import BeetsPlugin +from beets.ui.commands import PromptChoice +from beetsplug.info import print_data + + +class MBSubmitPlugin(BeetsPlugin): + def __init__(self): + super(MBSubmitPlugin, self).__init__() + + self.config.add({ + 'format': '$track. $title - $artist ($length)', + 'threshold': 'medium', + }) + + # Validate and store threshold. + self.threshold = self.config['threshold'].as_choice({ + 'none': Recommendation.none, + 'low': Recommendation.low, + 'medium': Recommendation.medium, + 'strong': Recommendation.strong + }) + + self.register_listener('before_choose_candidate', + self.before_choose_candidate_event) + + def before_choose_candidate_event(self, session, task): + if task.rec <= self.threshold: + return [PromptChoice('p', 'Print tracks', self.print_tracks)] + + def print_tracks(self, session, task): + for i in task.items: + print_data(None, i, self.config['format'].get()) diff --git a/beetsplug/mbsync.py b/beetsplug/mbsync.py index 99456da2c8..00eb966fad 100644 --- a/beetsplug/mbsync.py +++ b/beetsplug/mbsync.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Jakob Schnitzer. +# Copyright 2016, Jakob Schnitzer. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/metasync/__init__.py b/beetsplug/metasync/__init__.py index 7ddbb3091f..219d734740 100644 --- a/beetsplug/metasync/__init__.py +++ b/beetsplug/metasync/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Heinz Wiesinger. +# Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/metasync/amarok.py b/beetsplug/metasync/amarok.py index a591cf923f..78241dc1b5 100644 --- a/beetsplug/metasync/amarok.py +++ b/beetsplug/metasync/amarok.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Heinz Wiesinger. +# Copyright 2016, Heinz Wiesinger. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/metasync/itunes.py b/beetsplug/metasync/itunes.py index 75a612c280..ead77cd395 100644 --- a/beetsplug/metasync/itunes.py +++ b/beetsplug/metasync/itunes.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Tom Jaspers. +# Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/missing.py b/beetsplug/missing.py index 648b202bab..05e66b7a86 100644 --- a/beetsplug/missing.py +++ b/beetsplug/missing.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Pedro Silva. +# Copyright 2016, Pedro Silva. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index dcf43ec625..326afe4dad 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Peter Schnebel and Johann Klähn. +# Copyright 2016, Peter Schnebel and Johann Klähn. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 8b072ecf3f..5e4e999aa9 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/play.py b/beetsplug/play.py index a330743129..b52b7e6354 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, David Hamp-Gonsalves +# Copyright 2016, David Hamp-Gonsalves # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -41,6 +41,8 @@ def __init__(self): 'use_folders': False, 'relative_to': None, 'raw': False, + # Backwards compatibility. See #1803 and line 74 + 'warning_threshold': -2, 'warning_treshold': 100, }) @@ -63,10 +65,23 @@ def play_music(self, lib, opts, args): command passing that playlist, at request insert optional arguments. """ command_str = config['play']['command'].get() + if not command_str: + command_str = util.open_anything() use_folders = config['play']['use_folders'].get(bool) relative_to = config['play']['relative_to'].get() raw = config['play']['raw'].get(bool) - warning_treshold = config['play']['warning_treshold'].get(int) + warning_threshold = config['play']['warning_threshold'].get(int) + # We use -2 as a default value for warning_threshold to detect if it is + # set or not. We can't use a falsey value because it would have an + # actual meaning in the configuration of this plugin, and we do not use + # -1 because some people might use it as a value to obtain no warning, + # which wouldn't be that bad of a practice. + if warning_threshold == -2: + # if warning_threshold has not been set by user, look for + # warning_treshold, to preserve backwards compatibility. See #1803. + # warning_treshold has the correct default value of 100. + warning_threshold = config['play']['warning_treshold'].get(int) + if relative_to: relative_to = util.normpath(relative_to) @@ -108,7 +123,7 @@ def play_music(self, lib, opts, args): return # Warn user before playing any huge playlists. - if warning_treshold and len(selection) > warning_treshold: + if warning_threshold and len(selection) > warning_threshold: ui.print_(ui.colorize( 'text_warning', 'You are about to queue {0} {1}.'.format(len(selection), @@ -129,13 +144,8 @@ def play_music(self, lib, opts, args): try: util.interactive_open(open_args, command_str) except OSError as exc: - raise ui.UserError("Could not play the music playlist: " + raise ui.UserError("Could not play the query: " "{0}".format(exc)) - finally: - if not raw: - self._log.debug('Removing temporary playlist: {}', - open_args[0]) - util.remove(open_args[0]) def _create_tmp_playlist(self, paths_list): """Create a temporary .m3u file. Return the filename. diff --git a/beetsplug/random.py b/beetsplug/random.py index 7f3317b816..22ae23252b 100644 --- a/beetsplug/random.py +++ b/beetsplug/random.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Philippe Mongeau. +# Copyright 2016, Philippe Mongeau. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 422c345d89..9e5bff4e05 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. +# Copyright 2016, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -43,6 +43,11 @@ class FatalReplayGainError(Exception): """ +class FatalGstreamerPluginReplayGainError(FatalReplayGainError): + """Raised when a fatal error occurs in the GStreamerBackend when + loading the required plugins.""" + + def call(args): """Execute the command and return its output or raise a ReplayGainError on failure. @@ -391,7 +396,7 @@ def __init__(self, config, log): if self._src is None or self._decbin is None or self._conv is None \ or self._res is None or self._rg is None: - raise FatalReplayGainError( + raise FatalGstreamerPluginReplayGainError( "Failed to load required GStreamer plugins" ) @@ -496,14 +501,25 @@ def compute_album_gain(self, album): if len(self._file_tags) != len(items): raise ReplayGainError("Some items in album did not receive tags") - ret = [] + # Collect track gains. + track_gains = [] for item in items: - ret.append(Gain(self._file_tags[item]["TRACK_GAIN"], - self._file_tags[item]["TRACK_PEAK"])) + try: + gain = self._file_tags[item]["TRACK_GAIN"] + peak = self._file_tags[item]["TRACK_PEAK"] + except KeyError: + raise ReplayGainError("results missing for track") + track_gains.append(Gain(gain, peak)) + # Get album gain information from the last track. last_tags = self._file_tags[items[-1]] - return AlbumGain(Gain(last_tags["ALBUM_GAIN"], - last_tags["ALBUM_PEAK"]), ret) + try: + gain = last_tags["ALBUM_GAIN"] + peak = last_tags["ALBUM_PEAK"] + except KeyError: + raise ReplayGainError("results missing for album") + + return AlbumGain(Gain(gain, peak), track_gains) def close(self): self._bus.remove_signal_watch() @@ -522,10 +538,9 @@ def _on_error(self, bus, message): err, debug = message.parse_error() f = self._src.get_property("location") # A GStreamer error, either an unsupported format or a bug. - self._error = \ - ReplayGainError(u"Error {0} - {1} on file {2}".format(err, - debug, - f)) + self._error = ReplayGainError( + "Error {0!r} - {1!r} on file {2!r}".format(err, debug, f) + ) def _on_tag(self, bus, message): tags = message.parse_tag() @@ -728,7 +743,7 @@ def _compute_track_gain(self, item): # Each call to title_gain on a ReplayGain object returns peak and gain # of the track. - rg_track_gain, rg_track_peak = rg._title_gain(rg, audiofile) + rg_track_gain, rg_track_peak = self._title_gain(rg, audiofile) self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}', item.artist, item.title, rg_track_gain, rg_track_peak) diff --git a/beetsplug/rewrite.py b/beetsplug/rewrite.py index b08fb1fb6f..2327bc7800 100644 --- a/beetsplug/rewrite.py +++ b/beetsplug/rewrite.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/scrub.py b/beetsplug/scrub.py index 4e37ad7ff8..34e6428ad8 100644 --- a/beetsplug/scrub.py +++ b/beetsplug/scrub.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index f404d98d1b..cc988b22ca 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Dang Mai . +# Copyright 2016, Dang Mai . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/the.py b/beetsplug/the.py index d13eb1aca9..b6ffc945fd 100644 --- a/beetsplug/the.py +++ b/beetsplug/the.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Blemjhoo Tezoulbr . +# Copyright 2016, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index fef512dc8f..ca2a942a93 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Bruno Cauet +# Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/types.py b/beetsplug/types.py index a203834d56..4c5d20a45f 100644 --- a/beetsplug/types.py +++ b/beetsplug/types.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index a6e6f55c9d..3779156264 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/beetsplug/web/static/jquery.js b/beetsplug/web/static/jquery.js index 5b43a3eced..e14142126e 100644 --- a/beetsplug/web/static/jquery.js +++ b/beetsplug/web/static/jquery.js @@ -2,13 +2,13 @@ * jQuery JavaScript Library v1.7.1 * http://jquery.com/ * - * Copyright 2015, John Resig + * Copyright 2016, John Resig * Dual licensed under the MIT or GPL Version 2 licenses. * http://jquery.org/license * * Includes Sizzle.js * http://sizzlejs.com/ - * Copyright 2015, The Dojo Foundation + * Copyright 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * * Date: Mon Nov 21 21:11:03 2011 -0500 @@ -3851,7 +3851,7 @@ jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblcl /*! * Sizzle CSS Selector Engine - * Copyright 2015, The Dojo Foundation + * Copyright 2016, The Dojo Foundation * Released under the MIT, BSD, and GPL Licenses. * More information: http://sizzlejs.com/ */ diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 8e81473dbe..b427bbe8a2 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Blemjhoo Tezoulbr . +# Copyright 2016, Blemjhoo Tezoulbr . # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/docs/Makefile b/docs/Makefile index fd1707f27d..f940dd931f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -38,10 +38,6 @@ help: @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" -# My magical rebuilding, Safari-reloading auto target. -auto: - watchmedo shell-command --patterns='*.rst' --ignore-pattern='_build/*' --recursive --command='if [ "$${watch_event_type}" == "created" -o "$${watch_event_type}" == "modified" ]; then make html ; osascript -l JavaScript refresh_safari.js; fi' --wait - clean: -rm -rf $(BUILDDIR)/* diff --git a/docs/changelog.rst b/docs/changelog.rst index 49f79f5554..a2c19dcabd 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,13 +1,135 @@ Changelog ========= -1.3.16 (in development) +1.3.18 (in development) ----------------------- -New: +New features: + +* :doc:`/plugins/convert`: A new `album_art_maxwidth` lets you resize album + art while copying it. + +Fixes: + +* Fix a problem with the :ref:`stats-cmd` in exact mode when filenames on + Windows use non-ASCII characters. :bug:`1891` +* Fix a crash when iTunes Sound Check tags contained invalid data. :bug:`1895` + + +1.3.17 (February 7, 2016) +------------------------- + +This release introduces one new plugin to fetch audio information from the +`AcousticBrainz`_ project and another plugin to make it easier to submit your +handcrafted metadata back to MusicBrainz. +The importer also gained two oft-requested features: a way to skip the initial +search process by specifying an ID ahead of time, and a way to *manually* +provide metadata in the middle of the import process (via the +:doc:`/plugins/edit`). + +Also, as of this release, the beets project has some new Internet homes! Our +new domain name is `beets.io`_, and we have a shiny new GitHub organization: +`beetbox`_. + +Here are the big new features: + +* A new :doc:`/plugins/acousticbrainz` fetches acoustic-analysis information + from the `AcousticBrainz`_ project. Thanks to :user:`opatel99`, and thanks + to `Google Code-In`_! :bug:`1784` +* A new :doc:`/plugins/mbsubmit` lets you print music's current metadata in a + format that the MusicBrainz data parser can understand. You can trigger it + during an interactive import session. :bug:`1779` +* A new ``--search-id`` importer option lets you manually specify + IDs (i.e., MBIDs or Discogs IDs) for imported music. Doing this skips the + initial candidate search, which can be important for huge albums where this + initial lookup is slow. + Also, the ``enter Id`` prompt choice now accepts several IDs, separated by + spaces. :bug:`1808` +* :doc:`/plugins/edit`: You can now edit metadata *on the fly* during the + import process. The plugin provides two new interactive options: one to edit + *your music's* metadata, and one to edit the *matched metadata* retrieved + from MusicBrainz (or another data source). This feature is still in its + early stages, so please send feedback if you find anything missing. + :bug:`1846` :bug:`396` + +There are even more new features: + +* :doc:`/plugins/fetchart`: The Google Images backend has been restored. It + now requires an API key from Google. Thanks to :user:`lcharlick`. + :bug:`1778` +* :doc:`/plugins/info`: A new option will print only fields' names and not + their values. Thanks to :user:`GuilhermeHideki`. :bug:`1812` +* The :ref:`fields-cmd` command now displays flexible attributes. + Thanks to :user:`GuilhermeHideki`. :bug:`1818` +* The :ref:`modify-cmd` command lets you interactively select which albums or + items you want to change. :bug:`1843` +* The :ref:`move-cmd` command gained a new ``--timid`` flag to print and + confirm which files you want to move. :bug:`1843` +* The :ref:`move-cmd` command no longer prints filenames for files that + don't actually need to be moved. :bug:`1583` + +.. _Google Code-In: https://codein.withgoogle.com/ +.. _AcousticBrainz: http://acousticbrainz.org/ + +Fixes: + +* :doc:`/plugins/play`: Fix a regression in the last version where there was + no default command. :bug:`1793` +* :doc:`/plugins/lastimport`: The plugin now works again after being broken by + some unannounced changes to the Last.fm API. :bug:`1574` +* :doc:`/plugins/play`: Fixed a typo in a configuration option. The option is + now ``warning_threshold`` instead of ``warning_treshold``, but we kept the + old name around for compatibility. Thanks to :user:`JesseWeinstein`. + :bug:`1802` :bug:`1803` +* :doc:`/plugins/edit`: Editing metadata now moves files, when appropriate + (like the :ref:`modify-cmd` command). :bug:`1804` +* The :ref:`stats-cmd` command no longer crashes when files are missing or + inaccessible. :bug:`1806` +* :doc:`/plugins/fetchart`: Possibly fix a Unicode-related crash when using + some versions of pyOpenSSL. :bug:`1805` +* :doc:`/plugins/replaygain`: Fix an intermittent crash with the GStreamer + backend. :bug:`1855` +* :doc:`/plugins/lastimport`: The plugin now works with the beets API key by + default. You can still provide a different key the configuration. +* :doc:`/plugins/replaygain`: Fix a crash using the Python Audio Tools + backend. :bug:`1873` + +.. _beets.io: http://beets.io/ +.. _Beetbox: https://github.com/beetbox + + + +1.3.16 (December 28, 2015) +-------------------------- + +The big news in this release is a new :doc:`interactive editor plugin +`. It's really nifty: you can now change your music's metadata +by making changes in a visual text editor, which can sometimes be far more +efficient than the built-in :ref:`modify-cmd` command. No more carefully +retyping the same artist name with slight capitalization changes. + +This version also adds an oft-requested "not" operator to beets' queries, so +you can exclude music from any operation. It also brings friendlier formatting +(and querying!) of song durations. + +The big new stuff: * A new :doc:`/plugins/edit` lets you manually edit your music's metadata using your favorite text editor. :bug:`164` :bug:`1706` +* Queries can now use "not" logic. Type a ``^`` before part of a query to + *exclude* matching music from the results. For example, ``beet list -a + beatles ^album:1`` will find all your albums by the Beatles except for their + singles compilation, "1." See :ref:`not_query`. :bug:`819` :bug:`1728` +* A new :doc:`/plugins/embyupdate` can trigger a library refresh on an `Emby`_ + server when your beets database changes. +* Track length is now displayed as "M:SS" rather than a 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` + +Smaller changes: + * Three commands, ``modify``, ``update``, and ``mbsync``, would previously move files by default after changing their metadata. Now, these commands will only move files if you have the :ref:`config-import-copy` or @@ -20,25 +142,17 @@ New: various-artists albums. The setting defaults to "Various Artists," the MusicBrainz standard. In order to match MusicBrainz, the :doc:`/plugins/discogs` also adopts the same setting. -* :doc:`/plugins/embyupdate`: A plugin to trigger a library refresh on a - `Emby Server`_ if database changed. -* Queries can now use "not" logic. Type a ``^`` before part of a query to - *exclude* matching music from the results. For example, ``beet list -a - beatles ^album:1`` will find all your albums by the Beatles except for their - 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` +* :doc:`/plugins/info`: The ``info`` command now accepts a ``-f/--format`` + option for customizing how items are displayed, just like the built-in + ``list`` command. :bug:`1737` -For developers: +Some changes for developers: -* :doc:`/dev/plugins`: Two new hooks, ``albuminfo_received`` and +* Two new :ref:`plugin hooks `, ``albuminfo_received`` and ``trackinfo_received``, let plugins intercept metadata as soon as it is received, before it is applied to music in the database. :bug:`872` +* Plugins can now add options to the interactive importer prompts. See + :ref:`append_prompt_choices`. :bug:`1758` Fixes: @@ -50,12 +164,13 @@ Fixes: and name in quick succession. The importer would fail to detect them as duplicates, claiming that there were "empty albums" in the database even when there were not. :bug:`1652` -* :doc:`plugins/lastgenre`: Clean up the reggae related genres somewhat. +* :doc:`plugins/lastgenre`: Clean up the reggae-related genres somewhat. Thanks to :user:`Freso`. :bug:`1661` * The importer now correctly moves album art files when re-importing. :bug:`314` -* :doc:`/plugins/fetchart`: In auto mode, skips albums that already have - art attached to them so as not to interfere with re-imports. :bug:`314` +* :doc:`/plugins/fetchart`: In auto mode, the plugin now skips albums that + already have art attached to them so as not to interfere with re-imports. + :bug:`314` * :doc:`plugins/fetchart`: The plugin now only resizes album art if necessary, rather than always by default. :bug:`1264` * :doc:`plugins/fetchart`: Fix a bug where a database reference to a @@ -86,12 +201,17 @@ Fixes: 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` + scrubs files on import, as the documentation always claimed it did---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` +* Possibly allow flexible attributes to be used with the ``%aunique`` template + function. :bug:`1775` +* :doc:`/plugins/lyrics`: The Genius backend is now more robust to + communication errors. The backend has also been disabled by default, since + the API it depends on is currently down. :bug:`1770` -.. _Emby Server: http://emby.media +.. _Emby: http://emby.media 1.3.15 (October 17, 2015) @@ -1370,7 +1490,7 @@ previous versions would spit out a warning and then list your entire library. There's more detail than you could ever need `on the beets blog`_. -.. _on the beets blog: http://beets.radbox.org/blog/flexattr.html +.. _on the beets blog: http://beets.io/blog/flexattr.html 1.2.2 (August 27, 2013) @@ -1958,7 +2078,7 @@ begins today on features for version 1.1. unintentionally loading the plugins they contain. .. _The Echo Nest: http://the.echonest.com/ -.. _Tomahawk resolver: http://beets.radbox.org/blog/tomahawk-resolver.html +.. _Tomahawk resolver: http://beets.io/blog/tomahawk-resolver.html .. _mp3gain: http://mp3gain.sourceforge.net/download.php .. _aacgain: http://aacgain.altosdesign.com @@ -2155,7 +2275,7 @@ release. * Significant internal restructuring to avoid SQLite locking errors. As part of these changes, the not-very-useful "save" plugin event has been removed. -.. _pyacoustid: https://github.com/sampsyo/pyacoustid +.. _pyacoustid: https://github.com/beetbox/pyacoustid 1.0b13 (March 16, 2012) diff --git a/docs/conf.py b/docs/conf.py index d952b410e3..ad100b4672 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,16 +11,16 @@ master_doc = 'index' project = u'beets' -copyright = u'2012, Adrian Sampson' +copyright = u'2016, Adrian Sampson' version = '1.3' -release = '1.3.16' +release = '1.3.18' pygments_style = 'sphinx' # External links to the bug tracker. extlinks = { - 'bug': ('https://github.com/sampsyo/beets/issues/%s', '#'), + 'bug': ('https://github.com/beetbox/beets/issues/%s', '#'), 'user': ('https://github.com/%s', ''), } diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index 885ef22229..80409d5f5b 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -227,12 +227,17 @@ The events currently available are: of a ``TrackInfo``. Parameter: ``info``. +* `before_choose_candidate`: called before the user is prompted for a decision + during a ``beet import`` interactive session. Plugins can use this event for + :ref:`appending choices to the prompt ` by returning a + list of ``PromptChoices``. Parameters: ``task`` and ``session``. + The included ``mpdupdate`` plugin provides an example use case for event listeners. Extend the Autotagger ^^^^^^^^^^^^^^^^^^^^^ -Plugins in can also enhance the functionality of the autotagger. For a +Plugins can also enhance the functionality of the autotagger. For a comprehensive example, try looking at the ``chroma`` plugin, which is included with beets. @@ -512,19 +517,83 @@ str.format-style string formatting. So you can write logging calls like this:: When beets is in verbose mode, plugin messages are prefixed with the plugin name to make them easier to see. -What messages will be logged depends on the logging level and the action +Which messages will be logged depends on the logging level and the action performed: -* On import stages and event handlers, the default is ``WARNING`` messages and - above. -* On direct actions, the default is ``INFO`` or above, as with the rest of - beets. +* Inside import stages and event handlers, the default is ``WARNING`` messages + and above. +* Everywhere else, the default is ``INFO`` or above. -The verbosity can be increased with ``--verbose`` flags: each flags lowers the -level by a notch. +The verbosity can be increased with ``--verbose`` (``-v``) flags: each flags +lowers the level by a notch. That means that, with a single ``-v`` flag, event +handlers won't have their ``DEBUG`` messages displayed, but command functions +(for example) will. With ``-vv`` on the command line, ``DEBUG`` messages will +be displayed everywhere. This addresses a common pattern where plugins need to use the same code for a command and an import stage, but the command needs to print more messages than the import stage. (For example, you'll want to log "found lyrics for this song" when you're run explicitly as a command, but you don't want to noisily interrupt the importer interface when running automatically.) + +.. _append_prompt_choices: + +Append Prompt Choices +^^^^^^^^^^^^^^^^^^^^^ + +Plugins can also append choices to the prompt presented to the user during +an import session. + +To do so, add a listener for the ``before_choose_candidate`` event, and return +a list of ``PromptChoices`` that represent the additional choices that your +plugin shall expose to the user:: + + from beets.plugins import BeetsPlugin + from beets.ui.commands import PromptChoice + + class ExamplePlugin(BeetsPlugin): + def __init__(self): + super(ExamplePlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.before_choose_candidate_event) + + def before_choose_candidate_event(self, session, task): + return [PromptChoice('p', 'Print foo', self.foo), + PromptChoice('d', 'Do bar', self.bar)] + + def foo(self, session, task): + print('User has chosen "Print foo"!') + + def bar(self, session, task): + print('User has chosen "Do bar"!') + +The previous example modifies the standard prompt:: + + # selection (default 1), Skip, Use as-is, as Tracks, Group albums, + Enter search, enter Id, aBort? + +by appending two additional options (``Print foo`` and ``Do bar``):: + + # selection (default 1), Skip, Use as-is, as Tracks, Group albums, + Enter search, enter Id, aBort, Print foo, Do bar? + +If the user selects a choice, the ``callback`` attribute of the corresponding +``PromptChoice`` will be called. It is the responsibility of the plugin to +check for the status of the import session and decide the choices to be +appended: for example, if a particular choice should only be presented if the +album has no candidates, the relevant checks against ``task.candidates`` should +be performed inside the plugin's ``before_choose_candidate_event`` accordingly. + +Please make sure that the short letter for each of the choices provided by the +plugin is not already in use: the importer will emit a warning and discard +all but one of the choices using the same letter, giving priority to the +core importer prompt choices. As a reference, the following characters are used +by the choices on the core importer prompt, and hence should not be used: +``a``, ``s``, ``u``, ``t``, ``g``, ``e``, ``i``, ``b``. + +Additionally, the callback function can optionally specify the next action to +be performed by returning one of the values from ``importer.action``, which +will be passed to the main loop upon the callback has been processed. Note that +``action.MANUAL`` and ``action.MANUAL_ID`` will have no effect even if +returned by the callback, due to the current architecture of the import +process. diff --git a/docs/faq.rst b/docs/faq.rst index 0504615878..974b0dce62 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -138,13 +138,13 @@ it's helpful to run on the "bleeding edge". To run the latest source: 2. Install from source. There are a few easy ways to do this: - Use ``pip`` to install the latest snapshot tarball: just type - ``pip install https://github.com/sampsyo/beets/tarball/master``. + ``pip install https://github.com/beetbox/beets/tarball/master``. - Grab the source using Git: - ``git clone https://github.com/sampsyo/beets.git``. Then + ``git clone https://github.com/beetbox/beets.git``. Then ``cd beets`` and type ``python setup.py install``. - Use ``pip`` to install an "editable" version of beets based on an automatic source checkout. For example, run - ``pip install -e git+https://github.com/sampsyo/beets#egg=beets`` + ``pip install -e git+https://github.com/beetbox/beets#egg=beets`` to clone beets and install it, allowing you to modify the source in-place to try out changes. @@ -157,8 +157,8 @@ pages. …report a bug in beets? ----------------------- -We use the `issue tracker `__ -on GitHub. `Enter a new issue `__ +We use the `issue tracker `__ +on GitHub. `Enter a new issue `__ there to report a bug. Please follow these guidelines when reporting an issue: - Most importantly: if beets is crashing, please `include the @@ -221,7 +221,7 @@ move all your files. If you've already moved your music *outside* of beets, you have a few options: - Move the music back (with an ordinary ``mv``) and then use the above steps. -- Delete your database and re-create it from the new paths using ``beet import -AWMC``. +- Delete your database and re-create it from the new paths using ``beet import -AWC``. - Resort to manually modifying the SQLite database (not recommended). @@ -325,7 +325,7 @@ integrity---for example, type ``metaflac --list music.flac`` to check FLAC files. If beets still complains about a file that seems to be valid, `file a -bug `__ and we'll look into +bug `__ and we'll look into it. There's always a possibility that there's a bug "upstream" in the `Mutagen `__ library used by beets, in which case we'll forward the bug to that project's tracker. diff --git a/docs/guides/advanced.rst b/docs/guides/advanced.rst index c83c39f270..55bda7aac9 100644 --- a/docs/guides/advanced.rst +++ b/docs/guides/advanced.rst @@ -78,7 +78,7 @@ including a beets server. Just download Tomahawk and open its settings to connect it to beets. `A post on the beets blog`_ has a more detailed guide. .. _A post on the beets blog: - http://beets.radbox.org/blog/tomahawk-resolver.html + http://beets.io/blog/tomahawk-resolver.html .. _Tomahawk: http://www.tomahawk-player.org @@ -117,7 +117,12 @@ using the :ref:`modify-cmd` command:: beet modify context=party artist:'beastie boys' -And then :doc:`query ` your music just as you would with any +By default beets will show you the changes that are about to be applied and ask +if you really want to apply them to all, some or none of the items or albums. +You can type y for "yes", n for "no", or s for "select". If you choose the latter, +the command will prompt you for each individual matching item or album. + +Then :doc:`query ` your music just as you would with any other field:: beet ls context:mope @@ -132,7 +137,7 @@ And, unlike :ref:`built-in fields `, such fields can be removed:: Read more than you ever wanted to know about the *flexible attributes* feature `on the beets blog`_. -.. _on the beets blog: http://beets.radbox.org/blog/flexattr.html +.. _on the beets blog: http://beets.io/blog/flexattr.html Choose a path style manually for some music diff --git a/docs/guides/main.rst b/docs/guides/main.rst index b43e0e9b1e..2d80eb2fd3 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -4,7 +4,7 @@ Getting Started Welcome to `beets`_! This guide will help you begin using it to make your music collection better. -.. _beets: http://beets.radbox.org/ +.. _beets: http://beets.io/ Installing ---------- @@ -101,7 +101,7 @@ trouble or you have more detail to contribute here, please direct it to `the mailing list`_. .. _install Python: http://python.org/download/ -.. _beets.reg: https://github.com/sampsyo/beets/blob/master/extra/beets.reg +.. _beets.reg: https://github.com/beetbox/beets/blob/master/extra/beets.reg .. _install pip: http://www.pip-installer.org/en/latest/installing.html#install-pip .. _get-pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index c557bbcaf0..0b21644bf1 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -60,8 +60,8 @@ all of these limitations. because beets by default infers tags based on existing metadata. But this is not a hard and fast rule---there are a few ways to tag metadata-poor music: - * You can use the *E* option described below to search in MusicBrainz for - a specific album or song. + * You can use the *E* or *I* options described below to search in + MusicBrainz for a specific album or song. * The :doc:`Acoustid plugin ` extends the autotagger to use acoustic fingerprinting to find information for arbitrary audio. Install that plugin if you're willing to spend a little more CPU power @@ -76,7 +76,7 @@ all of these limitations. Musepack, Windows Media, Opus, and AIFF files are supported. (Do you use some other format? Please `file a feature request`_!) -.. _file a feature request: https://github.com/sampsyo/beets/issues/new +.. _file a feature request: https://github.com/beetbox/beets/issues/new Now that that's out of the way, let's tag some music. @@ -160,10 +160,10 @@ When beets needs your input about a match, it says something like this:: Beirut - Lon Gisland (Similarity: 94.4%) * Scenic World (Second Version) -> Scenic World - [A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, or aBort? + [A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, enter Id, or aBort? When beets asks you this question, it wants you to enter one of the capital -letters: A, M, S, U, T, G, E, or B. That is, you can choose one of the +letters: A, M, S, U, T, G, E, I or B. That is, you can choose one of the following: * *A*: Apply the suggested changes shown and move on. @@ -190,6 +190,11 @@ following: option if beets hasn't found any good options because the album is mistagged or untagged. +* *I*: Enter a metadata backend ID to use as search in the database. Use this + option to specify a backend entity (for example, a MusicBrainz release or + recording) directly, by pasting its ID or the full URL. You can also specify + several IDs by separating them by a space. + * *B*: Cancel this import task altogether. No further albums will be tagged; beets shuts down immediately. The next time you attempt to import the same directory, though, beets will ask you if you want to resume tagging where you @@ -281,7 +286,7 @@ MusicBrainz---so consider adding the data yourself. If you think beets is ignoring an album that's listed in MusicBrainz, please `file a bug report`_. -.. _file a bug report: https://github.com/sampsyo/beets/issues +.. _file a bug report: https://github.com/beetbox/beets/issues I Hope That Makes Sense ----------------------- diff --git a/docs/index.rst b/docs/index.rst index 57a736a485..7728693895 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,9 +17,9 @@ Freenode, send email to `the mailing list`_, or `file a bug`_ in the issue tracker. Please let us know where you think this documentation can be improved. -.. _beets: http://beets.radbox.org/ +.. _beets: http://beets.io/ .. _the mailing list: http://groups.google.com/group/beets-users -.. _file a bug: https://github.com/sampsyo/beets/issues +.. _file a bug: https://github.com/beetbox/beets/issues Contents -------- diff --git a/docs/plugins/acousticbrainz.rst b/docs/plugins/acousticbrainz.rst new file mode 100644 index 0000000000..9be9bede74 --- /dev/null +++ b/docs/plugins/acousticbrainz.rst @@ -0,0 +1,54 @@ +AcousticBrainz Plugin +===================== + +The ``acousticbrainz`` plugin gets acoustic-analysis information from the +`AcousticBrainz`_ project. The spirit is similar to the +:doc:`/plugins/echonest`. + +.. _AcousticBrainz: http://acousticbrainz.org/ + +Enable the ``acousticbrainz`` plugin in your configuration (see :ref:`using-plugins`) and run it by typing:: + + $ beet acousticbrainz [QUERY] + +For all tracks with a MusicBrainz recording ID, the plugin currently sets +these fields: + +* ``average_loudness`` +* ``chords_changes_rate`` +* ``chords_key`` +* ``chords_number_rate`` +* ``chords_scale`` +* ``danceable`` +* ``gender`` +* ``genre_rosamerica`` +* ``initial_key`` (This is a built-in beets field, which can also be provided + by :doc:`/plugins/keyfinder`.) +* ``key_strength`` +* ``mood_acoustic`` +* ``mood_aggressive`` +* ``mood_electronic`` +* ``mood_happy`` +* ``mood_party`` +* ``mood_relaxed`` +* ``mood_sad`` +* ``rhythm`` +* ``tonal`` +* ``voice_instrumental`` + +Automatic Tagging +----------------- + +To automatically tag files using AcousticBrainz data during import, just +enable the ``acousticbrainz`` plugin (see :ref:`using-plugins`). When importing +new files, beets will query the AcousticBrainz API using MBID and +set the appropriate metadata. + +Configuration +------------- + +To configure the plugin, make a ``acousticbrainz:`` section in your +configuration file. There is one option: + +- **auto**: Enable AcousticBrainz during ``beet import``. + Default: ``yes``. diff --git a/docs/plugins/chroma.rst b/docs/plugins/chroma.rst index 2f52aa8580..b2a9f90c6b 100644 --- a/docs/plugins/chroma.rst +++ b/docs/plugins/chroma.rst @@ -77,8 +77,8 @@ You will also need a mechanism for decoding audio files supported by the * On Windows, try the Gstreamer "WinBuilds" from the `OSSBuild`_ project. -.. _audioread: https://github.com/sampsyo/audioread -.. _pyacoustid: http://github.com/sampsyo/pyacoustid +.. _audioread: https://github.com/beetbox/audioread +.. _pyacoustid: http://github.com/beetbox/pyacoustid .. _FFmpeg: http://ffmpeg.org/ .. _MAD: http://spacepants.org/src/pymad/ .. _pymad: http://www.underbit.com/products/mad/ diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index 7480bacbb7..f5b2580655 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -62,6 +62,9 @@ file. The available options are: Default: none (system default), - **copy_album_art**: Copy album art when copying or transcoding albums matched using the ``-a`` option. Default: ``no``. +- **album_art_maxwidth**: Downscale album art if it's too big. The resize + operation reduces image width to at most ``maxwidth`` pixels while + preserving the aspect ratio. - **dest**: The directory where the files will be converted (or copied) to. Default: none. - **embed**: Embed album art in converted items. Default: ``yes``. diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index 038718f9bf..eb60cca582 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -30,7 +30,7 @@ Troubleshooting Several issues have been encountered with the Discogs API. If you have one, please start by searching for `a similar issue on the repo -`_. +`_. Here are two things you can try: diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst index 507d569509..44286f79cb 100644 --- a/docs/plugins/edit.rst +++ b/docs/plugins/edit.rst @@ -23,6 +23,30 @@ The ``edit`` command has these command-line options: (in addition to the defaults set in the configuration). - ``--all``: Edit *all* available fields. +Interactive Usage +----------------- + +The ``edit`` plugin can also be invoked during an import session. If enabled, it +adds two new options to the user prompt:: + + [A]pply, More candidates, Skip, Use as-is, as Tracks, Group albums, Enter search, enter Id, aBort, eDit, edit Candidates? + +- ``eDit``: use this option for using the original items' metadata as the + starting point for your edits. +- ``edit Candidates``: use this option for using a candidate's metadata as the + starting point for your edits. + +Please note that currently the interactive usage of the plugin will only allow +you to change the item-level fields. In case you need to edit the album-level +fields, the recommended approach is to invoke the plugin via the command line +in album mode (``beet edit -a QUERY``) after the import. + +Also, please be aware that the ``edit Candidates`` choice can only be used with +the matches found during the initial search (and currently not supporting the +candidates found via the ``Enter search`` or ``enter Id`` choices). You might +find the ``--search-id SEARCH_ID`` :ref:`import-cmd` option useful for those +cases where you already have a specific candidate ID that you want to edit. + Configuration ------------- diff --git a/docs/plugins/fetchart.rst b/docs/plugins/fetchart.rst index 80149d478f..e53dbc2193 100644 --- a/docs/plugins/fetchart.rst +++ b/docs/plugins/fetchart.rst @@ -50,12 +50,18 @@ 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``. Enable those two sources for more matches at + ``wikipedia`` and ``google``. Enable those two sources for more matches at the cost of some speed. +- **google_key**: Your Google API key (to enable the Google Custom Search + backend). + Default: None. +- **google_engine**: The custom search engine to use. + Default: The `beets custom search engine`_, which searches the entire web. Note: ``minwidth`` and ``enforce_ratio`` options require either `ImageMagick`_ or `Pillow`_. +.. _beets custom search engine: https://cse.google.com.au:443/cse/publicurl?cx=001442825323518660753:hrh5ch1gjzm .. _Pillow: https://github.com/python-pillow/Pillow .. _ImageMagick: http://www.imagemagick.org/ @@ -137,6 +143,24 @@ Once the library is installed, the plugin will use it to search automatically. .. _python-itunes: https://github.com/ocelma/python-itunes .. _pip: http://pip.openplans.org/ +Google custom search +'''''''''''''''''''' + +To use the google image search backend you need to +`register for a Google API key`_. Set the ``google_key`` configuration +option to your key, then add ``google`` to the list of sources in your +configuration. + +.. _register for a Google API key: https://code.google.com/apis/console. + +Optionally, you can `define a custom search engine`_. Get your search engine's +token and use it for your ``google_engine`` configuration option. The +default engine searches the entire web for cover art. + +.. _define a custom search engine: http://www.google.com/cse/all + +Note that the Google custom search API is limited to 100 queries per day. +After that, the fetchart plugin will fall back on other declared data sources. Embedding Album Art ------------------- diff --git a/docs/plugins/ihate.rst b/docs/plugins/ihate.rst index f2224bf5a6..f9cde39eb1 100644 --- a/docs/plugins/ihate.rst +++ b/docs/plugins/ihate.rst @@ -25,7 +25,7 @@ Here's an example:: ihate: warn: - artist:rnb - - genre: soul + - genre:soul # Only warn about tribute albums in rock genre. - genre:rock album:tribute skip: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 5da5ee0be1..7d6313d7f3 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -31,6 +31,7 @@ Each plugin has its own set of options that can be defined in a section bearing .. toctree:: :hidden: + acousticbrainz badfiles bpd bpm @@ -59,6 +60,7 @@ Each plugin has its own set of options that can be defined in a section bearing lastimport lyrics mbcollection + mbsubmit mbsync metasync missing @@ -94,6 +96,7 @@ Autotagger Extensions Metadata -------- +* :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata * :doc:`bpm`: Measure tempo using keystrokes. * :doc:`echonest`: Automatically fetch `acoustic attributes`_ from `the Echo Nest`_ (tempo, energy, danceability, ...). @@ -162,6 +165,7 @@ Miscellaneous * :doc:`ihate`: Automatically skip albums and tracks during the import process. * :doc:`info`: Print music files' tags to the console. * :doc:`mbcollection`: Maintain your MusicBrainz collection list. +* :doc:`mbsubmit`: Print an album's tracks in a MusicBrainz-friendly format. * :doc:`missing`: List missing tracks. * :doc:`random`: Randomly choose albums and tracks from your library. * :doc:`filefilter`: Automatically skip files during the import process based diff --git a/docs/plugins/info.rst b/docs/plugins/info.rst index b36e690519..238a957fff 100644 --- a/docs/plugins/info.rst +++ b/docs/plugins/info.rst @@ -39,7 +39,7 @@ Additional command-line options include: * ``--format`` or ``-f``: Specify a specific format with which to print every item. This uses the same template syntax as beets’ :doc:`path formats `. - +* ``--keys-only`` or ``-k``: Show the name of the tags without the values. .. _id3v2: http://id3v2.sourceforge.net .. _mp3info: http://www.ibiblio.org/mp3info/ diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 9d32424670..8db1981518 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -37,17 +37,43 @@ Wikipedia`_. .. _pip: http://www.pip-installer.org/ .. _pylast: http://code.google.com/p/pylast/ .. _script that scrapes Wikipedia: https://gist.github.com/1241307 -.. _internal whitelist: https://raw.githubusercontent.com/sampsyo/beets/master/beetsplug/lastgenre/genres.txt +.. _internal whitelist: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres.txt Canonicalization ^^^^^^^^^^^^^^^^ The plugin can also *canonicalize* genres, meaning that more obscure genres can be turned into coarser-grained ones that are present in the whitelist. This -works using a tree of nested genre names, represented using `YAML`_, where the +works using a `tree of nested genre names`_, represented using `YAML`_, where the leaves of the tree represent the most specific genres. +The most common way to use this would be with a custom whitelist containing only +a desired subset of genres. Consider for a example this minimal whitelist:: + + rock + heavy metal + pop + +together with the default genre tree. Then an item that has its genre specified +as *viking metal* would actually be tagged as *heavy metal* because neither +*viking metal* nor its parent *black metal* are in the whitelist. It always +tries to use the most specific genre that's available in the whitelist. + +The relevant subtree path in the default tree looks like this:: + + - rock: + - heavy metal: + - black metal: + - viking metal + +Considering that, it's not very useful to use the default whitelist (which +contains about any genre contained in the tree) with canonicalization because +nothing would ever be matched to a more generic node since all the specific +subgenres are in the whitelist to begin with. + + .. _YAML: http://www.yaml.org/ +.. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml Genre Source diff --git a/docs/plugins/lastimport.rst b/docs/plugins/lastimport.rst index 7d8ede891d..d047f8a3bf 100644 --- a/docs/plugins/lastimport.rst +++ b/docs/plugins/lastimport.rst @@ -6,21 +6,25 @@ library into beets' database. You can later create :doc:`smart playlists ` by querying ``play_count`` and do other fun stuff with this field. +.. _Last.fm: http://last.fm + Installation ------------ -To use the ``lastimport`` plugin, first enable it in your configuration (see -:ref:`using-plugins`). Then install the `requests`_ library by typing:: +The plugin requires `pylast`_, which you can install using `pip`_ by typing:: - pip install requests + pip install pylast + +After you have pylast installed, enable the ``lastimport`` plugin in your +configuration (see :ref:`using-plugins`). Next, add your Last.fm username to your beets configuration file:: lastfm: user: beetsfanatic -.. _requests: http://docs.python-requests.org/en/latest/ -.. _Last.fm: http://last.fm +.. _pip: http://www.pip-installer.org/ +.. _pylast: http://code.google.com/p/pylast/ Importing Play Counts --------------------- @@ -49,3 +53,9 @@ options under the ``lastimport:`` section: * **retry_limit**: How many times should we re-send requests to Last.fm on failure? Default: 3. + +By default, the plugin will use beets's own Last.fm API key. You can also +override it with your own API key:: + + lastfm: + api_key: your_api_key diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index 1b3ca0c2fa..0d504733f3 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -52,9 +52,9 @@ configuration file. The available options are: sources known to be scrapeable. - **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. - Default: ``google lyricwiki lyrics.com musixmatch genius``, i.e., all - sources. The *google* source will be automatically deactivated if no - ``google_API_key`` is setup. + Default: ``google lyricwiki lyrics.com musixmatch``, i.e., all the + sources except for `genius`. The `google` source will be automatically + deactivated if no ``google_API_key`` is setup. Here's an example of ``config.yaml``:: diff --git a/docs/plugins/mbsubmit.rst b/docs/plugins/mbsubmit.rst new file mode 100644 index 0000000000..5c13375ba3 --- /dev/null +++ b/docs/plugins/mbsubmit.rst @@ -0,0 +1,54 @@ +MusicBrainz Submit Plugin +========================= + +The ``mbsubmit`` plugin provides an extra prompt choice during an import +session that prints the tracks of the current album in a format that is +parseable by MusicBrainz's `track parser`_. + +.. _track parser: http://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings + +Usage +----- + +Enable the ``mbsubmit`` plugin in your configuration (see :ref:`using-plugins`) +and select the ``Print tracks`` choice which is by default displayed when no +strong recommendations are found for the album:: + + No matching release found for 3 tracks. + For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch + [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, + Print tracks? p + 01. An Obscure Track - An Obscure Artist (3:37) + 02. Another Obscure Track - An Obscure Artist (2:05) + 03. The Third Track - Another Obscure Artist (3:02) + + No matching release found for 3 tracks. + For help, see: http://beets.readthedocs.org/en/latest/faq.html#nomatch + [U]se as-is, as Tracks, Group albums, Skip, Enter search, enter Id, aBort, + Print tracks? + +As MusicBrainz currently does not support submitting albums programmatically, +the recommended workflow is to copy the output of the ``Print tracks`` choice +and paste it into the parser that can be found by clicking on the +"Track Parser" button on MusicBrainz "Tracklist" tab. + +Configuration +------------- + +To configure the plugin, make a ``mbsubmit:`` section in your configuration +file. The following options are available: + +- **format**: The format used for printing the tracks, defined using the + same template syntax as beets’ :doc:`path formats `. + Default: ``$track. $title - $artist ($length)``. +- **threshold**: The minimum strength of the autotagger recommendation that + will cause the ``Print tracks`` choice to be displayed on the prompt. + Default: ``medium`` (causing the choice to be displayed for all albums that + have a recommendation of medium strength or lower). Valid values: ``none``, + ``low``, ``medium``, ``strong``. + +Please note that some values of the ``threshold`` configuration option might +require other ``beets`` command line switches to be enabled in order to work as +intended. In particular, setting a threshold of ``strong`` will only display +the prompt if ``timid`` mode is enabled. You can find more information about +how the recommendation system works at :ref:`match-config`. diff --git a/docs/plugins/mpdstats.rst b/docs/plugins/mpdstats.rst index 1f74a0976d..4389fa27e7 100644 --- a/docs/plugins/mpdstats.rst +++ b/docs/plugins/mpdstats.rst @@ -95,4 +95,4 @@ Warning This has only been tested with MPD versions >= 0.16. It may not work on older versions. If that is the case, please report an `issue`_. -.. _issue: https://github.com/sampsyo/beets/issues +.. _issue: https://github.com/beetbox/beets/issues diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 9a7210b4e0..55733cbaa8 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -47,7 +47,7 @@ configuration file. The available options are: - **raw**: Instead of creating a temporary m3u playlist and then opening it, simply call the command with the paths returned by the query as arguments. Default: ``no``. -- **warning_treshold**: Set the minimum number of files to play which will +- **warning_threshold**: Set the minimum number of files to play which will trigger a warning to be emitted. If set to ``no``, warning are never issued. Default: 100. @@ -82,3 +82,17 @@ example:: indicates that you need to insert extra arguments before specifying the playlist. + +Note on the Leakage of the Generated Playlists +_______________________________________________ + +Because the command that will open the generated ``.m3u`` files can be +arbitrarily configured by the user, beets won't try to delete those files. For +this reason, using this plugin will leave one or several playlist(s) in the +directory selected to create temporary files (Most likely ``/tmp/`` on Unix-like +systems. See `tempfile.tempdir`_.). Leaking those playlists until they are +externally wiped could be an issue for privacy or storage reasons. If this is +the case for you, you might want to use the ``raw`` config option described +above. + +.. _tempfile.tempdir: https://docs.python.org/2/library/tempfile.html#tempfile.tempdir diff --git a/docs/plugins/replaygain.rst b/docs/plugins/replaygain.rst index 968e996e6e..6f119e2b06 100644 --- a/docs/plugins/replaygain.rst +++ b/docs/plugins/replaygain.rst @@ -67,7 +67,7 @@ Python Audio Tools This backend uses the `Python Audio Tools`_ package to compute ReplayGain for a range of different file formats. The package is not available via PyPI; it -must be installed manually. +must be installed manually (only versions preceding 3.x are compatible). On OS X, most of the dependencies can be installed with `Homebrew`_:: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index f39dad3938..fcef6d23f5 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -132,6 +132,11 @@ Optional command flags: option. If set, beets will just print a list of files that it would otherwise import. +* If you already have a metadata backend ID that matches the items to be + imported, you can instruct beets to restrict the search to that ID instead of + searching for other candidates by using the ``--search-id SEARCH_ID`` option. + Multiple IDs can be specified by simply repeating the option several times. + .. _rarfile: https://pypi.python.org/pypi/rarfile/2.2 .. only:: html @@ -245,7 +250,7 @@ move ```` :: - beet move [-cap] [-d DIR] QUERY + beet move [-capt] [-d DIR] QUERY Move or copy items in your library. @@ -256,8 +261,9 @@ anywhere in your filesystem. The ``-c`` option copies files instead of moving them. As with other commands, the ``-a`` option matches albums instead of items. To perform a "dry run", just use the ``-p`` (for "pretend") flag. This will -show you all how the files would be moved but won't actually change anything -on disk. +show you a list of files that would be moved but won't actually change anything +on disk. The ``-t`` option sets the timid mode which will ask again +before really moving or copying the files. .. _update-cmd: @@ -339,7 +345,9 @@ fields beet fields Show the item and album metadata fields available for use in :doc:`query` and -:doc:`pathformat`. Includes any template fields provided by plugins. +:doc:`pathformat`. The listing includes any template fields provided by +plugins and any flexible attributes you've manually assigned to your items and +albums. .. _config-cmd: @@ -439,7 +447,7 @@ defines some bash-specific functions to make this work without errors:: _filedir() { :; } eval "$(beet completion)" -.. _completion script: https://github.com/sampsyo/beets/blob/master/extra/_beet +.. _completion script: https://github.com/beetbox/beets/blob/master/extra/_beet .. only:: man diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 84e4368d61..2a93d2204f 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -715,7 +715,7 @@ defaults look like this:: singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title -Note the use of ``$albumartist`` instead of ``$artist``; this ensure that albums +Note the use of ``$albumartist`` instead of ``$artist``; this ensures that albums will be well-organized. For more about these format strings, see :doc:`pathformat`. The ``aunique{}`` function ensures that identically-named albums are placed in different directories; see :ref:`aunique` for details. diff --git a/docs/refresh_safari.js b/docs/refresh_safari.js deleted file mode 100644 index 7f5cc0ccc3..0000000000 --- a/docs/refresh_safari.js +++ /dev/null @@ -1,19 +0,0 @@ -var safari = Application('com.apple.Safari'); - -for (var i = 0; i < safari.windows.length; ++i) { - var win = safari.windows[i]; - var tabs = win.tabs; - if (Object.keys(tabs).length) { - for (var j = 0; j < win.tabs.length; ++j) { - var tab = win.tabs[j]; - var url = tab.url(); - if (url.indexOf("file:") == 0) { - // A local file URL. - safari.doJavaScript("location.reload();", { in: tab }); - console.log(url); - } - } - } -} - -'done'; diff --git a/docs/serve.py b/docs/serve.py new file mode 100644 index 0000000000..48673ea8da --- /dev/null +++ b/docs/serve.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from livereload import Server, shell +server = Server() +server.watch('*.rst', shell('make html')) +server.serve(root='_build/html') diff --git a/extra/_beet b/extra/_beet index 0550729432..9e4a3437b5 100644 --- a/extra/_beet +++ b/extra/_beet @@ -1,6 +1,6 @@ #compdef beet -# Completion for beets music library manager and MusicBrainz tagger: http://beets.radbox.org/ +# Completion for beets music library manager and MusicBrainz tagger: http://beets.io/ # useful: argument to _regex_arguments for matching any word local matchany=/$'[^\0]##\0'/ diff --git a/extra/release.py b/extra/release.py index 302a5f3de5..7bbc080c45 100755 --- a/extra/release.py +++ b/extra/release.py @@ -64,6 +64,9 @@ def release(): ), ] +GITHUB_USER = 'beetbox' +GITHUB_REPO = 'beets' + def bump_version(version): """Update the version number in setup.py, docs config, changelog, @@ -312,5 +315,40 @@ def publish(): subprocess.check_call(['twine', 'upload', path]) +@release.command() +def ghrelease(): + """Create a GitHub release using the `github-release` command-line + tool. + + Reads the changelog to upload from `changelog.md`. Uploads the + tarball from the `dist` directory. + """ + version = get_version(1) + tag = 'v' + version + + # Load the changelog. + with open(os.path.join(BASE, 'changelog.md')) as f: + cl_md = f.read() + + # Create the release. + subprocess.check_call([ + 'github-release', 'release', + '-u', GITHUB_USER, '-r', GITHUB_REPO, + '--tag', tag, + '--name', '{} {}'.format(GITHUB_REPO, version), + '--description', cl_md, + ]) + + # Attach the release tarball. + tarball = os.path.join(BASE, 'dist', 'beets-{}.tar.gz'.format(version)) + subprocess.check_call([ + 'github-release', 'upload', + '-u', GITHUB_USER, '-r', GITHUB_REPO, + '--tag', tag, + '--name', os.path.basename(tarball), + '--file', tarball, + ]) + + if __name__ == '__main__': release() diff --git a/setup.py b/setup.py index c7e4af3a06..3acd45e7c7 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -56,11 +56,11 @@ def build_manpages(): setup( name='beets', - version='1.3.16', + version='1.3.18', description='music tagger and library organizer', author='Adrian Sampson', author_email='adrian@radbox.org', - url='http://beets.radbox.org/', + url='http://beets.io/', license='MIT', platforms='ALL', long_description=_read('README.rst'), diff --git a/test/_common.py b/test/_common.py index 9e6ec0f85f..9f8b9f146a 100644 --- a/test/_common.py +++ b/test/_common.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -346,3 +346,11 @@ def system_mock(name): yield finally: platform.system = old_system + + +def slow_test(unused=None): + def _id(obj): + return obj + if 'SKIP_SLOW_TESTS' in os.environ: + return unittest.skip('test is slow') + return _id diff --git a/test/helper.py b/test/helper.py index 0c9e2befa2..e2bc423514 100644 --- a/test/helper.py +++ b/test/helper.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/lyrics_download_samples.py b/test/lyrics_download_samples.py index c340b3f05e..3a32577f90 100644 --- a/test/lyrics_download_samples.py +++ b/test/lyrics_download_samples.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte +# Copyright 2016, Fabrice Laporte # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_completion.sh b/test/rsrc/test_completion.sh similarity index 100% rename from test/test_completion.sh rename to test/rsrc/test_completion.sh diff --git a/test/test_art.py b/test/test_art.py index cb29f37695..7902bb213f 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -65,13 +65,13 @@ def test_jpeg_type_returns_path(self): self.assertNotEqual(artpath, None) -class FSArtTest(_common.TestCase): +class FSArtTest(UseThePlugin): def setUp(self): super(FSArtTest, self).setUp() self.dpath = os.path.join(self.temp_dir, 'arttest') os.mkdir(self.dpath) - self.source = fetchart.FileSystem(logger) + self.source = fetchart.FileSystem(logger, self.plugin.config) def test_finds_jpg_in_directory(self): _common.touch(os.path.join(self.dpath, 'a.jpg')) @@ -190,13 +190,13 @@ def test_local_only_gets_fs_image(self): self.assertEqual(len(responses.calls), 0) -class AAOTest(_common.TestCase): +class AAOTest(UseThePlugin): ASIN = 'xxxx' AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}'.format(ASIN) def setUp(self): super(AAOTest, self).setUp() - self.source = fetchart.AlbumArtOrg(logger) + self.source = fetchart.AlbumArtOrg(logger, self.plugin.config) @responses.activate def run(self, *args, **kwargs): @@ -209,10 +209,10 @@ def mock_response(self, url, body): def test_aao_scraper_finds_image(self): body = b"""
- - View larger image + + \"View """ self.mock_response(self.AAO_URL, body) album = _common.Bag(asin=self.ASIN) @@ -226,6 +226,42 @@ def test_aao_scraper_returns_no_result_when_no_image_present(self): self.assertEqual(list(res), []) +class GoogleImageTest(UseThePlugin): + def setUp(self): + super(GoogleImageTest, self).setUp() + self.source = fetchart.GoogleImages(logger, self.plugin.config) + + @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'{"items": [{"link": "url_to_the_image"}]}' + self.mock_response(fetchart.GoogleImages.URL, json) + result_url = self.source.get(album) + self.assertEqual(list(result_url)[0], 'url_to_the_image') + + def test_google_art_returns_no_result_when_error_received(self): + album = _common.Bag(albumartist="some artist", album="some album") + json = b'{"error": {"errors": [{"reason": "some reason"}]}}' + self.mock_response(fetchart.GoogleImages.URL, json) + result_url = self.source.get(album) + self.assertEqual(list(result_url), []) + + def test_google_art_returns_no_result_with_malformed_response(self): + album = _common.Bag(albumartist="some artist", album="some album") + json = b"""bla blup""" + self.mock_response(fetchart.GoogleImages.URL, json) + result_url = self.source.get(album) + self.assertEqual(list(result_url), []) + + +@_common.slow_test() class ArtImporterTest(UseThePlugin): def setUp(self): super(ArtImporterTest, self).setUp() diff --git a/test/test_autotag.py b/test/test_autotag.py index 46059d4613..2aecfb559f 100644 --- a/test/test_autotag.py +++ b/test/test_autotag.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_bucket.py b/test/test_bucket.py index 05b2a2a1fc..81a5d4441f 100644 --- a/test/test_bucket.py +++ b/test/test_bucket.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_convert.py b/test/test_convert.py index 7cd565b3a1..72d52feaaf 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -64,6 +64,7 @@ def assertNoFileTag(self, path, tag): .format(path, tag)) +@_common.slow_test() class ImportConvertTest(unittest.TestCase, TestHelper): def setUp(self): @@ -99,6 +100,7 @@ def test_import_original_on_convert_error(self): self.assertTrue(os.path.isfile(item.path)) +@_common.slow_test() class ConvertCliTest(unittest.TestCase, TestHelper): def setUp(self): @@ -186,6 +188,7 @@ def test_pretend(self): self.assertFalse(os.path.exists(converted)) +@_common.slow_test() class NeverConvertLossyFilesTest(unittest.TestCase, TestHelper): """Test the effect of the `never_convert_lossy_files` option. """ diff --git a/test/test_datequery.py b/test/test_datequery.py index ba23b910d0..9ad741ee0d 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_dbcore.py b/test/test_dbcore.py index 4e1f2dd376..39b7eea1ed 100644 --- a/test/test_dbcore.py +++ b/test/test_dbcore.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -19,8 +19,10 @@ unicode_literals) import os +import shutil import sqlite3 +from test import _common from test._common import unittest from beets import dbcore from tempfile import mkstemp @@ -116,15 +118,28 @@ class TestDatabaseTwoModels(dbcore.Database): pass +class TestModelWithGetters(dbcore.Model): + + @classmethod + def _getters(cls): + return {'aComputedField': (lambda s: 'thing')} + + def _template_funcs(self): + return {} + + +@_common.slow_test() class MigrationTest(unittest.TestCase): """Tests the ability to change the database schema between versions. """ - def setUp(self): - handle, self.libfile = mkstemp('db') + + @classmethod + def setUpClass(cls): + handle, cls.orig_libfile = mkstemp('orig_db') os.close(handle) # Set up a database with the two-field schema. - old_lib = TestDatabase2(self.libfile) + old_lib = TestDatabase2(cls.orig_libfile) # Add an item to the old library. old_lib._connection().execute( @@ -133,6 +148,15 @@ def setUp(self): old_lib._connection().commit() del old_lib + @classmethod + def tearDownClass(cls): + os.remove(cls.orig_libfile) + + def setUp(self): + handle, self.libfile = mkstemp('db') + os.close(handle) + shutil.copyfile(self.orig_libfile, self.libfile) + def tearDown(self): os.remove(self.libfile) @@ -274,6 +298,40 @@ def test_load_deleted_flex_field(self): model2.load() self.assertNotIn('flex_field', model2) + def test_check_db_fails(self): + with self.assertRaisesRegexp(ValueError, 'no database'): + dbcore.Model()._check_db() + with self.assertRaisesRegexp(ValueError, 'no id'): + TestModel1(self.db)._check_db() + + dbcore.Model(self.db)._check_db(need_id=False) + + def test_missing_field(self): + with self.assertRaises(AttributeError): + TestModel1(self.db).nonExistingKey + + def test_computed_field(self): + model = TestModelWithGetters() + self.assertEqual(model.aComputedField, 'thing') + with self.assertRaisesRegexp(KeyError, 'computed field .+ deleted'): + del model.aComputedField + + def test_items(self): + model = TestModel1(self.db) + model.id = 5 + self.assertEqual({('id', 5), ('field_one', None)}, + set(model.items())) + + def test_delete_internal_field(self): + model = dbcore.Model() + del model._db + with self.assertRaises(AttributeError): + model._db + + def test_parse_nonstring(self): + with self.assertRaisesRegexp(TypeError, "must be a string"): + dbcore.Model._parse(None, 42) + class FormatTest(unittest.TestCase): def test_format_fixed_field(self): @@ -588,6 +646,15 @@ def test_length(self): objs = self.db._fetch(TestModel1) self.assertEqual(len(objs), 2) + def test_out_of_range(self): + objs = self.db._fetch(TestModel1) + with self.assertRaises(IndexError): + objs[100] + + def test_no_results(self): + self.assertIsNone(self.db._fetch( + TestModel1, dbcore.query.FalseQuery()).get()) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_echonest.py b/test/test_echonest.py index 143d2633dd..cf460e19c5 100644 --- a/test/test_echonest.py +++ b/test/test_echonest.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes +# Copyright 2016, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_edit.py b/test/test_edit.py index ae0500029d..c35c71c08a 100644 --- a/test/test_edit.py +++ b/test/test_edit.py @@ -1,5 +1,5 @@ # This file is part of beets. -# Copyright 2015, Adrian Sampson and Diego Moreda. +# Copyright 2016, 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 @@ -17,13 +17,19 @@ import codecs from mock import patch +from test import _common from test._common import unittest from test.helper import TestHelper, control_stdin +from test.test_ui_importer import TerminalImportSessionSetup +from test.test_importer import ImportHelper, AutotagStub +from beets.library import Item +from beetsplug.edit import EditPlugin class ModifyFileMocker(object): """Helper for modifying a file, replacing or editing its contents. Used for - mocking the calls to the external editor during testing.""" + 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 @@ -62,8 +68,45 @@ def replace_contents(self, filename): f.write(contents) -class EditCommandTest(unittest.TestCase, TestHelper): - """ Black box tests for `beetsplug.edit`. Command line interaction is +class EditMixin(object): + """Helper containing some common functionality used for the Edit tests.""" + def assertItemFieldsModified(self, library_items, items, fields=[], + allowed=['path']): + """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. `allowed` is a list of field changes that are ignored + (they may or may not have changed; the assertion doesn't care). + """ + for lib_item, item in zip(library_items, items): + diff_fields = [field for field in lib_item._fields + if lib_item[field] != item[field]] + self.assertEqual(set(diff_fields).difference(allowed), + set(fields)) + + def run_mocked_interpreter(self, modify_file_args={}, stdin=[]): + """Run the edit command during an import session, with mocked stdin and + yaml writing. + """ + m = ModifyFileMocker(**modify_file_args) + with patch('beetsplug.edit.edit', side_effect=m.action): + with control_stdin('\n'.join(stdin)): + self.importer.run() + + 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) + + +@_common.slow_test() +class EditCommandTest(unittest.TestCase, TestHelper, EditMixin): + """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`. """ @@ -73,31 +116,22 @@ class EditCommandTest(unittest.TestCase, TestHelper): 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 + # 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 + # Keep track of write()s. self.write_patcher = patch('beets.library.Item.write') self.mock_write = self.write_patcher.start() def tearDown(self): + EditPlugin.listeners = None 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.""" @@ -107,23 +141,9 @@ def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_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 + """Edit title for all items in the library, then discard changes.""" + # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, # Cancel. @@ -135,7 +155,7 @@ def test_title_edit_discard(self): def test_title_edit_apply(self): """Edit title for all items in the library, then apply changes.""" - # edit titles + # Edit track titles. self.run_mocked_command({'replacements': {u't\u00eftle': u'modified t\u00eftle'}}, # Apply changes. @@ -148,14 +168,14 @@ def test_title_edit_apply(self): def test_single_title_edit_apply(self): """Edit title for one item in the library, then apply changes.""" - # edit title + # Edit one track 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 + # 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, @@ -163,9 +183,9 @@ def test_single_title_edit_apply(self): def test_noedit(self): """Do not edit anything.""" - # do not edit anything + # Do not edit anything. self.run_mocked_command({'contents': None}, - # no stdin + # No stdin. []) self.assertCounts(write_call_count=0, @@ -176,7 +196,7 @@ 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 + # Edit album. self.run_mocked_command({'replacements': {u'\u00e4lbum': u'modified \u00e4lbum'}}, # Apply changes. @@ -185,14 +205,14 @@ def test_album_edit_apply(self): self.assertCounts(write_call_count=self.TRACK_COUNT) self.assertItemFieldsModified(self.album.items(), self.items_orig, ['album']) - # ensure album is *not* modified + # 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 + # Append "foo: bar" to item with id == 1. self.run_mocked_command({'replacements': {u"id: 1": u"id: 1\nfoo: bar"}}, # Apply changes. @@ -233,7 +253,7 @@ def test_a_albumartist_edit_apply(self): def test_malformed_yaml(self): """Edit the yaml file incorrectly (resulting in a malformed yaml document).""" - # edit the yaml file to an invalid file + # Edit the yaml file to an invalid file. self.run_mocked_command({'contents': '!MALFORMED'}, # Edit again to fix? No. ['n']) @@ -244,15 +264,162 @@ def test_malformed_yaml(self): 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 + # Edit the yaml file to an invalid but parseable file. self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, - # no stdin + # No stdin. []) self.assertCounts(write_call_count=0, title_starts_with=u't\u00eftle') +@_common.slow_test() +class EditDuringImporterTest(TerminalImportSessionSetup, unittest.TestCase, + ImportHelper, TestHelper, EditMixin): + """TODO + """ + IGNORED = ['added', 'album_id', 'id', 'mtime', 'path'] + + def setUp(self): + self.setup_beets() + self.load_plugins('edit') + # Create some mediafiles, and store them for comparison. + self._create_import_dir(3) + self.items_orig = [Item.from_path(f.path) for f in self.media_files] + self.matcher = AutotagStub().install() + self.matcher.matching = AutotagStub.GOOD + self.config['import']['timid'] = True + + def tearDown(self): + EditPlugin.listeners = None + self.unload_plugins() + self.teardown_beets() + self.matcher.restore() + + def test_edit_apply_asis(self): + """Edit the album field for all items in the library, apply changes, + using the original item tags. + """ + self._setup_import_session() + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Tag Title': + u'Edited Title'}}, + # eDit, Apply changes. + ['d', 'a']) + + # Check that only the 'title' field is modified. + self.assertItemFieldsModified(self.lib.items(), self.items_orig, + ['title'], + self.IGNORED + ['albumartist', + 'mb_albumartistid']) + self.assertTrue(all('Edited Title' in i.title + for i in self.lib.items())) + + # Ensure album is *not* fetched from a candidate. + self.assertEqual(self.lib.albums()[0].mb_albumid, u'') + + def test_edit_discard_asis(self): + """Edit the album field for all items in the library, discard changes, + using the original item tags. + """ + self._setup_import_session() + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Tag Title': + u'Edited Title'}}, + # eDit, Cancel, Use as-is. + ['d', 'c', 'u']) + + # Check that nothing is modified, the album is imported ASIS. + self.assertItemFieldsModified(self.lib.items(), self.items_orig, + [], + self.IGNORED + ['albumartist', + 'mb_albumartistid']) + self.assertTrue(all('Tag Title' in i.title + for i in self.lib.items())) + + # Ensure album is *not* fetched from a candidate. + self.assertEqual(self.lib.albums()[0].mb_albumid, u'') + + def test_edit_apply_candidate(self): + """Edit the album field for all items in the library, apply changes, + using a candidate. + """ + self._setup_import_session() + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Applied Title': + u'Edited Title'}}, + # edit Candidates, 1, Apply changes. + ['c', '1', 'a']) + + # Check that 'title' field is modified, and other fields come from + # the candidate. + self.assertTrue(all('Edited Title ' in i.title + for i in self.lib.items())) + self.assertTrue(all('match ' in i.mb_trackid + for i in self.lib.items())) + + # Ensure album is fetched from a candidate. + self.assertIn('albumid', self.lib.albums()[0].mb_albumid) + + def test_edit_discard_candidate(self): + """Edit the album field for all items in the library, discard changes, + using a candidate. + """ + self._setup_import_session() + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Applied Title': + u'Edited Title'}}, + # edit Candidates, 1, Apply changes. + ['c', '1', 'a']) + + # Check that 'title' field is modified, and other fields come from + # the candidate. + self.assertTrue(all('Edited Title ' in i.title + for i in self.lib.items())) + self.assertTrue(all('match ' in i.mb_trackid + for i in self.lib.items())) + + # Ensure album is fetched from a candidate. + self.assertIn('albumid', self.lib.albums()[0].mb_albumid) + + def test_edit_apply_asis_singleton(self): + """Edit the album field for all items in the library, apply changes, + using the original item tags and singleton mode. + """ + self._setup_import_session(singletons=True) + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Tag Title': + u'Edited Title'}}, + # eDit, Apply changes, aBort. + ['d', 'a', 'b']) + + # Check that only the 'title' field is modified. + self.assertItemFieldsModified(self.lib.items(), self.items_orig, + ['title'], + self.IGNORED + ['albumartist', + 'mb_albumartistid']) + self.assertTrue(all('Edited Title' in i.title + for i in self.lib.items())) + + def test_edit_apply_candidate_singleton(self): + """Edit the album field for all items in the library, apply changes, + using a candidate and singleton mode. + """ + self._setup_import_session() + # Edit track titles. + self.run_mocked_interpreter({'replacements': {u'Applied Title': + u'Edited Title'}}, + # edit Candidates, 1, Apply changes, aBort. + ['c', '1', 'a', 'b']) + + # Check that 'title' field is modified, and other fields come from + # the candidate. + self.assertTrue(all('Edited Title ' in i.title + for i in self.lib.items())) + self.assertTrue(all('match ' in i.mb_trackid + for i in self.lib.items())) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_embedart.py b/test/test_embedart.py index 3cd67b54ec..8a21f55bca 100644 --- a/test/test_embedart.py +++ b/test/test_embedart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_fetchart.py b/test/test_fetchart.py index 0c8911c557..f0dda4fce1 100644 --- a/test/test_fetchart.py +++ b/test/test_fetchart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_filefilter.py b/test/test_filefilter.py index 1b1d326f11..aec96977ed 100644 --- a/test/test_filefilter.py +++ b/test/test_filefilter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Malte Ried. +# Copyright 2016, Malte Ried. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_files.py b/test/test_files.py index e78c50511d..60bc8024a1 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ftintitle.py b/test/test_ftintitle.py index 663cf2b686..481917f859 100644 --- a/test/test_ftintitle.py +++ b/test/test_ftintitle.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -36,6 +36,7 @@ def tearDown(self): def _ft_add_item(self, path, artist, title, aartist): return self.add_item(path=path, artist=artist, + artist_sort=artist, title=title, albumartist=aartist) @@ -51,6 +52,14 @@ def test_functional_drop(self): self.assertEqual(item['artist'], u'Alice') self.assertEqual(item['title'], u'Song 1') + def test_functional_not_found(self): + item = self._ft_add_item('/', u'Alice ft Bob', u'Song 1', u'George') + self.run_command('ftintitle', '-d') + item.load() + # item should be unchanged + self.assertEqual(item['artist'], u'Alice ft Bob') + self.assertEqual(item['title'], u'Song 1') + def test_functional_custom_format(self): self._ft_set_config('feat. {0}') item = self._ft_add_item('/', u'Alice ft Bob', u'Song 1', u'Alice') @@ -126,6 +135,11 @@ def test_find_feat_part(self): 'album_artist': 'Bob', 'feat_part': 'Alice' }, + { + 'artist': 'Alice ft. Carol', + 'album_artist': 'Bob', + 'feat_part': None + }, ] for test_case in test_cases: diff --git a/test/test_importadded.py b/test/test_importadded.py index 7ca61318e0..b8801da600 100644 --- a/test/test_importadded.py +++ b/test/test_importadded.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Stig Inge Lea Bjornsen. +# Copyright 2016, Stig Inge Lea Bjornsen. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_importer.py b/test/test_importer.py index dd093377d9..56f4a17a53 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -236,6 +236,7 @@ def assert_lib_dir_empty(self): self.assertEqual(len(os.listdir(self.libdir)), 0) +@_common.slow_test() class NonAutotaggedImportTest(_common.TestCase, ImportHelper): def setUp(self): self.setup_beets(disk=True) @@ -1688,6 +1689,169 @@ def test_import_pretend_empty(self): .format(displayable_path(self.empty_path))]) +class ImportMusicBrainzIdTest(_common.TestCase, ImportHelper): + """Test the --musicbrainzid argument.""" + + MB_RELEASE_PREFIX = 'https://musicbrainz.org/release/' + MB_RECORDING_PREFIX = 'https://musicbrainz.org/recording/' + ID_RELEASE_0 = '00000000-0000-0000-0000-000000000000' + ID_RELEASE_1 = '11111111-1111-1111-1111-111111111111' + ID_RECORDING_0 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ID_RECORDING_1 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + + def setUp(self): + self.setup_beets() + self._create_import_dir(1) + + # Patch calls to musicbrainzngs. + self.release_patcher = patch('musicbrainzngs.get_release_by_id', + side_effect=mocked_get_release_by_id) + self.recording_patcher = patch('musicbrainzngs.get_recording_by_id', + side_effect=mocked_get_recording_by_id) + self.release_patcher.start() + self.recording_patcher.start() + + def tearDown(self): + self.recording_patcher.stop() + self.release_patcher.stop() + self.teardown_beets() + + def test_one_mbid_one_album(self): + self.config['import']['search_ids'] = \ + [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0] + self._setup_import_session() + + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_0') + + def test_several_mbid_one_album(self): + self.config['import']['search_ids'] = \ + [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, + self.MB_RELEASE_PREFIX + self.ID_RELEASE_1] + self._setup_import_session() + + self.importer.add_choice(2) # Pick the 2nd best match (release 1). + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_1') + + def test_one_mbid_one_singleton(self): + self.config['import']['search_ids'] = \ + [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0] + self._setup_import_session(singletons=True) + + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_0') + + def test_several_mbid_one_singleton(self): + self.config['import']['search_ids'] = \ + [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, + self.MB_RECORDING_PREFIX + self.ID_RECORDING_1] + self._setup_import_session(singletons=True) + + self.importer.add_choice(2) # Pick the 2nd best match (recording 1). + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_1') + + def test_candidates_album(self): + """Test directly ImportTask.lookup_candidates().""" + task = importer.ImportTask(paths=self.import_dir, + toppath='top path', + items=[_common.item()]) + task.search_ids = [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0, + self.MB_RELEASE_PREFIX + self.ID_RELEASE_1, + 'an invalid and discarded id'] + + task.lookup_candidates() + self.assertEqual(set(['VALID_RELEASE_0', 'VALID_RELEASE_1']), + set([c.info.album for c in task.candidates])) + + def test_candidates_singleton(self): + """Test directly SingletonImportTask.lookup_candidates().""" + task = importer.SingletonImportTask(toppath='top path', + item=_common.item()) + task.search_ids = [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0, + self.MB_RECORDING_PREFIX + self.ID_RECORDING_1, + 'an invalid and discarded id'] + + task.lookup_candidates() + self.assertEqual(set(['VALID_RECORDING_0', 'VALID_RECORDING_1']), + set([c.info.title for c in task.candidates])) + + +# Helpers for ImportMusicBrainzIdTest. + + +def mocked_get_release_by_id(id_, includes=[], release_status=[], + release_type=[]): + """Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list + of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in + the release title and artist name, so that ID_RELEASE_0 is a closer match + to the items created by ImportHelper._create_import_dir().""" + # Map IDs to (release title, artist), so the distances are different. + releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0', + 'TAG ARTIST'), + ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1', + 'DISTANT_MATCH')} + + return { + 'release': { + 'title': releases[id_][0], + 'id': id_, + 'medium-list': [{ + 'track-list': [{ + 'recording': { + 'title': 'foo', + 'id': 'bar', + 'length': 59, + }, + 'position': 9, + }], + 'position': 5, + }], + 'artist-credit': [{ + 'artist': { + 'name': releases[id_][1], + 'id': 'some-id', + }, + }], + 'release-group': { + 'id': 'another-id', + } + } + } + + +def mocked_get_recording_by_id(id_, includes=[], release_status=[], + release_type=[]): + """Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted + list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs + only in the recording title and artist name, so that ID_RECORDING_0 is a + closer match to the items created by ImportHelper._create_import_dir().""" + # Map IDs to (recording title, artist), so the distances are different. + releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0', + 'TAG ARTIST'), + ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1', + 'DISTANT_MATCH')} + + return { + 'recording': { + 'title': releases[id_][0], + 'id': id_, + 'length': 59, + 'artist-credit': [{ + 'artist': { + 'name': releases[id_][1], + 'id': 'some-id', + }, + }], + } + } + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_info.py b/test/test_info.py index 4a85b6dc94..fdf0b38612 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_keyfinder.py b/test/test_keyfinder.py index 8a3bfe4e35..898bc566eb 100644 --- a/test/test_keyfinder.py +++ b/test/test_keyfinder.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_lastgenre.py b/test/test_lastgenre.py index d61738fc53..9f61107e95 100644 --- a/test/test_lastgenre.py +++ b/test/test_lastgenre.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_library.py b/test/test_library.py index 9aeaad719f..5fdb1f2ec8 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_logging.py b/test/test_logging.py index 81df95a78d..a3fe363b9e 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -12,6 +12,7 @@ import beets.logging as blog from beets import plugins, ui import beetsplug +from test import _common from test._common import unittest, TestCase from test import helper @@ -163,6 +164,7 @@ def test_import_stage_level2(self): self.assertIn('dummy: debug import_stage', logs) +@_common.slow_test() class ConcurrentEventsTest(TestCase, helper.TestHelper): """Similar to LoggingLevelTest but lower-level and focused on multiple events interaction. Since this is a bit heavy we don't do it in diff --git a/test/test_lyrics.py b/test/test_lyrics.py index 515e965871..bd2e69e976 100644 --- a/test/test_lyrics.py +++ b/test/test_lyrics.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Fabrice Laporte. +# Copyright 2016, Fabrice Laporte. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -380,7 +380,7 @@ 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 + # https://github.com/beetbox/beets/issues/1673 s = self.source url = s['url'] + s['path'] url_title = u'foo' diff --git a/test/test_mb.py b/test/test_mb.py index d68be7e4d0..dae1003c97 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mbsubmit.py b/test/test_mbsubmit.py new file mode 100644 index 0000000000..808af9d8c4 --- /dev/null +++ b/test/test_mbsubmit.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, 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) + +from test._common import unittest +from test.helper import capture_stdout, control_stdin, TestHelper +from test.test_importer import ImportHelper, AutotagStub +from test.test_ui_importer import TerminalImportSessionSetup + + +class MBSubmitPluginTest(TerminalImportSessionSetup, unittest.TestCase, + ImportHelper, TestHelper): + def setUp(self): + self.setup_beets() + self.load_plugins('mbsubmit') + self._create_import_dir(2) + self._setup_import_session() + self.matcher = AutotagStub().install() + + def tearDown(self): + self.unload_plugins() + self.teardown_beets() + self.matcher.restore() + + def test_print_tracks_output(self): + """Test the output of the "print tracks" choice.""" + self.matcher.matching = AutotagStub.BAD + + with capture_stdout() as output: + with control_stdin('\n'.join(['p', 's'])): + # Print tracks; Skip + self.importer.run() + + # Manually build the string for comparing the output. + tracklist = ('Print tracks? ' + '01. Tag Title 1 - Tag Artist (0:01)\n' + '02. Tag Title 2 - Tag Artist (0:01)') + self.assertIn(tracklist, output.getvalue()) + + def test_print_tracks_output_as_tracks(self): + """Test the output of the "print tracks" choice, as singletons.""" + self.matcher.matching = AutotagStub.BAD + + with capture_stdout() as output: + with control_stdin('\n'.join(['t', 's', 'p', 's'])): + # as Tracks; Skip; Print tracks; Skip + self.importer.run() + + # Manually build the string for comparing the output. + tracklist = ('Print tracks? ' + '02. Tag Title 2 - Tag Artist (0:01)') + self.assertIn(tracklist, output.getvalue()) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_mbsync.py b/test/test_mbsync.py index 47517f4eee..1cfdf96a1e 100644 --- a/test/test_mbsync.py +++ b/test/test_mbsync.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 784703f2c2..ed56809826 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mediafile_edge.py b/test/test_mediafile_edge.py index 480fa2b593..da522cd3d7 100644 --- a/test/test_mediafile_edge.py +++ b/test/test_mediafile_edge.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -313,6 +313,13 @@ def test_special_characters(self): self.assertEqual(gain, 0.0) self.assertEqual(peak, 0.0) + def test_decode_handles_unicode(self): + # Most of the time, we expect to decode the raw bytes. But some formats + # might give us text strings, which we need to handle. + gain, peak = beets.mediafile._sc_decode(u'caf\xe9') + self.assertEqual(gain, 0.0) + self.assertEqual(peak, 0.0) + class ID3v23Test(unittest.TestCase, TestHelper): def _make_test(self, ext='mp3', id3v23=False): diff --git a/test/test_metasync.py b/test/test_metasync.py index e37ef33646..6404ea1d8b 100644 --- a/test/test_metasync.py +++ b/test/test_metasync.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Tom Jaspers. +# Copyright 2016, Tom Jaspers. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_mpdstats.py b/test/test_mpdstats.py index b07da5e322..f28e29d684 100644 --- a/test/test_mpdstats.py +++ b/test/test_mpdstats.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015 +# Copyright 2016 # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,7 +17,7 @@ unicode_literals) -from mock import Mock +from mock import Mock, patch, call, ANY from test._common import unittest from test.helper import TestHelper @@ -44,6 +44,44 @@ def test_update_rating(self): self.assertFalse(mpdstats.update_rating(item, True)) self.assertFalse(mpdstats.update_rating(None, True)) + def test_get_item(self): + ITEM_PATH = '/foo/bar.flac' + item = Item(title='title', path=ITEM_PATH, id=1) + item.add(self.lib) + + log = Mock() + mpdstats = MPDStats(self.lib, log) + + self.assertEqual(str(mpdstats.get_item(ITEM_PATH)), str(item)) + self.assertIsNone(mpdstats.get_item('/some/non-existing/path')) + self.assertIn('item not found:', log.info.call_args[0][0]) + + FAKE_UNKNOWN_STATE = 'some-unknown-one' + STATUSES = [{'state': FAKE_UNKNOWN_STATE}, + {'state': 'pause'}, + {'state': 'play', 'songid': 1, 'time': '0:1'}, + {'state': 'stop'}] + EVENTS = [["player"]] * (len(STATUSES) - 1) + [KeyboardInterrupt] + ITEM_PATH = '/foo/bar.flac' + + @patch("beetsplug.mpdstats.MPDClientWrapper", return_value=Mock(**{ + "events.side_effect": EVENTS, "status.side_effect": STATUSES, + "playlist.return_value": {1: ITEM_PATH}})) + def test_run_MPDStats(self, mpd_mock): + item = Item(title='title', path=self.ITEM_PATH, id=1) + item.add(self.lib) + + log = Mock() + try: + MPDStats(self.lib, log).run() + except KeyboardInterrupt: + pass + + log.debug.assert_has_calls( + [call(u'unhandled status "{0}"', ANY)]) + log.info.assert_has_calls( + [call(u'pause'), call(u'playing {0}', ANY), call(u'stop')]) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_permissions.py b/test/test_permissions.py index 479c5e5af4..c1777ed595 100644 --- a/test/test_permissions.py +++ b/test/test_permissions.py @@ -5,6 +5,9 @@ from __future__ import (division, absolute_import, print_function, unicode_literals) +import os +from mock import patch, Mock + from test._common import unittest from test.helper import TestHelper from beetsplug.permissions import (check_permissions, @@ -26,45 +29,45 @@ def tearDown(self): self.unload_plugins() def test_permissions_on_album_imported(self): - self.importer = self.create_importer() - self.importer.run() - item = self.lib.items().get() - - file_perm = self.config['permissions']['file'].get() - file_perm = convert_perm(file_perm) - - dir_perm = self.config['permissions']['dir'].get() - dir_perm = convert_perm(dir_perm) - - music_dirs = dirs_in_library(self.lib.directory, item.path) - - self.assertTrue(check_permissions(item.path, file_perm)) - self.assertFalse(check_permissions(item.path, convert_perm(644))) - - for path in music_dirs: - self.assertTrue(check_permissions(path, dir_perm)) - self.assertFalse(check_permissions(path, convert_perm(644))) + self.do_thing(True) def test_permissions_on_item_imported(self): self.config['import']['singletons'] = True + self.do_thing(True) + + @patch("os.chmod", Mock()) + def test_failing_to_set_permissions(self): + self.do_thing(False) + + def do_thing(self, expectSuccess): + def get_stat(v): + return os.stat( + os.path.join(self.temp_dir, 'import', *v)).st_mode & 0o777 self.importer = self.create_importer() + typs = ['file', 'dir'] + self.exp_perms = { + True: {k: convert_perm(self.config['permissions'][k].get()) + for k in typs}, + False: {k: get_stat(v) + for (k, v) in zip(typs, (('album 0', 'track 0.mp3'), ()))}} + self.importer.run() item = self.lib.items().get() - file_perm = self.config['permissions']['file'].get() - file_perm = convert_perm(file_perm) - - dir_perm = self.config['permissions']['dir'].get() - dir_perm = convert_perm(dir_perm) + self.assertPerms(item.path, 'file', expectSuccess) - music_dirs = dirs_in_library(self.lib.directory, item.path) + for path in dirs_in_library(self.lib.directory, item.path): + self.assertPerms(path, 'dir', expectSuccess) - self.assertTrue(check_permissions(item.path, file_perm)) - self.assertFalse(check_permissions(item.path, convert_perm(644))) + def assertPerms(self, path, typ, expectSuccess): + for x in [(True, self.exp_perms[expectSuccess][typ], '!='), + (False, self.exp_perms[not expectSuccess][typ], '==')]: + self.assertEqual(x[0], check_permissions(path, x[1]), + msg='{} : {} {} {}'.format( + path, oct(os.stat(path).st_mode), x[2], oct(x[1]))) - for path in music_dirs: - self.assertTrue(check_permissions(path, dir_perm)) - self.assertFalse(check_permissions(path, convert_perm(644))) + def test_convert_perm_from_string(self): + self.assertEqual(convert_perm('10'), 8) def suite(): diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 8c91912934..705a7eb73f 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_play.py b/test/test_play.py new file mode 100644 index 0000000000..d0371cf159 --- /dev/null +++ b/test/test_play.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# This file is part of beets. +# Copyright 2016, Jesse Weinstein +# +# 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. + +"""Tests for the play plugin""" + +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +import os + +from mock import patch, ANY + +from test._common import unittest +from test.helper import TestHelper, control_stdin + +from beets.ui import UserError +from beets.util import open_anything + + +class PlayPluginTest(unittest.TestCase, TestHelper): + def setUp(self): + self.setup_beets() + self.load_plugins('play') + self.item = self.add_item(album='a nice älbum', title='aNiceTitle') + self.lib.add_album([self.item]) + self.open_patcher = patch('beetsplug.play.util.interactive_open') + self.open_mock = self.open_patcher.start() + self.config['play']['command'] = 'echo' + + def tearDown(self): + self.open_patcher.stop() + self.teardown_beets() + self.unload_plugins() + + def do_test(self, args=('title:aNiceTitle',), expected_cmd='echo', + expected_playlist='{}\n'): + self.run_command('play', *args) + + self.open_mock.assert_called_once_with(ANY, expected_cmd) + exp_playlist = expected_playlist.format(self.item.path.decode('utf-8')) + with open(self.open_mock.call_args[0][0][0], 'r') as playlist: + self.assertEqual(exp_playlist, playlist.read().decode('utf-8')) + + def test_basic(self): + self.do_test() + + def test_album_option(self): + self.do_test(['-a', 'nice']) + + def test_args_option(self): + self.do_test(['-A', 'foo', 'title:aNiceTitle'], 'echo foo') + + def test_args_option_in_middle(self): + self.config['play']['command'] = 'echo $args other' + + self.do_test(['-A', 'foo', 'title:aNiceTitle'], 'echo foo other') + + def test_relative_to(self): + self.config['play']['command'] = 'echo' + self.config['play']['relative_to'] = '/something' + + self.do_test(expected_cmd='echo', expected_playlist='..{}\n') + + def test_use_folders(self): + self.config['play']['command'] = None + self.config['play']['use_folders'] = True + self.run_command('play', '-a', 'nice') + + self.open_mock.assert_called_once_with(ANY, open_anything()) + playlist = open(self.open_mock.call_args[0][0][0], 'r') + self.assertEqual('{}\n'.format( + os.path.dirname(self.item.path.decode('utf-8'))), + playlist.read().decode('utf-8')) + + def test_raw(self): + self.config['play']['raw'] = True + + self.run_command('play', 'nice') + + self.open_mock.assert_called_once_with([self.item.path], 'echo') + + def test_not_found(self): + self.run_command('play', 'not found') + + self.open_mock.assert_not_called() + + def test_warning_threshold(self): + self.config['play']['warning_threshold'] = 1 + self.add_item(title='another NiceTitle') + + with control_stdin("a"): + self.run_command('play', 'nice') + + self.open_mock.assert_not_called() + + def test_warning_threshold_backwards_compat(self): + self.config['play']['warning_treshold'] = 1 + self.add_item(title='another NiceTitle') + + with control_stdin("a"): + self.run_command('play', 'nice') + + self.open_mock.assert_not_called() + + def test_command_failed(self): + self.open_mock.side_effect = OSError("some reason") + + with self.assertRaises(UserError): + self.run_command('play', 'title:aNiceTitle') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_player.py b/test/test_player.py index 6afa0ba21a..4f2f6653a7 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_plugins.py b/test/test_plugins.py index 8e6689c6de..eaf2e83ed6 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -17,17 +17,18 @@ unicode_literals) import os -from mock import patch, Mock +from mock import patch, Mock, ANY import shutil import itertools from beets.importer import SingletonImportTask, SentinelImportTask, \ - ArchiveImportTask -from beets import plugins, config + ArchiveImportTask, action +from beets import plugins, config, ui from beets.library import Item from beets.dbcore import types from beets.mediafile import MediaFile -from test.test_importer import ImportHelper +from test.test_importer import ImportHelper, AutotagStub +from test.test_ui_importer import TerminalImportSessionSetup from test._common import unittest, RSRC from test import helper @@ -401,6 +402,156 @@ def dummy9(self, **kwargs): plugins.send('event9', foo=5) +class PromptChoicesTest(TerminalImportSessionSetup, unittest.TestCase, + ImportHelper, TestHelper): + def setUp(self): + self.setup_plugin_loader() + self.setup_beets() + self._create_import_dir(3) + self._setup_import_session() + self.matcher = AutotagStub().install() + # keep track of ui.input_option() calls + self.input_options_patcher = patch('beets.ui.input_options', + side_effect=ui.input_options) + self.mock_input_options = self.input_options_patcher.start() + + def tearDown(self): + self.input_options_patcher.stop() + self.teardown_plugin_loader() + self.teardown_beets() + self.matcher.restore() + + def test_plugin_choices_in_ui_input_options_album(self): + """Test the presence of plugin choices on the prompt (album).""" + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.return_choices) + + def return_choices(self, session, task): + return [ui.commands.PromptChoice('f', 'Foo', None), + ui.commands.PromptChoice('r', 'baR', None)] + + self.register_plugin(DummyPlugin) + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', + u'as Tracks', u'Group albums', u'Enter search', + u'enter Id', u'aBort') + ('Foo', 'baR') + + self.importer.add_choice(action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_once_with(opts, default='a', + require=ANY) + + def test_plugin_choices_in_ui_input_options_singleton(self): + """Test the presence of plugin choices on the prompt (singleton).""" + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.return_choices) + + def return_choices(self, session, task): + return [ui.commands.PromptChoice('f', 'Foo', None), + ui.commands.PromptChoice('r', 'baR', None)] + + self.register_plugin(DummyPlugin) + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', + u'Enter search', + u'enter Id', u'aBort') + ('Foo', 'baR') + + config['import']['singletons'] = True + self.importer.add_choice(action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_with(opts, default='a', + require=ANY) + + def test_choices_conflicts(self): + """Test the short letter conflict solving.""" + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.return_choices) + + def return_choices(self, session, task): + return [ui.commands.PromptChoice('a', 'A foo', None), # dupe + ui.commands.PromptChoice('z', 'baZ', None), # ok + ui.commands.PromptChoice('z', 'Zupe', None), # dupe + ui.commands.PromptChoice('z', 'Zoo', None)] # dupe + + self.register_plugin(DummyPlugin) + # Default options + not dupe extra choices by the plugin ('baZ') + opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', + u'as Tracks', u'Group albums', u'Enter search', + u'enter Id', u'aBort') + ('baZ',) + self.importer.add_choice(action.SKIP) + self.importer.run() + self.mock_input_options.assert_called_once_with(opts, default='a', + require=ANY) + + def test_plugin_callback(self): + """Test that plugin callbacks are being called upon user choice.""" + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.return_choices) + + def return_choices(self, session, task): + return [ui.commands.PromptChoice('f', 'Foo', self.foo)] + + def foo(self, session, task): + pass + + self.register_plugin(DummyPlugin) + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', + u'as Tracks', u'Group albums', u'Enter search', + u'enter Id', u'aBort') + ('Foo',) + + # DummyPlugin.foo() should be called once + with patch.object(DummyPlugin, 'foo', autospec=True) as mock_foo: + with helper.control_stdin('\n'.join(['f', 's'])): + self.importer.run() + self.assertEqual(mock_foo.call_count, 1) + + # input_options should be called twice, as foo() returns None + self.assertEqual(self.mock_input_options.call_count, 2) + self.mock_input_options.assert_called_with(opts, default='a', + require=ANY) + + def test_plugin_callback_return(self): + """Test that plugin callbacks that return a value exit the loop.""" + class DummyPlugin(plugins.BeetsPlugin): + def __init__(self): + super(DummyPlugin, self).__init__() + self.register_listener('before_choose_candidate', + self.return_choices) + + def return_choices(self, session, task): + return [ui.commands.PromptChoice('f', 'Foo', self.foo)] + + def foo(self, session, task): + return action.SKIP + + self.register_plugin(DummyPlugin) + # Default options + extra choices by the plugin ('Foo', 'Bar') + opts = (u'Apply', u'More candidates', u'Skip', u'Use as-is', + u'as Tracks', u'Group albums', u'Enter search', + u'enter Id', u'aBort') + ('Foo',) + + # DummyPlugin.foo() should be called once + with helper.control_stdin('f\n'): + self.importer.run() + + # input_options should be called once, as foo() returns SKIP + self.mock_input_options.assert_called_once_with(opts, default='a', + require=ANY) + + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_query.py b/test/test_query.py index 8b3ad9821c..bb9572a1b0 100644 --- a/test/test_query.py +++ b/test/test_query.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -371,12 +371,25 @@ def test_eq(self): class PathQueryTest(_common.LibTestCase, TestHelper, AssertsMixin): def setUp(self): super(PathQueryTest, self).setUp() + + # This is the item we'll try to match. self.i.path = '/a/b/c.mp3' self.i.title = 'path item' self.i.album = 'path album' self.i.store() self.lib.add_album([self.i]) + # A second item for testing exclusion. + i2 = _common.item() + i2.path = '/x/y/z.mp3' + i2.title = 'another item' + i2.album = 'another album' + self.lib.add(i2) + self.lib.add_album([i2]) + + # Unadorned path queries with path separators in them are considered + # path queries only when the path in question actually exists. So we + # mock the existence check to return true. self.patcher_exists = patch('beets.library.os.path.exists') self.patcher_exists.start().return_value = True @@ -448,6 +461,12 @@ def test_slashed_query_matches_path(self): results = self.lib.albums(q) self.assert_albums_matched(results, ['path album']) + @unittest.skip('unfixed (#1865)') + def test_path_query_in_or_query(self): + q = '/a/b , /a/b' + results = self.lib.items(q) + self.assert_items_matched(results, ['path item']) + def test_non_slashed_does_not_match_path(self): q = 'c.mp3' results = self.lib.items(q) @@ -462,7 +481,7 @@ def test_slashes_in_explicit_field_does_not_match_path(self): self.assert_items_matched(results, []) def test_path_item_regex(self): - q = 'path::\\.mp3$' + q = 'path::c\\.mp3$' results = self.lib.items(q) self.assert_items_matched(results, ['path item']) diff --git a/test/test_replaygain.py b/test/test_replaygain.py index e85c4504bf..5b20f5f3bf 100644 --- a/test/test_replaygain.py +++ b/test/test_replaygain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes +# Copyright 2016, Thomas Scholtes # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -20,7 +20,10 @@ from test._common import unittest from test.helper import TestHelper, has_program +from beets import config from beets.mediafile import MediaFile +from beetsplug.replaygain import (FatalGstreamerPluginReplayGainError, + GStreamerBackend) try: import gi @@ -44,6 +47,7 @@ class ReplayGainCliTestBase(TestHelper): def setUp(self): self.setup_beets() + self.config['replaygain']['backend'] = self.backend try: self.load_plugins('replaygain') @@ -61,7 +65,6 @@ def setUp(self): pass raise exc_info[1], None, exc_info[2] - self.config['replaygain']['backend'] = self.backend album = self.add_album_fixture(2) for item in album.items(): self._reset_replaygain(item) @@ -87,6 +90,13 @@ def test_cli_saves_track_gain(self): self.assertIsNone(mediafile.rg_track_gain) self.run_command('replaygain') + + # Skip the test if rg_track_peak and rg_track gain is None, assuming + # that it could only happen if the decoder plugins are missing. + if all(i.rg_track_peak is None and i.rg_track_gain is None + for i in self.lib.items()): + self.skipTest('decoder plugins could not be loaded.') + for item in self.lib.items(): self.assertIsNotNone(item.rg_track_peak) self.assertIsNotNone(item.rg_track_gain) @@ -132,6 +142,18 @@ def test_cli_saves_album_gain_to_file(self): class ReplayGainGstCliTest(ReplayGainCliTestBase, unittest.TestCase): backend = u'gstreamer' + def setUp(self): + try: + # Check if required plugins can be loaded by instantiating a + # GStreamerBackend (via its .__init__). + config['replaygain']['targetlevel'] = 89 + GStreamerBackend(config['replaygain'], None) + except FatalGstreamerPluginReplayGainError as e: + # Skip the test if plugins could not be loaded. + self.skipTest(str(e)) + + super(ReplayGainGstCliTest, self).setUp() + @unittest.skipIf(not GAIN_PROG_AVAILABLE, 'no *gain command found') class ReplayGainCmdCliTest(ReplayGainCliTestBase, unittest.TestCase): diff --git a/test/test_smartplaylist.py b/test/test_smartplaylist.py index 4d9826539e..c5ac4fb5ba 100644 --- a/test/test_smartplaylist.py +++ b/test/test_smartplaylist.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Bruno Cauet. +# Copyright 2016, Bruno Cauet. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_sort.py b/test/test_sort.py index 9413e7d7be..dc4e6d2b22 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_template.py b/test/test_template.py index 318c2d5b5d..96100bdfeb 100644 --- a/test/test_template.py +++ b/test/test_template.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index 2468e30b5e..60297c68ed 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Bruno Cauet +# Copyright 2016, Bruno Cauet # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_types_plugin.py b/test/test_types_plugin.py index 57bfb22824..ba3fa54e12 100644 --- a/test/test_types_plugin.py +++ b/test/test_types_plugin.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Thomas Scholtes. +# Copyright 2016, Thomas Scholtes. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ui.py b/test/test_ui.py index 78e4f5891a..480e6ce031 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -39,6 +39,7 @@ from beets import config from beets import plugins from beets.util.confit import ConfigError +from beets import util class ListTest(unittest.TestCase): @@ -157,10 +158,13 @@ def setUp(self): def tearDown(self): self.teardown_beets() - def modify(self, *args): - with control_stdin('y'): + def modify_inp(self, inp, *args): + with control_stdin(inp): ui._raw_main(['modify'] + list(args), self.lib) + def modify(self, *args): + self.modify_inp('y', *args) + # Item tests def test_modify_item(self): @@ -168,6 +172,20 @@ def test_modify_item(self): item = self.lib.items().get() self.assertEqual(item.title, 'newTitle') + def test_modify_item_abort(self): + item = self.lib.items().get() + title = item.title + self.modify_inp('n', "title=newTitle") + item = self.lib.items().get() + self.assertEqual(item.title, title) + + def test_modify_item_no_change(self): + title = "Tracktitle" + item = self.add_item_fixture(title=title) + self.modify_inp('y', "title", "title={0}".format(title)) + item = self.lib.items(title).get() + self.assertEqual(item.title, title) + def test_modify_write_tags(self): self.modify("title=newTitle") item = self.lib.items().get() @@ -190,6 +208,13 @@ def test_not_move(self): item = self.lib.items().get() self.assertNotIn(b'newTitle', item.path) + def test_no_write_no_move(self): + self.modify("--nomove", "--nowrite", "title=newTitle") + item = self.lib.items().get() + item.read() + self.assertNotIn(b'newTitle', item.path) + self.assertNotEqual(item.title, 'newTitle') + def test_update_mtime(self): item = self.item old_mtime = item.mtime @@ -206,6 +231,22 @@ def test_reset_mtime_with_no_write(self): item.load() self.assertEqual(0, item.mtime) + def test_selective_modify(self): + title = "Tracktitle" + album = "album" + origArtist = "composer" + newArtist = "coverArtist" + for i in range(0, 10): + self.add_item_fixture(title="{0}{1}".format(title, i), + artist=origArtist, + album=album) + self.modify_inp('s\ny\ny\ny\nn\nn\ny\ny\ny\ny\nn', + title, "artist={0}".format(newArtist)) + origItems = self.lib.items("artist:{0}".format(origArtist)) + newItems = self.lib.items("artist:{0}".format(newArtist)) + self.assertEqual(len(list(origItems)), 3) + self.assertEqual(len(list(newItems)), 7) + # Album Tests def test_modify_album(self): @@ -594,6 +635,7 @@ def test_manual_search_gets_unicode(self): self.assertEqual(album, u'\xc2me') +@_common.slow_test() class ConfigTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() @@ -630,7 +672,8 @@ def setUp(self): def tearDown(self): commands.default_commands.pop() os.chdir(self._orig_cwd) - os.environ['HOME'] = self._old_home + if self._old_home is not None: + os.environ['HOME'] = self._old_home self.teardown_beets() def _make_test_cmd(self): @@ -1035,6 +1078,7 @@ def test_custom_paths_prepend(self): self.assertEqual(pf[1:], default_formats) +@_common.slow_test() class PluginTest(_common.TestCase): def test_plugin_command_from_pluginpath(self): config['pluginpath'] = [os.path.join(_common.RSRC, 'beetsplug')] @@ -1042,18 +1086,13 @@ def test_plugin_command_from_pluginpath(self): ui._raw_main(['test']) +@_common.slow_test() class CompletionTest(_common.TestCase): def test_completion(self): # Load plugin commands config['pluginpath'] = [os.path.join(_common.RSRC, 'beetsplug')] config['plugins'] = ['test'] - test_script = os.path.join( - os.path.dirname(__file__), 'test_completion.sh' - ) - bash_completion = os.path.abspath(os.environ.get( - 'BASH_COMPLETION_SCRIPT', '/etc/bash_completion')) - # Tests run in bash cmd = os.environ.get('BEETS_TEST_SHELL', '/bin/bash --norc').split() if not has_program(cmd[0]): @@ -1061,21 +1100,28 @@ def test_completion(self): tester = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE) - # Load bash_completion + # Load bash_completion library. + for path in commands.BASH_COMPLETION_PATHS: + if os.path.exists(util.syspath(path)): + bash_completion = path + break + else: + self.skipTest('bash-completion script not found') try: - with open(bash_completion, 'r') as bash_completion: - tester.stdin.writelines(bash_completion) + with open(util.syspath(bash_completion), 'r') as f: + tester.stdin.writelines(f) except IOError: - self.skipTest('bash-completion script not found') + self.skipTest('could not read bash-completion script') - # Load complection script + # Load completion script. self.io.install() ui._raw_main(['completion']) completion_script = self.io.getoutput() self.io.restore() tester.stdin.writelines(completion_script) - # Load testsuite + # Load test suite. + test_script = os.path.join(_common.RSRC, 'test_completion.sh') with open(test_script, 'r') as test_script: tester.stdin.writelines(test_script) (out, err) = tester.communicate() @@ -1129,6 +1175,32 @@ def test_root_format_option(self): '--format-album', '$albumartist', 'ls', '-a') self.assertEqual(l, 'the album artist\n') + def test_help(self): + l = self.run_with_output('help') + self.assertIn('Usage:', l) + + l = self.run_with_output('help', 'list') + self.assertIn('Usage:', l) + + with self.assertRaises(ui.UserError): + self.run_command('help', 'this.is.not.a.real.command') + + def test_stats(self): + l = self.run_with_output('stats') + self.assertIn('Approximate total size:', l) + + # # Need to have more realistic library setup for this to work + # l = self.run_with_output('stats', '-e') + # self.assertIn('Total size:', l) + + def test_version(self): + l = self.run_with_output('version') + self.assertIn('no plugins loaded', l) + + # # Need to have plugin loaded + # l = self.run_with_output('version') + # self.assertIn('plugins: ', l) + class CommonOptionsParserTest(unittest.TestCase, TestHelper): def setUp(self): diff --git a/test/test_ui_commands.py b/test/test_ui_commands.py index c8995aedf6..ed76207253 100644 --- a/test/test_ui_commands.py +++ b/test/test_ui_commands.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ui_importer.py b/test/test_ui_importer.py index e8e43a6f79..4f0de1c922 100644 --- a/test/test_ui_importer.py +++ b/test/test_ui_importer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_ui_init.py b/test/test_ui_init.py index 667b0a56d4..98a2aac996 100644 --- a/test/test_ui_init.py +++ b/test/test_ui_init.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -21,6 +21,56 @@ from beets import ui +class InputMethodsTest(_common.TestCase): + def setUp(self): + super(InputMethodsTest, self).setUp() + self.io.install() + + def _print_helper(self, s): + print(s) + + def _print_helper2(self, s, prefix): + print(prefix, s) + + def test_input_select_objects(self): + full_items = ['1', '2', '3', '4', '5'] + + # Test no + self.io.addinput('n') + items = ui.input_select_objects( + "Prompt", full_items, self._print_helper) + self.assertEqual(items, []) + + # Test yes + self.io.addinput('y') + items = ui.input_select_objects( + "Prompt", full_items, self._print_helper) + self.assertEqual(items, full_items) + + # Test selective 1 + self.io.addinput('s') + self.io.addinput('n') + self.io.addinput('y') + self.io.addinput('n') + self.io.addinput('y') + self.io.addinput('n') + items = ui.input_select_objects( + "Prompt", full_items, self._print_helper) + self.assertEqual(items, ['2', '4']) + + # Test selective 2 + self.io.addinput('s') + self.io.addinput('y') + self.io.addinput('y') + self.io.addinput('n') + self.io.addinput('y') + self.io.addinput('n') + items = ui.input_select_objects( + "Prompt", full_items, + lambda s: self._print_helper2(s, "Prefix")) + self.assertEqual(items, ['1', '2', '4']) + + class InitTest(_common.LibTestCase): def setUp(self): super(InitTest, self).setUp() diff --git a/test/test_util.py b/test/test_util.py index db68ed8853..6d4707273e 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/test_vfs.py b/test/test_vfs.py index e1569b2c3c..e11e04780b 100644 --- a/test/test_vfs.py +++ b/test/test_vfs.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the diff --git a/test/testall.py b/test/testall.py index 3e6255f6d6..fa556e364b 100755 --- a/test/testall.py +++ b/test/testall.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # This file is part of beets. -# Copyright 2015, Adrian Sampson. +# Copyright 2016, Adrian Sampson. # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the