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] 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 fd2dbf551e..db36ef0807 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 c8fa2b5fc2..788dab92f9 100644 --- a/beets/library.py +++ b/beets/library.py @@ -1753,6 +1753,99 @@ def tmpl_aunique(self, keys=None, disam=None, bracket=None): 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 6981b87f92..31ced7a2c0 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -805,6 +805,91 @@ def test_key_flexible_attribute(self): 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()