From 8d957f35f976d5bc22692088b51bbf5056051745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Marqui=CC=81nez=20Ferra=CC=81ndiz?= Date: Fri, 12 Aug 2022 14:19:52 +0200 Subject: [PATCH 1/3] Add path template "sunique" to disambiguate between singleton tracks --- beets/config_default.yaml | 5 +++ beets/library.py | 93 +++++++++++++++++++++++++++++++++++++++ test/test_library.py | 85 +++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index fd2dbf551..db36ef080 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -55,6 +55,11 @@ aunique: disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig bracket: '[]' +sunique: + keys: artist title + disambiguators: year trackdisambig + bracket: '[]' + overwrite_null: album: [] track: [] diff --git a/beets/library.py b/beets/library.py index c8fa2b5fc..788dab92f 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1753,6 +1753,99 @@ class DefaultTemplateFunctions: self.lib._memotable[memokey] = res return res + def tmpl_sunique(self, keys=None, disam=None, bracket=None): + """Generate a string that is guaranteed to be unique among all + singletons in the library who share the same set of keys. + + A fields from "disam" is used in the string if one is sufficient to + disambiguate the albums. Otherwise, a fallback opaque value is + used. Both "keys" and "disam" should be given as + whitespace-separated lists of field names, while "bracket" is a + pair of characters to be used as brackets surrounding the + disambiguator or empty to have no brackets. + """ + # Fast paths: no album, no item or library, or memoized value. + if not self.item or not self.lib: + return '' + + if isinstance(self.item, Item): + item_id = self.item.id + album_id = self.item.album_id + else: + raise NotImplementedError("sunique is only implemented for items") + + if item_id is None: + return '' + + memokey = ('sunique', keys, disam, item_id) + memoval = self.lib._memotable.get(memokey) + if memoval is not None: + return memoval + + keys = keys or beets.config['sunique']['keys'].as_str() + disam = disam or beets.config['sunique']['disambiguators'].as_str() + if bracket is None: + bracket = beets.config['sunique']['bracket'].as_str() + keys = keys.split() + disam = disam.split() + + # Assign a left and right bracket or leave blank if argument is empty. + if len(bracket) == 2: + bracket_l = bracket[0] + bracket_r = bracket[1] + else: + bracket_l = '' + bracket_r = '' + + if album_id is not None: + # Do nothing for non singletons. + self.lib._memotable[memokey] = '' + return '' + + # Find matching singletons to disambiguate with. + subqueries = [dbcore.query.NoneQuery('album_id', True)] + item_keys = Item.all_keys() + for key in keys: + value = self.item.get(key, '') + # Use slow queries for flexible attributes. + fast = key in item_keys + subqueries.append(dbcore.MatchQuery(key, value, fast)) + singletons = self.lib.items(dbcore.AndQuery(subqueries)) + + # If there's only one singleton to matching these details, then do + # nothing. + if len(singletons) == 1: + self.lib._memotable[memokey] = '' + return '' + + # Find the first disambiguator that distinguishes the singletons. + for disambiguator in disam: + # Get the value for each singleton for the current field. + disam_values = {s.get(disambiguator, '') for s in singletons} + + # If the set of unique values is equal to the number of + # singletons in the disambiguation set, we're done -- this is + # sufficient disambiguation. + if len(disam_values) == len(singletons): + break + else: + # No disambiguator distinguished all fields. + res = f' {bracket_l}{item_id}{bracket_r}' + self.lib._memotable[memokey] = res + return res + + # Flatten disambiguation value into a string. + disam_value = self.item.formatted(for_path=True).get(disambiguator) + + # Return empty string if disambiguator is empty. + if disam_value: + res = f' {bracket_l}{disam_value}{bracket_r}' + else: + res = '' + + self.lib._memotable[memokey] = res + return res + @staticmethod def tmpl_first(s, count=1, skip=0, sep='; ', join_str='; '): """Get the item(s) from x to y in a string separated by something diff --git a/test/test_library.py b/test/test_library.py index 6981b87f9..31ced7a2c 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -805,6 +805,91 @@ class DisambiguationTest(_common.TestCase, PathFormattingMixin): 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() From f641df0748bcc83da5f224c95e523b8bbd261c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Marqui=CC=81nez=20Ferra=CC=81ndiz?= Date: Tue, 16 Aug 2022 17:54:12 +0200 Subject: [PATCH 2/3] Encapsulate common code for the aunique and sunique templates in a single method --- beets/library.py | 151 ++++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 86 deletions(-) diff --git a/beets/library.py b/beets/library.py index 788dab92f..3b8a85685 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1683,75 +1683,17 @@ class DefaultTemplateFunctions: if album_id is None: return '' - memokey = ('aunique', keys, disam, album_id) + memokey = self._tmpl_unique_memokey('aunique', keys, disam, album_id) memoval = self.lib._memotable.get(memokey) if memoval is not None: return memoval - keys = keys or beets.config['aunique']['keys'].as_str() - disam = disam or beets.config['aunique']['disambiguators'].as_str() - if bracket is None: - bracket = beets.config['aunique']['bracket'].as_str() - keys = keys.split() - disam = disam.split() - - # Assign a left and right bracket or leave blank if argument is empty. - if len(bracket) == 2: - bracket_l = bracket[0] - bracket_r = bracket[1] - else: - bracket_l = '' - bracket_r = '' - album = self.lib.get_album(album_id) - if not album: + + return self._tmpl_unique( + 'aunique', keys, disam, bracket, album_id, album, album.item_keys, # Do nothing for singletons. - self.lib._memotable[memokey] = '' - return '' - - # Find matching albums to disambiguate with. - subqueries = [] - for key in keys: - value = album.get(key, '') - # Use slow queries for flexible attributes. - fast = key in album.item_keys - subqueries.append(dbcore.MatchQuery(key, value, fast)) - albums = self.lib.albums(dbcore.AndQuery(subqueries)) - - # If there's only one album to matching these details, then do - # nothing. - if len(albums) == 1: - self.lib._memotable[memokey] = '' - return '' - - # Find the first disambiguator that distinguishes the albums. - for disambiguator in disam: - # Get the value for each album for the current field. - disam_values = {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 - # sufficient disambiguation. - if len(disam_values) == len(albums): - break - - else: - # No disambiguator distinguished all fields. - res = f' {bracket_l}{album.id}{bracket_r}' - self.lib._memotable[memokey] = res - return res - - # Flatten disambiguation value into a string. - disam_value = album.formatted(for_path=True).get(disambiguator) - - # Return empty string if disambiguator is empty. - if disam_value: - res = f' {bracket_l}{disam_value}{bracket_r}' - else: - res = '' - - self.lib._memotable[memokey] = res - return res + lambda a: a is None) def tmpl_sunique(self, keys=None, disam=None, bracket=None): """Generate a string that is guaranteed to be unique among all @@ -1770,22 +1712,60 @@ class DefaultTemplateFunctions: if isinstance(self.item, Item): item_id = self.item.id - album_id = self.item.album_id else: raise NotImplementedError("sunique is only implemented for items") if item_id is None: return '' - memokey = ('sunique', keys, disam, item_id) + return self._tmpl_unique( + 'sunique', keys, disam, bracket, item_id, self.item, + Item.all_keys(), + # Do nothing for non singletons. + lambda i: i.album_id is not None, + initial_subqueries=[dbcore.query.NoneQuery('album_id', True)]) + + def _tmpl_unique_memokey(self, name, keys, disam, item_id): + """Get the memokey for the unique template named "name" for the + specific parameters. + """ + return (name, keys, disam, item_id) + + def _tmpl_unique(self, name, keys, disam, bracket, item_id, db_item, + item_keys, skip_item, initial_subqueries=None): + """Generate a string that is guaranteed to be unique among all items of + the same type as "db_item" who share the same set of keys. + + A field from "disam" is used in the string if one is sufficient to + disambiguate the items. Otherwise, a fallback opaque value is + used. Both "keys" and "disam" should be given as + whitespace-separated lists of field names, while "bracket" is a + pair of characters to be used as brackets surrounding the + disambiguator or empty to have no brackets. + + "name" is the name of the templates. It is also the name of the + configuration section where the default values of the parameters + are stored. + + "skip_item" is a function that must return True when the template + should return an empty string. + + "initial_subqueries" is a list of subqueries that should be included + in the query to find the ambigous items. + """ + memokey = self._tmpl_unique_memokey(name, keys, disam, item_id) memoval = self.lib._memotable.get(memokey) if memoval is not None: return memoval - keys = keys or beets.config['sunique']['keys'].as_str() - disam = disam or beets.config['sunique']['disambiguators'].as_str() + if skip_item(db_item): + self.lib._memotable[memokey] = '' + return '' + + keys = keys or beets.config[name]['keys'].as_str() + disam = disam or beets.config[name]['disambiguators'].as_str() if bracket is None: - bracket = beets.config['sunique']['bracket'].as_str() + bracket = beets.config[name]['bracket'].as_str() keys = keys.split() disam = disam.split() @@ -1797,36 +1777,35 @@ class DefaultTemplateFunctions: bracket_l = '' bracket_r = '' - if album_id is not None: - # Do nothing for non singletons. - self.lib._memotable[memokey] = '' - return '' - - # Find matching singletons to disambiguate with. - subqueries = [dbcore.query.NoneQuery('album_id', True)] - item_keys = Item.all_keys() + # Find matching items to disambiguate with. + subqueries = [] + if initial_subqueries is not None: + subqueries.extend(initial_subqueries) for key in keys: - value = self.item.get(key, '') + value = db_item.get(key, '') # Use slow queries for flexible attributes. fast = key in item_keys subqueries.append(dbcore.MatchQuery(key, value, fast)) - singletons = self.lib.items(dbcore.AndQuery(subqueries)) + query = dbcore.AndQuery(subqueries) + ambigous_items = (self.lib.items(query) + if isinstance(db_item, Item) + else self.lib.albums(query)) - # If there's only one singleton to matching these details, then do + # If there's only one item to matching these details, then do # nothing. - if len(singletons) == 1: + if len(ambigous_items) == 1: self.lib._memotable[memokey] = '' return '' - # Find the first disambiguator that distinguishes the singletons. + # Find the first disambiguator that distinguishes the items. for disambiguator in disam: - # Get the value for each singleton for the current field. - disam_values = {s.get(disambiguator, '') for s in singletons} + # Get the value for each item for the current field. + disam_values = {s.get(disambiguator, '') for s in ambigous_items} # If the set of unique values is equal to the number of - # singletons in the disambiguation set, we're done -- this is + # items in the disambiguation set, we're done -- this is # sufficient disambiguation. - if len(disam_values) == len(singletons): + if len(disam_values) == len(ambigous_items): break else: # No disambiguator distinguished all fields. @@ -1835,7 +1814,7 @@ class DefaultTemplateFunctions: return res # Flatten disambiguation value into a string. - disam_value = self.item.formatted(for_path=True).get(disambiguator) + disam_value = db_item.formatted(for_path=True).get(disambiguator) # Return empty string if disambiguator is empty. if disam_value: From 6aa9804c24400dd654b88cdb1bf687f652bca581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Marqui=CC=81nez=20Ferra=CC=81ndiz?= Date: Wed, 17 Aug 2022 17:03:16 +0200 Subject: [PATCH 3/3] Document the %sunique template --- docs/changelog.rst | 2 ++ docs/reference/config.rst | 17 +++++++++++++++++ docs/reference/pathformat.rst | 14 ++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 31861af24..d21a55d37 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -35,6 +35,8 @@ New features: * :ref:`import-options`: Add support for re-running the importer on paths in log files that were created with the ``-l`` (or ``--logfile``) argument. :bug:`4379` :bug:`4387` +* Add :ref:`%sunique{} ` template to disambiguate between singletons. + :bug:`4438` Bug fixes: diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 6e7df1b59..58656256f 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -326,6 +326,23 @@ The defaults look like this:: See :ref:`aunique` for more details. +.. _config-sunique: + +sunique +~~~~~~~ + +These options are used to generate a string that is guaranteed to be unique +among all singletons in the library who share the same set of keys. + +The defaults look like this:: + + sunique: + keys: artist title + disambiguators: year trackdisambig + bracket: '[]' + +See :ref:`sunique` for more details. + .. _terminal_encoding: diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index f6f2e06cc..b52c2b32a 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -73,6 +73,8 @@ These functions are built in to beets: option. * ``%aunique{identifiers,disambiguators,brackets}``: Provides a unique string to disambiguate similar albums in the database. See :ref:`aunique`, below. +* ``%sunique{identifiers,disambiguators,brackets}``: Provides a unique string + to disambiguate similar singletons in the database. See :ref:`sunique`, below. * ``%time{date_time,format}``: Return the date and time in any format accepted by `strftime`_. For example, to get the year some music was added to your library, use ``%time{$added,%Y}``. @@ -145,6 +147,18 @@ its import time. Only the second album will receive a disambiguation string. If you want to add the disambiguation string to both albums, just run ``beet move`` (possibly restricted by a query) to update the paths for the albums. +.. _sunique: + +Singleton Disambiguation +------------------------ + +It is also possible to have singleton tracks with the same name and the same +artist. Beets provides the ``%sunique{}`` template to avoid having the same +file path. + +It has the same arguments as the :ref:`%aunique ` template, but the default +values are different. The default identifiers are ``artist title`` and the +default disambiguators are ``year trackdisambig``. Syntax Details --------------