# This file is part of beets. # 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 # "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 non-query database functions of Item. """ import os import os.path import re import shutil import stat import sys import time import unicodedata import unittest from test import _common from test._common import item from test.helper import TestHelper from mediafile import MediaFile, UnreadableFileError import beets.dbcore.query import beets.library from beets import config, plugins, util from beets.util import bytestring_path, syspath # Shortcut to path normalization. np = util.normpath class LoadTest(_common.LibTestCase): def test_load_restores_data_from_db(self): original_title = self.i.title self.i.title = "something" self.i.load() self.assertEqual(original_title, self.i.title) def test_load_clears_dirty_flags(self): self.i.artist = "something" self.assertTrue("artist" in self.i._dirty) self.i.load() self.assertTrue("artist" not in self.i._dirty) class StoreTest(_common.LibTestCase): def test_store_changes_database_value(self): self.i.year = 1987 self.i.store() new_year = ( self.lib._connection() .execute("select year from items where " 'title="the title"') .fetchone()["year"] ) self.assertEqual(new_year, 1987) def test_store_only_writes_dirty_fields(self): original_genre = self.i.genre self.i._values_fixed["genre"] = "beatboxing" # change w/o dirtying self.i.store() new_genre = ( self.lib._connection() .execute("select genre from items where " 'title="the title"') .fetchone()["genre"] ) self.assertEqual(new_genre, original_genre) def test_store_clears_dirty_flags(self): self.i.composer = "tvp" self.i.store() self.assertTrue("composer" not in self.i._dirty) def test_store_album_cascades_flex_deletes(self): album = _common.album() album.flex1 = "Flex-1" self.lib.add(album) item = _common.item() item.album_id = album.id item.flex1 = "Flex-1" self.lib.add(item) del album.flex1 album.store() self.assertNotIn("flex1", album) self.assertNotIn("flex1", album.items()[0]) class AddTest(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.i = item() def test_item_add_inserts_row(self): self.lib.add(self.i) new_grouping = ( self.lib._connection() .execute( "select grouping from items " 'where composer="the composer"' ) .fetchone()["grouping"] ) self.assertEqual(new_grouping, self.i.grouping) def test_library_add_path_inserts_row(self): i = beets.library.Item.from_path( os.path.join(_common.RSRC, b"full.mp3") ) self.lib.add(i) new_grouping = ( self.lib._connection() .execute( "select grouping from items " 'where composer="the composer"' ) .fetchone()["grouping"] ) self.assertEqual(new_grouping, self.i.grouping) class RemoveTest(_common.LibTestCase): def test_remove_deletes_from_db(self): self.i.remove() c = self.lib._connection().execute("select * from items") self.assertEqual(c.fetchone(), None) class GetSetTest(_common.TestCase): def setUp(self): super().setUp() self.i = item() def test_set_changes_value(self): self.i.bpm = 4915 self.assertEqual(self.i.bpm, 4915) def test_set_sets_dirty_flag(self): self.i.comp = not self.i.comp self.assertTrue("comp" in self.i._dirty) def test_set_does_not_dirty_if_value_unchanged(self): self.i.title = self.i.title self.assertTrue("title" not in self.i._dirty) def test_invalid_field_raises_attributeerror(self): self.assertRaises(AttributeError, getattr, self.i, "xyzzy") def test_album_fallback(self): # integration test of item-album fallback lib = beets.library.Library(":memory:") i = item(lib) album = lib.add_album([i]) album["flex"] = "foo" album.store() self.assertTrue("flex" in i) self.assertFalse("flex" in i.keys(with_album=False)) self.assertEqual(i["flex"], "foo") self.assertEqual(i.get("flex"), "foo") self.assertEqual(i.get("flex", with_album=False), None) self.assertEqual(i.get("flexx"), None) class DestinationTest(_common.TestCase): def setUp(self): super().setUp() # default directory is ~/Music and the only reason why it was switched # to ~/.Music is to confirm that tests works well when path to # temporary directory contains . self.lib = beets.library.Library(":memory:", "~/.Music") self.i = item(self.lib) def tearDown(self): super().tearDown() self.lib._connection().close() # Reset config if it was changed in test cases config.clear() config.read(user=False, defaults=True) def test_directory_works_with_trailing_slash(self): self.lib.directory = b"one/" self.lib.path_formats = [("default", "two")] self.assertEqual(self.i.destination(), np("one/two")) def test_directory_works_without_trailing_slash(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "two")] self.assertEqual(self.i.destination(), np("one/two")) def test_destination_substitutes_metadata_values(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$album/$artist $title")] self.i.title = "three" self.i.artist = "two" self.i.album = "one" self.assertEqual(self.i.destination(), np("base/one/two three")) def test_destination_preserves_extension(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$title")] self.i.path = "hey.audioformat" self.assertEqual(self.i.destination(), np("base/the title.audioformat")) def test_lower_case_extension(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$title")] self.i.path = "hey.MP3" self.assertEqual(self.i.destination(), np("base/the title.mp3")) def test_destination_pads_some_indices(self): self.lib.directory = b"base" self.lib.path_formats = [ ("default", "$track $tracktotal $disc $disctotal $bpm") ] self.i.track = 1 self.i.tracktotal = 2 self.i.disc = 3 self.i.disctotal = 4 self.i.bpm = 5 self.assertEqual(self.i.destination(), np("base/01 02 03 04 5")) def test_destination_pads_date_values(self): self.lib.directory = b"base" self.lib.path_formats = [("default", "$year-$month-$day")] self.i.year = 1 self.i.month = 2 self.i.day = 3 self.assertEqual(self.i.destination(), np("base/0001-02-03")) def test_destination_escapes_slashes(self): self.i.album = "one/two" dest = self.i.destination() self.assertTrue(b"one" in dest) self.assertTrue(b"two" in dest) self.assertFalse(b"one/two" in dest) def test_destination_escapes_leading_dot(self): self.i.album = ".something" dest = self.i.destination() self.assertTrue(b"something" in dest) self.assertFalse(b"/.something" in dest) def test_destination_preserves_legitimate_slashes(self): self.i.artist = "one" self.i.album = "two" dest = self.i.destination() self.assertTrue(os.path.join(b"one", b"two") in dest) def test_destination_long_names_truncated(self): self.i.title = "X" * 300 self.i.artist = "Y" * 300 for c in self.i.destination().split(util.PATH_SEP): self.assertTrue(len(c) <= 255) def test_destination_long_names_keep_extension(self): self.i.title = "X" * 300 self.i.path = b"something.extn" dest = self.i.destination() self.assertEqual(dest[-5:], b".extn") def test_distination_windows_removes_both_separators(self): self.i.title = "one \\ two / three.mp3" with _common.platform_windows(): p = self.i.destination() self.assertFalse(b"one \\ two" in p) self.assertFalse(b"one / two" in p) self.assertFalse(b"two \\ three" in p) self.assertFalse(b"two / three" in p) def test_path_with_format(self): self.lib.path_formats = [("default", "$artist/$album ($format)")] p = self.i.destination() self.assertTrue(b"(FLAC)" in p) def test_heterogeneous_album_gets_single_directory(self): i1, i2 = item(), item() self.lib.add_album([i1, i2]) i1.year, i2.year = 2009, 2010 self.lib.path_formats = [("default", "$album ($year)/$track $title")] dest1, dest2 = i1.destination(), i2.destination() self.assertEqual(os.path.dirname(dest1), os.path.dirname(dest2)) def test_default_path_for_non_compilations(self): self.i.comp = False self.lib.add_album([self.i]) self.lib.directory = b"one" self.lib.path_formats = [("default", "two"), ("comp:true", "three")] self.assertEqual(self.i.destination(), np("one/two")) def test_singleton_path(self): i = item(self.lib) self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("singleton:true", "four"), ("comp:true", "three"), ] self.assertEqual(i.destination(), np("one/four")) def test_comp_before_singleton_path(self): i = item(self.lib) i.comp = True self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("comp:true", "three"), ("singleton:true", "four"), ] self.assertEqual(i.destination(), np("one/three")) def test_comp_path(self): self.i.comp = True self.lib.add_album([self.i]) self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("comp:true", "three"), ] self.assertEqual(self.i.destination(), np("one/three")) def test_albumtype_query_path(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = "sometype" self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("albumtype:sometype", "four"), ("comp:true", "three"), ] self.assertEqual(self.i.destination(), np("one/four")) def test_albumtype_path_fallback_to_comp(self): self.i.comp = True self.lib.add_album([self.i]) self.i.albumtype = "sometype" self.lib.directory = b"one" self.lib.path_formats = [ ("default", "two"), ("albumtype:anothertype", "four"), ("comp:true", "three"), ] self.assertEqual(self.i.destination(), np("one/three")) def test_get_formatted_does_not_replace_separators(self): with _common.platform_posix(): name = os.path.join("a", "b") self.i.title = name newname = self.i.formatted().get("title") self.assertEqual(name, newname) def test_get_formatted_pads_with_zero(self): with _common.platform_posix(): self.i.track = 1 name = self.i.formatted().get("track") self.assertTrue(name.startswith("0")) def test_get_formatted_uses_kbps_bitrate(self): with _common.platform_posix(): self.i.bitrate = 12345 val = self.i.formatted().get("bitrate") self.assertEqual(val, "12kbps") def test_get_formatted_uses_khz_samplerate(self): with _common.platform_posix(): self.i.samplerate = 12345 val = self.i.formatted().get("samplerate") self.assertEqual(val, "12kHz") def test_get_formatted_datetime(self): with _common.platform_posix(): self.i.added = 1368302461.210265 val = self.i.formatted().get("added") self.assertTrue(val.startswith("2013")) def test_get_formatted_none(self): with _common.platform_posix(): self.i.some_other_field = None val = self.i.formatted().get("some_other_field") self.assertEqual(val, "") def test_artist_falls_back_to_albumartist(self): self.i.artist = "" self.i.albumartist = "something" self.lib.path_formats = [("default", "$artist")] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b"something") def test_albumartist_falls_back_to_artist(self): self.i.artist = "trackartist" self.i.albumartist = "" self.lib.path_formats = [("default", "$albumartist")] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b"trackartist") def test_artist_overrides_albumartist(self): self.i.artist = "theartist" self.i.albumartist = "something" self.lib.path_formats = [("default", "$artist")] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b"theartist") def test_albumartist_overrides_artist(self): self.i.artist = "theartist" self.i.albumartist = "something" self.lib.path_formats = [("default", "$albumartist")] p = self.i.destination() self.assertEqual(p.rsplit(util.PATH_SEP, 1)[1], b"something") def test_unicode_normalized_nfd_on_mac(self): instr = unicodedata.normalize("NFC", "caf\xe9") self.lib.path_formats = [("default", instr)] dest = self.i.destination(platform="darwin", fragment=True) self.assertEqual(dest, unicodedata.normalize("NFD", instr)) def test_unicode_normalized_nfc_on_linux(self): instr = unicodedata.normalize("NFD", "caf\xe9") self.lib.path_formats = [("default", instr)] dest = self.i.destination(platform="linux", fragment=True) self.assertEqual(dest, unicodedata.normalize("NFC", instr)) def test_non_mbcs_characters_on_windows(self): oldfunc = sys.getfilesystemencoding sys.getfilesystemencoding = lambda: "mbcs" try: self.i.title = "h\u0259d" self.lib.path_formats = [("default", "$title")] p = self.i.destination() self.assertFalse(b"?" in p) # We use UTF-8 to encode Windows paths now. self.assertTrue("h\u0259d".encode() in p) finally: sys.getfilesystemencoding = oldfunc def test_unicode_extension_in_fragment(self): self.lib.path_formats = [("default", "foo")] self.i.path = util.bytestring_path("bar.caf\xe9") dest = self.i.destination(platform="linux", fragment=True) self.assertEqual(dest, "foo.caf\xe9") def test_asciify_and_replace(self): config["asciify_paths"] = True self.lib.replacements = [(re.compile('"'), "q")] self.lib.directory = b"lib" self.lib.path_formats = [("default", "$title")] self.i.title = "\u201c\u00f6\u2014\u00cf\u201d" self.assertEqual(self.i.destination(), np("lib/qo--Iq")) def test_asciify_character_expanding_to_slash(self): config["asciify_paths"] = True self.lib.directory = b"lib" self.lib.path_formats = [("default", "$title")] self.i.title = "ab\xa2\xbdd" self.assertEqual(self.i.destination(), np("lib/abC_ 1_2d")) def test_destination_with_replacements(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"a"), "e")] self.lib.path_formats = [("default", "$album/$title")] self.i.title = "foo" self.i.album = "bar" self.assertEqual(self.i.destination(), np("base/ber/foo")) def test_destination_with_replacements_argument(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"a"), "f")] self.lib.path_formats = [("default", "$album/$title")] self.i.title = "foo" self.i.album = "bar" replacements = [(re.compile(r"a"), "e")] self.assertEqual( self.i.destination(replacements=replacements), np("base/ber/foo") ) @unittest.skip("unimplemented: #359") def test_destination_with_empty_component(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"^$"), "_")] self.lib.path_formats = [("default", "$album/$artist/$title")] self.i.title = "three" self.i.artist = "" self.i.albumartist = "" self.i.album = "one" self.assertEqual(self.i.destination(), np("base/one/_/three")) @unittest.skip("unimplemented: #359") def test_destination_with_empty_final_component(self): self.lib.directory = b"base" self.lib.replacements = [(re.compile(r"^$"), "_")] self.lib.path_formats = [("default", "$album/$title")] self.i.title = "" self.i.album = "one" self.i.path = "foo.mp3" self.assertEqual(self.i.destination(), np("base/one/_.mp3")) 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 = [ (re.compile(r"X$"), "Z"), ] # 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 reflect the replacement. dest = self.i.destination() self.assertEqual(dest[-2:], b"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$"), "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:], b"XX") def test_album_field_query(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "two"), ("flex:foo", "three")] album = self.lib.add_album([self.i]) self.assertEqual(self.i.destination(), np("one/two")) album["flex"] = "foo" album.store() self.assertEqual(self.i.destination(), np("one/three")) def test_album_field_in_template(self): self.lib.directory = b"one" self.lib.path_formats = [("default", "$flex/two")] album = self.lib.add_album([self.i]) album["flex"] = "foo" album.store() self.assertEqual(self.i.destination(), np("one/foo/two")) class ItemFormattedMappingTest(_common.LibTestCase): def test_formatted_item_value(self): formatted = self.i.formatted() self.assertEqual(formatted["artist"], "the artist") def test_get_unset_field(self): formatted = self.i.formatted() with self.assertRaises(KeyError): formatted["other_field"] def test_get_method_with_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get("other_field"), "") def test_get_method_with_specified_default(self): formatted = self.i.formatted() self.assertEqual(formatted.get("other_field", "default"), "default") def test_item_precedence(self): album = self.lib.add_album([self.i]) album["artist"] = "foo" album.store() self.assertNotEqual("foo", self.i.formatted().get("artist")) def test_album_flex_field(self): album = self.lib.add_album([self.i]) album["flex"] = "foo" album.store() self.assertEqual("foo", self.i.formatted().get("flex")) def test_album_field_overrides_item_field_for_path(self): # Make the album inconsistent with the item. album = self.lib.add_album([self.i]) album.album = "foo" album.store() self.i.album = "bar" self.i.store() # Ensure the album takes precedence. formatted = self.i.formatted(for_path=True) self.assertEqual(formatted["album"], "foo") def test_artist_falls_back_to_albumartist(self): self.i.artist = "" formatted = self.i.formatted() self.assertEqual(formatted["artist"], "the album artist") def test_albumartist_falls_back_to_artist(self): self.i.albumartist = "" formatted = self.i.formatted() self.assertEqual(formatted["albumartist"], "the artist") def test_both_artist_and_albumartist_empty(self): self.i.artist = "" self.i.albumartist = "" formatted = self.i.formatted() self.assertEqual(formatted["albumartist"], "") class PathFormattingMixin: """Utilities for testing path formatting.""" def _setf(self, fmt): self.lib.path_formats.insert(0, ("default", fmt)) def _assert_dest(self, dest, i=None): if i is None: i = self.i with _common.platform_posix(): actual = i.destination() self.assertEqual(actual, dest) class DestinationFunctionTest(_common.TestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i = item(self.lib) def tearDown(self): super().tearDown() self.lib._connection().close() def test_upper_case_literal(self): self._setf("%upper{foo}") self._assert_dest(b"/base/FOO") def test_upper_case_variable(self): self._setf("%upper{$title}") self._assert_dest(b"/base/THE TITLE") def test_title_case_variable(self): self._setf("%title{$title}") self._assert_dest(b"/base/The Title") def test_title_case_variable_aphostrophe(self): self._setf("%title{I can't}") self._assert_dest(b"/base/I Can't") def test_asciify_variable(self): self._setf("%asciify{ab\xa2\xbdd}") self._assert_dest(b"/base/abC_ 1_2d") def test_left_variable(self): self._setf("%left{$title, 3}") self._assert_dest(b"/base/the") def test_right_variable(self): self._setf("%right{$title,3}") self._assert_dest(b"/base/tle") def test_if_false(self): self._setf("x%if{,foo}") self._assert_dest(b"/base/x") def test_if_false_value(self): self._setf("x%if{false,foo}") self._assert_dest(b"/base/x") def test_if_true(self): self._setf("%if{bar,foo}") self._assert_dest(b"/base/foo") def test_if_else_false(self): self._setf("%if{,foo,baz}") self._assert_dest(b"/base/baz") def test_if_else_false_value(self): self._setf("%if{false,foo,baz}") self._assert_dest(b"/base/baz") def test_if_int_value(self): self._setf("%if{0,foo,baz}") self._assert_dest(b"/base/baz") def test_nonexistent_function(self): self._setf("%foo{bar}") self._assert_dest(b"/base/%foo{bar}") def test_if_def_field_return_self(self): self.i.bar = 3 self._setf("%ifdef{bar}") self._assert_dest(b"/base/3") def test_if_def_field_not_defined(self): self._setf(" %ifdef{bar}/$artist") self._assert_dest(b"/base/the artist") def test_if_def_field_not_defined_2(self): self._setf("$artist/%ifdef{bar}") self._assert_dest(b"/base/the artist") def test_if_def_true(self): self._setf("%ifdef{artist,cool}") self._assert_dest(b"/base/cool") def test_if_def_true_complete(self): self.i.series = "Now" self._setf("%ifdef{series,$series Series,Albums}/$album") self._assert_dest(b"/base/Now Series/the album") def test_if_def_false_complete(self): self._setf("%ifdef{plays,$plays,not_played}") self._assert_dest(b"/base/not_played") def test_first(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf("%first{$genres}") self._assert_dest(b"/base/Pop") def test_first_skip(self): self.i.genres = "Pop; Rock; Classical Crossover" self._setf("%first{$genres,1,2}") self._assert_dest(b"/base/Classical Crossover") def test_first_different_sep(self): self._setf("%first{Alice / Bob / Eve,2,0, / , & }") self._assert_dest(b"/base/Alice & Bob") class DisambiguationTest(_common.TestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i1 = item() self.i1.year = 2001 self.lib.add_album([self.i1]) self.i2 = item() self.i2.year = 2002 self.lib.add_album([self.i2]) self.lib._connection().commit() self._setf("foo%aunique{albumartist album,year}/$title") def tearDown(self): super().tearDown() self.lib._connection().close() def test_unique_expands_to_disambiguating_year(self): self._assert_dest(b"/base/foo [2001]/the title", self.i1) def test_unique_with_default_arguments_uses_albumtype(self): album2 = self.lib.get_album(self.i1) album2.albumtype = "bar" album2.store() self._setf("foo%aunique{}/$title") self._assert_dest(b"/base/foo [bar]/the title", self.i1) def test_unique_expands_to_nothing_for_distinct_albums(self): album2 = self.lib.get_album(self.i2) album2.album = "different album" album2.store() self._assert_dest(b"/base/foo/the title", self.i1) def test_use_fallback_numbers_when_identical(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album2.store() self._assert_dest(b"/base/foo [1]/the title", self.i1) self._assert_dest(b"/base/foo [2]/the title", self.i2) def test_unique_falls_back_to_second_distinguishing_field(self): self._setf("foo%aunique{albumartist album,month year}/$title") self._assert_dest(b"/base/foo [2001]/the title", self.i1) def test_unique_sanitized(self): album2 = self.lib.get_album(self.i2) album2.year = 2001 album1 = self.lib.get_album(self.i1) album1.albumtype = "foo/bar" album2.store() album1.store() self._setf("foo%aunique{albumartist album,albumtype}/$title") self._assert_dest(b"/base/foo [foo_bar]/the title", self.i1) def test_drop_empty_disambig_string(self): album1 = self.lib.get_album(self.i1) album1.albumdisambig = None album2 = self.lib.get_album(self.i2) album2.albumdisambig = "foo" album1.store() album2.store() self._setf("foo%aunique{albumartist album,albumdisambig}/$title") self._assert_dest(b"/base/foo/the title", self.i1) def test_change_brackets(self): self._setf("foo%aunique{albumartist album,year,()}/$title") self._assert_dest(b"/base/foo (2001)/the title", self.i1) def test_remove_brackets(self): self._setf("foo%aunique{albumartist album,year,}/$title") self._assert_dest(b"/base/foo 2001/the title", self.i1) def test_key_flexible_attribute(self): album1 = self.lib.get_album(self.i1) album1.flex = "flex1" album2 = self.lib.get_album(self.i2) album2.flex = "flex2" album1.store() album2.store() self._setf("foo%aunique{albumartist album flex,year}/$title") self._assert_dest(b"/base/foo/the title", self.i1) class SingletonDisambiguationTest(_common.TestCase, PathFormattingMixin): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.lib.directory = b"/base" self.lib.path_formats = [("default", "path")] self.i1 = item() self.i1.year = 2001 self.lib.add(self.i1) self.i2 = item() self.i2.year = 2002 self.lib.add(self.i2) self.lib._connection().commit() self._setf("foo/$title%sunique{artist title,year}") def tearDown(self): super().tearDown() self.lib._connection().close() def test_sunique_expands_to_disambiguating_year(self): self._assert_dest(b"/base/foo/the title [2001]", self.i1) def test_sunique_with_default_arguments_uses_trackdisambig(self): self.i1.trackdisambig = "live version" self.i1.year = self.i2.year self.i1.store() self._setf("foo/$title%sunique{}") self._assert_dest(b"/base/foo/the title [live version]", self.i1) def test_sunique_expands_to_nothing_for_distinct_singletons(self): self.i2.title = "different track" self.i2.store() self._assert_dest(b"/base/foo/the title", self.i1) def test_sunique_does_not_match_album(self): self.lib.add_album([self.i2]) self._assert_dest(b"/base/foo/the title", self.i1) def test_sunique_use_fallback_numbers_when_identical(self): self.i2.year = self.i1.year self.i2.store() self._assert_dest(b"/base/foo/the title [1]", self.i1) self._assert_dest(b"/base/foo/the title [2]", self.i2) def test_sunique_falls_back_to_second_distinguishing_field(self): self._setf("foo/$title%sunique{albumartist album,month year}") self._assert_dest(b"/base/foo/the title [2001]", self.i1) def test_sunique_sanitized(self): self.i2.year = self.i1.year self.i1.trackdisambig = "foo/bar" self.i2.store() self.i1.store() self._setf("foo/$title%sunique{artist title,trackdisambig}") self._assert_dest(b"/base/foo/the title [foo_bar]", self.i1) def test_drop_empty_disambig_string(self): self.i1.trackdisambig = None self.i2.trackdisambig = "foo" self.i1.store() self.i2.store() self._setf("foo/$title%sunique{albumartist album,trackdisambig}") self._assert_dest(b"/base/foo/the title", self.i1) def test_change_brackets(self): self._setf("foo/$title%sunique{artist title,year,()}") self._assert_dest(b"/base/foo/the title (2001)", self.i1) def test_remove_brackets(self): self._setf("foo/$title%sunique{artist title,year,}") self._assert_dest(b"/base/foo/the title 2001", self.i1) def test_key_flexible_attribute(self): self.i1.flex = "flex1" self.i2.flex = "flex2" self.i1.store() self.i2.store() self._setf("foo/$title%sunique{artist title flex,year}") self._assert_dest(b"/base/foo/the title", self.i1) class PluginDestinationTest(_common.TestCase): def setUp(self): super().setUp() # Mock beets.plugins.item_field_getters. self._tv_map = {} def field_getters(): getters = {} for key, value in self._tv_map.items(): getters[key] = lambda _: value return getters self.old_field_getters = plugins.item_field_getters plugins.item_field_getters = field_getters self.lib = beets.library.Library(":memory:") self.lib.directory = b"/base" self.lib.path_formats = [("default", "$artist $foo")] self.i = item(self.lib) def tearDown(self): super().tearDown() plugins.item_field_getters = self.old_field_getters def _assert_dest(self, dest): with _common.platform_posix(): the_dest = self.i.destination() self.assertEqual(the_dest, b"/base/" + dest) def test_undefined_value_not_substituted(self): self._assert_dest(b"the artist $foo") def test_plugin_value_not_substituted(self): self._tv_map = { "foo": "bar", } self._assert_dest(b"the artist bar") def test_plugin_value_overrides_attribute(self): self._tv_map = { "artist": "bar", } self._assert_dest(b"bar $foo") def test_plugin_value_sanitized(self): self._tv_map = { "foo": "bar/baz", } self._assert_dest(b"the artist bar_baz") class AlbumInfoTest(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.i = item() self.lib.add_album((self.i,)) def test_albuminfo_reflects_metadata(self): ai = self.lib.get_album(self.i) self.assertEqual(ai.mb_albumartistid, self.i.mb_albumartistid) self.assertEqual(ai.albumartist, self.i.albumartist) self.assertEqual(ai.album, self.i.album) self.assertEqual(ai.year, self.i.year) def test_albuminfo_stores_art(self): ai = self.lib.get_album(self.i) ai.artpath = "/my/great/art" ai.store() new_ai = self.lib.get_album(self.i) self.assertEqual(new_ai.artpath, b"/my/great/art") def test_albuminfo_for_two_items_doesnt_duplicate_row(self): i2 = item(self.lib) self.lib.get_album(self.i) self.lib.get_album(i2) c = self.lib._connection().cursor() c.execute("select * from albums where album=?", (self.i.album,)) # Cursor should only return one row. self.assertNotEqual(c.fetchone(), None) self.assertEqual(c.fetchone(), None) def test_individual_tracks_have_no_albuminfo(self): i2 = item() i2.album = "aTotallyDifferentAlbum" self.lib.add(i2) ai = self.lib.get_album(i2) self.assertEqual(ai, None) def test_get_album_by_id(self): ai = self.lib.get_album(self.i) ai = self.lib.get_album(self.i.id) self.assertNotEqual(ai, None) def test_album_items_consistent(self): ai = self.lib.get_album(self.i) for i in ai.items(): if i.id == self.i.id: break else: self.fail("item not found") def test_albuminfo_changes_affect_items(self): ai = self.lib.get_album(self.i) ai.album = "myNewAlbum" ai.store() i = self.lib.items()[0] self.assertEqual(i.album, "myNewAlbum") def test_albuminfo_change_albumartist_changes_items(self): ai = self.lib.get_album(self.i) ai.albumartist = "myNewArtist" ai.store() i = self.lib.items()[0] self.assertEqual(i.albumartist, "myNewArtist") self.assertNotEqual(i.artist, "myNewArtist") def test_albuminfo_change_artist_does_change_items(self): ai = self.lib.get_album(self.i) ai.artist = "myNewArtist" ai.store(inherit=True) i = self.lib.items()[0] self.assertEqual(i.artist, "myNewArtist") def test_albuminfo_change_artist_does_not_change_items(self): ai = self.lib.get_album(self.i) ai.artist = "myNewArtist" ai.store(inherit=False) i = self.lib.items()[0] self.assertNotEqual(i.artist, "myNewArtist") def test_albuminfo_remove_removes_items(self): item_id = self.i.id self.lib.get_album(self.i).remove() c = self.lib._connection().execute( "SELECT id FROM items WHERE id=?", (item_id,) ) self.assertEqual(c.fetchone(), None) def test_removing_last_item_removes_album(self): self.assertEqual(len(self.lib.albums()), 1) self.i.remove() self.assertEqual(len(self.lib.albums()), 0) def test_noop_albuminfo_changes_affect_items(self): i = self.lib.items()[0] i.album = "foobar" i.store() ai = self.lib.get_album(self.i) ai.album = ai.album ai.store() i = self.lib.items()[0] self.assertEqual(i.album, ai.album) class ArtDestinationTest(_common.TestCase): def setUp(self): super().setUp() config["art_filename"] = "artimage" config["replace"] = {"X": "Y"} self.lib = beets.library.Library( ":memory:", replacements=[(re.compile("X"), "Y")] ) self.i = item(self.lib) self.i.path = self.i.destination() self.ai = self.lib.add_album((self.i,)) def test_art_filename_respects_setting(self): art = self.ai.art_destination("something.jpg") new_art = bytestring_path("%sartimage.jpg" % os.path.sep) self.assertTrue(new_art in art) def test_art_path_in_item_dir(self): art = self.ai.art_destination("something.jpg") track = self.i.destination() self.assertEqual(os.path.dirname(art), os.path.dirname(track)) def test_art_path_sanitized(self): config["art_filename"] = "artXimage" art = self.ai.art_destination("something.jpg") self.assertTrue(b"artYimage" in art) class PathStringTest(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") self.i = item(self.lib) def test_item_path_is_bytestring(self): self.assertTrue(isinstance(self.i.path, bytes)) def test_fetched_item_path_is_bytestring(self): i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_unicode_path_becomes_bytestring(self): self.i.path = "unicodepath" self.assertTrue(isinstance(self.i.path, bytes)) def test_unicode_in_database_becomes_bytestring(self): self.lib._connection().execute( """ update items set path=? where id=? """, (self.i.id, "somepath"), ) i = list(self.lib.items())[0] self.assertTrue(isinstance(i.path, bytes)) def test_special_chars_preserved_in_database(self): path = "b\xe1r".encode() self.i.path = path self.i.store() i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_special_char_path_added_to_database(self): self.i.remove() path = "b\xe1r".encode() i = item() i.path = path self.lib.add(i) i = list(self.lib.items())[0] self.assertEqual(i.path, path) def test_destination_returns_bytestring(self): self.i.artist = "b\xe1r" dest = self.i.destination() self.assertTrue(isinstance(dest, bytes)) def test_art_destination_returns_bytestring(self): self.i.artist = "b\xe1r" alb = self.lib.add_album([self.i]) dest = alb.art_destination("image.jpg") self.assertTrue(isinstance(dest, bytes)) def test_artpath_stores_special_chars(self): path = b"b\xe1r" alb = self.lib.add_album([self.i]) alb.artpath = path alb.store() alb = self.lib.get_album(self.i) self.assertEqual(path, alb.artpath) def test_sanitize_path_with_special_chars(self): path = "b\xe1r?" new_path = util.sanitize_path(path) self.assertTrue(new_path.startswith("b\xe1r")) def test_sanitize_path_returns_unicode(self): path = "b\xe1r?" new_path = util.sanitize_path(path) self.assertTrue(isinstance(new_path, str)) def test_unicode_artpath_becomes_bytestring(self): alb = self.lib.add_album([self.i]) alb.artpath = "somep\xe1th" self.assertTrue(isinstance(alb.artpath, bytes)) def test_unicode_artpath_in_database_decoded(self): alb = self.lib.add_album([self.i]) self.lib._connection().execute( "update albums set artpath=? where id=?", ("somep\xe1th", alb.id) ) alb = self.lib.get_album(alb.id) self.assertTrue(isinstance(alb.artpath, bytes)) class MtimeTest(_common.TestCase): def setUp(self): super().setUp() self.ipath = os.path.join(self.temp_dir, b"testfile.mp3") shutil.copy( syspath(os.path.join(_common.RSRC, b"full.mp3")), syspath(self.ipath), ) self.i = beets.library.Item.from_path(self.ipath) self.lib = beets.library.Library(":memory:") self.lib.add(self.i) def tearDown(self): super().tearDown() if os.path.exists(self.ipath): os.remove(self.ipath) def _mtime(self): return int(os.path.getmtime(self.ipath)) def test_mtime_initially_up_to_date(self): self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_reset_on_db_modify(self): self.i.title = "something else" self.assertLess(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_write(self): self.i.title = "something else" self.i.write() self.assertGreaterEqual(self.i.mtime, self._mtime()) def test_mtime_up_to_date_after_read(self): self.i.title = "something else" self.i.read() self.assertGreaterEqual(self.i.mtime, self._mtime()) class ImportTimeTest(_common.TestCase): def setUp(self): super().setUp() self.lib = beets.library.Library(":memory:") def added(self): self.track = item() self.album = self.lib.add_album((self.track,)) self.assertGreater(self.album.added, 0) self.assertGreater(self.track.added, 0) def test_atime_for_singleton(self): self.singleton = item(self.lib) self.assertGreater(self.singleton.added, 0) class TemplateTest(_common.LibTestCase): def test_year_formatted_in_template(self): self.i.year = 123 self.i.store() self.assertEqual(self.i.evaluate_template("$year"), "0123") def test_album_flexattr_appears_in_item_template(self): self.album = self.lib.add_album([self.i]) self.album.foo = "baz" self.album.store() self.assertEqual(self.i.evaluate_template("$foo"), "baz") def test_album_and_item_format(self): config["format_album"] = "foö $foo" album = beets.library.Album() album.foo = "bar" album.tagada = "togodo" self.assertEqual(f"{album}", "foö bar") self.assertEqual(f"{album:$tagada}", "togodo") self.assertEqual(str(album), "foö bar") self.assertEqual(bytes(album), b"fo\xc3\xb6 bar") config["format_item"] = "bar $foo" item = beets.library.Item() item.foo = "bar" item.tagada = "togodo" self.assertEqual(f"{item}", "bar bar") self.assertEqual(f"{item:$tagada}", "togodo") class UnicodePathTest(_common.LibTestCase): def test_unicode_path(self): self.i.path = os.path.join(_common.RSRC, "unicode\u2019d.mp3".encode()) # If there are any problems with unicode paths, we will raise # here and fail. self.i.read() self.i.write() class WriteTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_write_nonexistant(self): item = self.create_item() item.path = b"/path/does/not/exist" with self.assertRaises(beets.library.ReadError): item.write() def test_no_write_permission(self): item = self.add_item_fixture() path = syspath(item.path) os.chmod(path, stat.S_IRUSR) try: self.assertRaises(beets.library.WriteError, item.write) finally: # Restore write permissions so the file can be cleaned up. os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) def test_write_with_custom_path(self): item = self.add_item_fixture() custom_path = os.path.join(self.temp_dir, b"custom.mp3") shutil.copy(syspath(item.path), syspath(custom_path)) item["artist"] = "new artist" self.assertNotEqual( MediaFile(syspath(custom_path)).artist, "new artist" ) self.assertNotEqual(MediaFile(syspath(item.path)).artist, "new artist") item.write(custom_path) self.assertEqual(MediaFile(syspath(custom_path)).artist, "new artist") self.assertNotEqual(MediaFile(syspath(item.path)).artist, "new artist") def test_write_custom_tags(self): item = self.add_item_fixture(artist="old artist") item.write(tags={"artist": "new artist"}) self.assertNotEqual(item.artist, "new artist") self.assertEqual(MediaFile(syspath(item.path)).artist, "new artist") def test_write_multi_tags(self): item = self.add_item_fixture(artist="old artist") item.write(tags={"artists": ["old artist", "another artist"]}) self.assertEqual( MediaFile(syspath(item.path)).artists, ["old artist", "another artist"], ) def test_write_multi_tags_id3v23(self): item = self.add_item_fixture(artist="old artist") item.write( tags={"artists": ["old artist", "another artist"]}, id3v23=True ) self.assertEqual( MediaFile(syspath(item.path)).artists, ["old artist/another artist"] ) def test_write_date_field(self): # Since `date` is not a MediaField, this should do nothing. item = self.add_item_fixture() clean_year = item.year item.date = "foo" item.write() self.assertEqual(MediaFile(syspath(item.path)).year, clean_year) class ItemReadTest(unittest.TestCase): def test_unreadable_raise_read_error(self): unreadable = os.path.join(_common.RSRC, b"image-2x3.png") item = beets.library.Item() with self.assertRaises(beets.library.ReadError) as cm: item.read(unreadable) self.assertIsInstance(cm.exception.reason, UnreadableFileError) def test_nonexistent_raise_read_error(self): item = beets.library.Item() with self.assertRaises(beets.library.ReadError): item.read("/thisfiledoesnotexist") class FilesizeTest(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets() def tearDown(self): self.teardown_beets() def test_filesize(self): item = self.add_item_fixture() self.assertNotEqual(item.filesize, 0) def test_nonexistent_file(self): item = beets.library.Item() self.assertEqual(item.filesize, 0) class ParseQueryTest(unittest.TestCase): def test_parse_invalid_query_string(self): with self.assertRaises(beets.dbcore.InvalidQueryError) as raised: beets.library.parse_query_string('foo"', None) self.assertIsInstance(raised.exception, beets.dbcore.query.ParsingError) def test_parse_bytes(self): with self.assertRaises(AssertionError): beets.library.parse_query_string(b"query", None) class LibraryFieldTypesTest(unittest.TestCase): """Test format() and parse() for library-specific field types""" def test_datetype(self): t = beets.library.DateType() # format time_format = beets.config["time_format"].as_str() time_local = time.strftime(time_format, time.localtime(123456789)) self.assertEqual(time_local, t.format(123456789)) # parse self.assertEqual(123456789.0, t.parse(time_local)) self.assertEqual(123456789.0, t.parse("123456789.0")) self.assertEqual(t.null, t.parse("not123456789.0")) self.assertEqual(t.null, t.parse("1973-11-29")) def test_pathtype(self): t = beets.library.PathType() # format self.assertEqual("/tmp", t.format("/tmp")) self.assertEqual("/tmp/\xe4lbum", t.format("/tmp/\u00e4lbum")) # parse self.assertEqual(np(b"/tmp"), t.parse("/tmp")) self.assertEqual(np(b"/tmp/\xc3\xa4lbum"), t.parse("/tmp/\u00e4lbum/")) def test_musicalkey(self): t = beets.library.MusicalKey() # parse self.assertEqual("C#m", t.parse("c#m")) self.assertEqual("Gm", t.parse("g minor")) self.assertEqual("Not c#m", t.parse("not C#m")) def test_durationtype(self): t = beets.library.DurationType() # format self.assertEqual("1:01", t.format(61.23)) self.assertEqual("60:01", t.format(3601.23)) self.assertEqual("0:00", t.format(None)) # parse self.assertEqual(61.0, t.parse("1:01")) self.assertEqual(61.23, t.parse("61.23")) self.assertEqual(3601.0, t.parse("60:01")) self.assertEqual(t.null, t.parse("1:00:01")) self.assertEqual(t.null, t.parse("not61.23")) # config format_raw_length beets.config["format_raw_length"] = True self.assertEqual(61.23, t.format(61.23)) self.assertEqual(3601.23, t.format(3601.23)) def suite(): return unittest.TestLoader().loadTestsFromName(__name__) if __name__ == "__main__": unittest.main(defaultTest="suite")