diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index 274e7af64..df796a134 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -271,6 +271,13 @@ class Model(object): else: return base_keys + @classmethod + def all_keys(self): + """Get a list of available keys for objects of this type. + Includes fixed and computed fields. + """ + return list(self._fields) + self._getters().keys() + # Act like a dictionary. def update(self, values): diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index fff72d5f0..c04e734b8 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -766,7 +766,10 @@ class FixedFieldSort(FieldSort): def order_clause(self): order = "ASC" if self.ascending else "DESC" if self.case_insensitive: - field = 'LOWER({})'.format(self.field) + field = '(CASE ' \ + 'WHEN TYPEOF({0})="text" THEN LOWER({0}) ' \ + 'WHEN TYPEOF({0})="blob" THEN LOWER({0}) ' \ + 'ELSE {0} END)'.format(self.field) else: field = self.field return "{0} {1}".format(field, order) diff --git a/beets/importer.py b/beets/importer.py index 713bcd52f..844b66086 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -1408,7 +1408,8 @@ def group_albums(session): if task.skip: continue tasks = [] - for _, items in itertools.groupby(task.items, group): + sorted_items = sorted(task.items, key=group) + for _, items in itertools.groupby(sorted_items, group): items = list(items) task = ImportTask(task.toppath, [i.path for i in items], items) diff --git a/beets/library.py b/beets/library.py index 006630c08..85c6e1b40 100644 --- a/beets/library.py +++ b/beets/library.py @@ -479,15 +479,6 @@ class Item(LibModel): i.mtime = i.current_mtime() # Initial mtime. return i - @classmethod - def get_fields(cls): - """Returns Item fields available for queries and format strings.""" - plugin_fields = [] - for plugin in plugins.find_plugins(): - plugin_fields += plugin.template_fields.keys() - return (cls._fields.keys() + cls._getters().keys() + - cls._types.keys()), plugin_fields - def __setitem__(self, key, value): """Set the item's value for a standard field or a flexattr. """ @@ -790,26 +781,21 @@ class Item(LibModel): if beets.config['asciify_paths']: subpath = unidecode(subpath) - # Truncate components and remove forbidden characters. - subpath = util.sanitize_path(subpath, self._db.replacements) - - # Encode for the filesystem. - if not fragment: - subpath = bytestring_path(subpath) - - # Preserve extension. - _, extension = os.path.splitext(self.path) - if fragment: - # Outputting Unicode. - extension = extension.decode('utf8', 'ignore') - subpath += extension.lower() - - # Truncate too-long components. maxlen = beets.config['max_filename_length'].get(int) if not maxlen: # When zero, try to determine from filesystem. maxlen = util.max_filename_length(self._db.directory) - subpath = util.truncate_path(subpath, maxlen) + + subpath, fellback = util.legalize_path( + subpath, self._db.replacements, maxlen, + os.path.splitext(self.path)[1], fragment + ) + if fellback: + # Print an error message if legalization fell back to + # default replacements because of the maximum length. + log.warning('Fell back to default replacements when naming ' + 'file {}. Configure replacements to avoid lengthening ' + 'the filename.', subpath) if fragment: return subpath @@ -915,15 +901,6 @@ class Album(LibModel): getters['albumtotal'] = Album._albumtotal return getters - @classmethod - def get_fields(cls): - """Returns Album fields available for queries and format strings.""" - plugin_fields = [] - for plugin in plugins.find_plugins(): - plugin_fields += plugin.album_template_fields.keys() - return (cls._fields.keys() + cls._getters().keys() + - cls._types.keys()), plugin_fields - def items(self): """Returns an iterable over the items associated with this album. diff --git a/beets/mediafile.py b/beets/mediafile.py index acc9dbc46..b2a72d84c 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -270,10 +270,23 @@ def _sc_encode(gain, peak): # Cover art and other images. +def _wider_test_jpeg(data): + """Test for a jpeg file following the UNIX file implementation which + uses the magic bytes rather than just looking for the bytes b'JFIF' + or b'EXIF' at a fixed position. + """ + if data[:2] == b'\xff\xd8': + return 'jpeg' + + def _image_mime_type(data): """Return the MIME type of the image data (a bytestring). """ - kind = imghdr.what(None, h=data) + # This checks for a jpeg file with only the magic bytes (unrecognized by + # imghdr.what). imghdr.what returns none for that type of file, so + # _wider_test_jpeg is run in that case. It still returns None if it didn't + # match such a jpeg file. + kind = imghdr.what(None, h=data) or _wider_test_jpeg(data) if kind in ['gif', 'jpeg', 'png', 'tiff', 'bmp']: return 'image/{0}'.format(kind) elif kind == 'pgm': diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 76473e678..c499d36d8 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -82,17 +82,11 @@ def fields_func(lib, opts, args): names.sort() print_(" " + "\n ".join(names)) - fs, pfs = library.Item.get_fields() print_("Item fields:") - _print_rows(fs) - print_("Template fields from plugins:") - _print_rows(pfs) + _print_rows(library.Item.all_keys()) - fs, pfs = library.Album.get_fields() print_("Album fields:") - _print_rows(fs) - print_("Template fields from plugins:") - _print_rows(pfs) + _print_rows(library.Album.all_keys()) fields_cmd = ui.Subcommand( @@ -1482,11 +1476,13 @@ def config_func(lib, opts, args): def config_edit(): """Open a program to edit the user configuration. + An empty config file is created if no existing config file exists. """ path = config.user_config_path() - editor = os.environ.get('EDITOR') try: + if not os.path.isfile(path): + open(path, 'w+').close() util.interactive_open(path, editor) except OSError as exc: message = "Could not edit configuration: {0}".format(exc) diff --git a/beets/util/__init__.py b/beets/util/__init__.py index 68be740f6..f17571f0d 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -545,6 +545,78 @@ def truncate_path(path, length=MAX_FILENAME_LENGTH): return os.path.join(*out) +def _legalize_stage(path, replacements, length, extension, fragment): + """Perform a single round of path legalization steps + (sanitation/replacement, encoding from Unicode to bytes, + extension-appending, and truncation). Return the path (Unicode if + `fragment` is set, `bytes` otherwise) and whether truncation was + required. + """ + # Perform an initial sanitization including user replacements. + path = sanitize_path(path, replacements) + + # Encode for the filesystem. + if not fragment: + path = bytestring_path(path) + + # Preserve extension. + path += extension.lower() + + # Truncate too-long components. + pre_truncate_path = path + path = truncate_path(path, length) + + return path, path != pre_truncate_path + + +def legalize_path(path, replacements, length, extension, fragment): + """Given a path-like Unicode string, produce a legal path. Return + the path and a flag indicating whether some replacements had to be + ignored (see below). + + The legalization process (see `_legalize_stage`) consists of + applying the sanitation rules in `replacements`, encoding the string + to bytes (unless `fragment` is set), truncating components to + `length`, appending the `extension`. + + This function performs up to three calls to `_legalize_stage` in + case truncation conflicts with replacements (as can happen when + truncation creates whitespace at the end of the string, for + example). The limited number of iterations iterations avoids the + possibility of an infinite loop of sanitation and truncation + operations, which could be caused by replacement rules that make the + string longer. The flag returned from this function indicates that + the path has to be truncated twice (indicating that replacements + made the string longer again after it was truncated); the + application should probably log some sort of warning. + """ + + if fragment: + # Outputting Unicode. + extension = extension.decode('utf8', 'ignore') + + first_stage_path, _ = _legalize_stage( + path, replacements, length, extension, fragment + ) + + # Convert back to Unicode with extension removed. + first_stage_path, _ = os.path.splitext(displayable_path(first_stage_path)) + + # Re-sanitize following truncation (including user replacements). + second_stage_path, retruncated = _legalize_stage( + first_stage_path, replacements, length, extension, fragment + ) + + # If the path was once again truncated, discard user replacements + # and run through one last legalization stage. + if retruncated: + second_stage_path, _ = _legalize_stage( + first_stage_path, None, length, extension, fragment + ) + + return second_stage_path, retruncated + + def str2bool(value): """Returns a boolean reflecting a human-entered string.""" return value.lower() in ('yes', '1', 'true', 't', 'y') @@ -588,8 +660,8 @@ def cpu_count(): num = 0 elif sys.platform == b'darwin': try: - num = int(command_output([b'sysctl', b'-n', b'hw.ncpu'])) - except ValueError: + num = int(command_output([b'/usr/sbin/sysctl', b'-n', b'hw.ncpu'])) + except (ValueError, OSError, subprocess.CalledProcessError): num = 0 else: try: diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 908f6a94c..991fec935 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -212,8 +212,8 @@ class DuplicatesPlugin(BeetsPlugin): return key, checksum def _group_by(self, objs, keys, strict): - """Return a dictionary with keys arbitrary concatenations of attributes and - values lists of objects (Albums or Items) with those keys. + """Return a dictionary with keys arbitrary concatenations of attributes + and values lists of objects (Albums or Items) with those keys. If strict, all attributes must be defined for a duplicate match. """ @@ -237,9 +237,13 @@ class DuplicatesPlugin(BeetsPlugin): return counts def _order(self, objs, tiebreak=None): - """Return objs sorted by descending order of fields in tiebreak dict. + """Return the objects (Items or Albums) sorted by descending + order of priority. - Default ordering is based on attribute completeness. + If provided, the `tiebreak` dict indicates the field to use to + prioritize the objects. Otherwise, Items are placed in order of + "completeness" (objects with more non-null fields come first) + and Albums are ordered by their track count. """ if tiebreak: kind = 'items' if all(isinstance(o, Item) @@ -248,9 +252,14 @@ class DuplicatesPlugin(BeetsPlugin): else: kind = Item if all(isinstance(o, Item) for o in objs) else Album if kind is Item: - fields = [f for sublist in kind.get_fields() for f in sublist] - key = lambda x: len([(a, getattr(x, a, None)) for a in fields - if getattr(x, a, None) not in (None, '')]) + def truthy(v): + # Avoid a Unicode warning by avoiding comparison + # between a bytes object and the empty Unicode + # string ''. + return v is not None and \ + (v != '' if isinstance(v, unicode) else True) + fields = kind.all_keys() + key = lambda x: sum(1 for f in fields if truthy(getattr(x, f))) else: key = lambda x: len(x.items()) diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index bc772bd83..11651a130 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -436,7 +436,9 @@ class FetchArtPlugin(plugins.BeetsPlugin): self._log.debug(u'downloaded art to: {0}', util.displayable_path(fh.name)) return fh.name - except (IOError, requests.RequestException): + except (IOError, requests.RequestException, TypeError): + # Handling TypeError works around a urllib3 bug: + # https://github.com/shazow/urllib3/issues/556 self._log.debug(u'error fetching art') def _is_valid_image_candidate(self, candidate): diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index e19101e3e..3c9c0042a 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -388,6 +388,12 @@ class GStreamerBackend(Backend): self._res = self.Gst.ElementFactory.make("audioresample", "res") self._rg = self.Gst.ElementFactory.make("rganalysis", "rg") + 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( + "Failed to load required GStreamer plugins" + ) + # We check which files need gain ourselves, so all files given # to rganalsys should have their gain computed, even if it # already exists. diff --git a/beetsplug/zero.py b/beetsplug/zero.py index 48ca930ca..abccde36f 100644 --- a/beetsplug/zero.py +++ b/beetsplug/zero.py @@ -41,6 +41,7 @@ class ZeroPlugin(BeetsPlugin): self.config.add({ 'fields': [], + 'update_database': False, }) self.patterns = {} @@ -99,3 +100,5 @@ class ZeroPlugin(BeetsPlugin): if match: self._log.debug(u'{0}: {1} -> None', field, value) tags[field] = None + if self.config['update_database']: + item[field] = None diff --git a/docs/changelog.rst b/docs/changelog.rst index 5673f86f0..9cf1e22c7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,9 +26,13 @@ New features: :bug:`1104` :bug:`1493` * :doc:`/plugins/plexupdate`: A new ``token`` configuration option lets you specify a key for Plex Home setups. Thanks to :user:`edcarroll`. :bug:`1494` +* :doc:`/plugins/zero`: A new ``update_database`` configuration option + allows the database to be updated along with files' tags. :bug:`1516` * :doc:`/plugins/play`: A new option `--args`/`-A` has been added, used to hand over options to the player. +>>>>>>> master + Fixes: * :doc:`/plugins/importfeeds`: Avoid generating incorrect m3u filename when @@ -66,6 +70,26 @@ Fixes: :user:`Somasis`. :bug:`1512` * Some messages in the console UI now use plural nouns correctly. Thanks to :user:`JesseWeinstein`. :bug:`1521` +* Sorting numerical fields (such as track) now works again. :bug:`1511` +* :doc:`/plugins/replaygain`: Missing GStreamer plugins now cause a helpful + error message instead of a crash. :bug:`1518` +* Fix an edge case when producing sanitized filenames where the maximum path + length conflicted with the :ref:`replace` rules. Thanks to Ben Ockmore. + :bug:`496` :bug:`1361` +* Fix an incompatibility with OS X 10.11 (where ``/usr/sbin`` seems not to be + on the user's path by default). +* Fix an incompatibility with certain JPEG files. Here's a relevant `Python + bug`_. Thanks to :user:`nathdwek`. :bug:`1545` +* Fix the :ref:`group_albums` importer mode so it can handle when files are + not already in order by album. :bug:`1550` +* The ``fields`` command no longer separates built-in fields from + plugin-provided ones. This distinction was becoming increasingly unreliable. +* :doc:`/plugins/duplicates`: Fix a Unicode warning when paths contained + non-ASCII characters. :bug:`1551` +* :doc:`/plugins/fetchart`: Work around a urllib3 bug that could cause a + crash. :bug:`1555` :bug:`1556` + +.. _Python bug: http://bugs.python.org/issue16512 1.3.13 (April 24, 2015) diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index a97a8baa9..b1cf2deca 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -361,11 +361,11 @@ method. Here's an example plugin that provides a meaningless new field "foo":: - class fooplugin(beetsplugin): + class FooPlugin(BeetsPlugin): def __init__(self): - field = mediafile.mediafield( - mediafile.mp3descstoragestyle(u'foo') - mediafile.storagestyle(u'foo') + field = mediafile.MediaField( + mediafile.MP3DescStorageStyle(u'foo'), + mediafile.StorageStyle(u'foo') ) self.add_media_field('foo', field) diff --git a/docs/plugins/lyrics.rst b/docs/plugins/lyrics.rst index e7ecd1b04..1d91a00eb 100644 --- a/docs/plugins/lyrics.rst +++ b/docs/plugins/lyrics.rst @@ -49,10 +49,10 @@ configuration file. The available options are: - **google_engine_ID**: The custom search engine to use. Default: The `beets custom search engine`_, which gathers an updated list of sources known to be scrapeable. -- **sources**: List of sources to search for lyrics. An asterisk `*` expands +- **sources**: List of sources to search for lyrics. An asterisk ``*`` expands to all available sources. Default: ``google lyricwiki lyrics.com musixmatch``, i.e., all sources. - *google* source will be automatically deactivated if no `google_engine_ID` is + *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/zero.rst b/docs/plugins/zero.rst index c53a7b148..2682ee6ca 100644 --- a/docs/plugins/zero.rst +++ b/docs/plugins/zero.rst @@ -3,8 +3,7 @@ Zero Plugin The ``zero`` plugin allows you to null fields in files' metadata tags. Fields can be nulled unconditionally or conditioned on a pattern match. For example, -the plugin can strip useless comments like "ripped by MyGreatRipper." This -plugin only affects files' tags ; the beets database is left unchanged. +the plugin can strip useless comments like "ripped by MyGreatRipper." To use the ``zero`` plugin, enable the plugin in your configuration (see :ref:`using-plugins`). @@ -21,6 +20,8 @@ fields to nullify and the conditions for nullifying them: embedded in the media file. * To conditionally filter a field, use ``field: [regexp, regexp]`` to specify regular expressions. +* By default this plugin only affects files' tags ; the beets database is left + unchanged. To update the tags in the database, set the ``update_database`` option. For example:: @@ -28,6 +29,7 @@ For example:: fields: month day genre comments comments: [EAC, LAME, from.+collection, 'ripped by'] genre: [rnb, 'power metal'] + update_database: true If a custom pattern is not defined for a given field, the field will be nulled unconditionally. diff --git a/docs/reference/config.rst b/docs/reference/config.rst index d737bba69..9000f070a 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -119,6 +119,11 @@ compatibility with Windows-influenced network filesystems like Samba). Trailing dots and trailing whitespace, which can cause problems on Windows clients, are also removed. +When replacements other than the defaults are used, it is possible that they +will increase the length of the path. In the scenario where this leads to a +conflict with the maximum filename length, the default replacements will be +used to resolve the conflict and beets will display a warning. + Note that paths might contain special characters such as typographical quotes (``“”``). With the configuration above, those will not be replaced as they don't match the typewriter quote (``"``). To also strip these diff --git a/extra/_beet b/extra/_beet index 1987ed57c..a7533d9a7 100644 --- a/extra/_beet +++ b/extra/_beet @@ -18,6 +18,10 @@ function _beet_field_values() { local -a output fieldvals local library="$(beet config|grep library|cut -f 2 -d ' ')" + if [ -z "$library" ]; then + # Use default library location if there is no user defined one + library="~/.config/beets/library.db" + fi output=$(sqlite3 ${~library} "select distinct $1 from items;") case $1 in @@ -50,7 +54,7 @@ query=( \( "$matchquery" ":query:query string:{_beet_query}" \) \( "$matchquery" modify=( \( "$matchmodify" ":modify:modify string:$modifyelem" \) \( "$matchmodify" ":modify:modify string:$modifyelem" \) \# ) # arguments to _regex_arguments for completing files and directories -local -a files dirs +local -a files dirs files=("$matchany" ':file:file:_files') dirs=("$matchany" ':dir:directory:_dirs') @@ -82,7 +86,7 @@ retagopt='-L:retag items matching a query:${query[@]}' skipopt='-i:skip already-imported directories' noskipopt='-I:do not skip already-imported directories' flatopt='--flat:import an entire tree as a single album' -groupopt='-g:group tracks in a folder into seperate albums' +groupopt='-g:group tracks in a folder into seperate albums' editopt='-e:edit user configuration with $EDITOR' defaultopt='-d:include the default configuration' copynomoveopt='-c:copy instead of moving' @@ -184,7 +188,7 @@ do options=( "${reply[@]}" \# "${query[@]}" ) ;; (stats) - _regex_words options "stats options" "$helpopt" "$exactopt" + _regex_words options "stats options" "$helpopt" "$exactopt" options=( "${reply[@]}" \# "${query[@]}" ) ;; (update) @@ -200,9 +204,9 @@ do ;; (help) # The help subcommand is treated separately - continue + continue ;; - (*) # completions for plugin commands are generated using _beet_subcmd_options + (*) # completions for plugin commands are generated using _beet_subcmd_options _beet_subcmd_options "$subcmd" options=( \( "${reply[@]}" \# "${query[@]}" \) ) ;; @@ -237,6 +241,6 @@ zstyle ":completion:${curcontext}:" tag-order '! options' # Execute the completion function _beet "$@" -# Local Variables: +# Local Variables: # mode:shell-script # End: diff --git a/test/rsrc/only-magic-bytes.jpg b/test/rsrc/only-magic-bytes.jpg new file mode 100644 index 000000000..ca0cf220f Binary files /dev/null and b/test/rsrc/only-magic-bytes.jpg differ diff --git a/test/test_library.py b/test/test_library.py index a4b4d08a8..30b0d6fc4 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -452,8 +452,7 @@ class DestinationTest(_common.TestCase): self.assertEqual(self.i.destination(), np('base/one/_.mp3')) - @unittest.skip('unimplemented: #496') - def test_truncation_does_not_conflict_with_replacement(self): + def test_legalize_path_one_for_one_replacement(self): # Use a replacement that should always replace the last X in any # path component with a Z. self.lib.replacements = [ @@ -466,7 +465,23 @@ class DestinationTest(_common.TestCase): # The final path should reflect the replacement. dest = self.i.destination() - self.assertTrue('XZ' in dest) + self.assertEqual(dest[-2:], 'XZ') + + def test_legalize_path_one_for_many_replacement(self): + # Use a replacement that should always replace the last X in any + # path component with four Zs. + self.lib.replacements = [ + (re.compile(r'X$'), u'ZZZZ'), + ] + + # Construct an item whose untruncated path ends with a Y but whose + # truncated version ends with an X. + self.i.title = 'X' * 300 + 'Y' + + # The final path should ignore the user replacement and create a path + # of the correct length, containing Xs. + dest = self.i.destination() + self.assertEqual(dest[-2:], 'XX') class ItemFormattedMappingTest(_common.LibTestCase): diff --git a/test/test_mb.py b/test/test_mb.py index c1c93bbdc..7662e0722 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -471,7 +471,7 @@ class MBLibraryTest(unittest.TestCase): ai = list(mb.match_album('hello', 'there'))[0] sp.assert_called_with(artist='hello', release='there', limit=5) - gp.assert_calledwith(mbid) + gp.assert_called_with(mbid, mock.ANY) self.assertEqual(ai.tracks[0].title, 'foo') self.assertEqual(ai.album, 'hi') diff --git a/test/test_mediafile_edge.py b/test/test_mediafile_edge.py index 3e828ac3e..7a17fe86a 100644 --- a/test/test_mediafile_edge.py +++ b/test/test_mediafile_edge.py @@ -78,6 +78,17 @@ class EdgeTest(unittest.TestCase): f = beets.mediafile.MediaFile(os.path.join(_common.RSRC, 'oldape.ape')) self.assertEqual(f.bitrate, 0) + def test_only_magic_bytes_jpeg(self): + # Some jpeg files can only be recognized by their magic bytes and as + # such aren't recognized by imghdr. Ensure that this still works thanks + # to our own follow up mimetype detection based on + # https://github.com/file/file/blob/master/magic/Magdir/jpeg#L12 + f = open(os.path.join(_common.RSRC, 'only-magic-bytes.jpg'), 'rb') + jpg_data = f.read() + self.assertEqual( + beets.mediafile._image_mime_type(jpg_data), + 'image/jpeg') + class InvalidValueToleranceTest(unittest.TestCase): diff --git a/test/test_sort.py b/test/test_sort.py index 519d19c6e..e763b6167 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -34,21 +34,21 @@ class DummyDataTestCase(_common.TestCase): albums = [_common.album() for _ in range(3)] albums[0].album = "Album A" albums[0].genre = "Rock" - albums[0].year = "2001" + albums[0].year = 2001 albums[0].flex1 = "Flex1-1" albums[0].flex2 = "Flex2-A" albums[0].albumartist = "Foo" albums[0].albumartist_sort = None albums[1].album = "Album B" albums[1].genre = "Rock" - albums[1].year = "2001" + albums[1].year = 2001 albums[1].flex1 = "Flex1-2" albums[1].flex2 = "Flex2-A" albums[1].albumartist = "Bar" albums[1].albumartist_sort = None albums[2].album = "Album C" albums[2].genre = "Jazz" - albums[2].year = "2005" + albums[2].year = 2005 albums[2].flex1 = "Flex1-1" albums[2].flex2 = "Flex2-B" albums[2].albumartist = "Baz" @@ -67,6 +67,7 @@ class DummyDataTestCase(_common.TestCase): items[0].album_id = albums[0].id items[0].artist_sort = None items[0].path = "/path0.mp3" + items[0].track = 1 items[1].title = 'Baz qux' items[1].artist = 'Two' items[1].album = 'Baz' @@ -77,6 +78,7 @@ class DummyDataTestCase(_common.TestCase): items[1].album_id = albums[0].id items[1].artist_sort = None items[1].path = "/patH1.mp3" + items[1].track = 2 items[2].title = 'Beets 4 eva' items[2].artist = 'Three' items[2].album = 'Foo' @@ -87,6 +89,7 @@ class DummyDataTestCase(_common.TestCase): items[2].album_id = albums[1].id items[2].artist_sort = None items[2].path = "/paTH2.mp3" + items[2].track = 3 items[3].title = 'Beets 4 eva' items[3].artist = 'Three' items[3].album = 'Foo2' @@ -97,6 +100,7 @@ class DummyDataTestCase(_common.TestCase): items[3].album_id = albums[2].id items[3].artist_sort = None items[3].path = "/PATH3.mp3" + items[3].track = 4 for item in items: self.lib.add(item) @@ -399,6 +403,7 @@ class CaseSensitivityTest(DummyDataTestCase, _common.TestCase): item.flex2 = "flex2-A" item.album_id = album.id item.artist_sort = None + item.track = 10 self.lib.add(item) self.new_album = album @@ -451,6 +456,17 @@ class CaseSensitivityTest(DummyDataTestCase, _common.TestCase): self.assertEqual(results[0].flex1, 'Flex1-0') self.assertEqual(results[-1].flex1, 'flex1') + def test_case_sensitive_only_affects_text(self): + config['sort_case_insensitive'] = True + q = 'track+' + results = list(self.lib.items(q)) + # If the numerical values were sorted as strings, + # then ['1', '10', '2'] would be valid. + print([r.track for r in results]) + self.assertEqual(results[0].track, 1) + self.assertEqual(results[1].track, 2) + self.assertEqual(results[-1].track, 10) + def suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/test/test_zero.py b/test/test_zero.py index 7274cc943..a23b47d1c 100644 --- a/test/test_zero.py +++ b/test/test_zero.py @@ -88,6 +88,23 @@ class ZeroPluginTest(unittest.TestCase, TestHelper): self.assertEqual(item['year'], 2000) self.assertIsNone(mediafile.year) + def test_change_database(self): + item = self.add_item_fixture(year=2000) + item.write() + mediafile = MediaFile(item.path) + self.assertEqual(2000, mediafile.year) + + config['zero'] = { + 'fields': ['year'], + 'update_database': True, + } + self.load_plugins('zero') + + item.write() + mediafile = MediaFile(item.path) + self.assertEqual(item['year'], 0) + self.assertIsNone(mediafile.year) + def test_album_art(self): path = self.create_mediafile_fixture(images=['jpg']) item = Item.from_path(path)