Skip to content

Commit

Permalink
Add path template "sunique" to disambiguate between singleton tracks
Browse files Browse the repository at this point in the history
  • Loading branch information
jaimeMF committed Aug 12, 2022
1 parent 6eec17c commit 8d957f3
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 0 deletions.
5 changes: 5 additions & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down
93 changes: 93 additions & 0 deletions beets/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions test/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit 8d957f3

Please sign in to comment.