Merge pull request #4438 from jaimeMF/singleton_unique_paths

Add path template "sunique" to disambiguate between singleton tracks
This commit is contained in:
Adrian Sampson 2022-08-17 15:54:43 -07:00 committed by GitHub
commit fa81d6c568
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 219 additions and 24 deletions

View file

@ -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: []

View file

@ -1683,15 +1683,89 @@ 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()
album = self.lib.get_album(album_id)
return self._tmpl_unique(
'aunique', keys, disam, bracket, album_id, album, album.item_keys,
# Do nothing for singletons.
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
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
else:
raise NotImplementedError("sunique is only implemented for items")
if item_id is None:
return ''
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
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['aunique']['bracket'].as_str()
bracket = beets.config[name]['bracket'].as_str()
keys = keys.split()
disam = disam.split()
@ -1703,46 +1777,44 @@ class DefaultTemplateFunctions:
bracket_l = ''
bracket_r = ''
album = self.lib.get_album(album_id)
if not album:
# Do nothing for singletons.
self.lib._memotable[memokey] = ''
return ''
# Find matching albums to disambiguate with.
# Find matching items to disambiguate with.
subqueries = []
if initial_subqueries is not None:
subqueries.extend(initial_subqueries)
for key in keys:
value = album.get(key, '')
value = db_item.get(key, '')
# Use slow queries for flexible attributes.
fast = key in album.item_keys
fast = key in item_keys
subqueries.append(dbcore.MatchQuery(key, value, fast))
albums = self.lib.albums(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 album to matching these details, then do
# If there's only one item to matching these details, then do
# nothing.
if len(albums) == 1:
if len(ambigous_items) == 1:
self.lib._memotable[memokey] = ''
return ''
# Find the first disambiguator that distinguishes the albums.
# Find the first disambiguator that distinguishes the items.
for disambiguator in disam:
# Get the value for each album for the current field.
disam_values = {a.get(disambiguator, '') for a in albums}
# 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
# albums 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(albums):
if len(disam_values) == len(ambigous_items):
break
else:
# No disambiguator distinguished all fields.
res = f' {bracket_l}{album.id}{bracket_r}'
res = f' {bracket_l}{item_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)
disam_value = db_item.formatted(for_path=True).get(disambiguator)
# Return empty string if disambiguator is empty.
if disam_value:

View file

@ -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{} <sunique>` template to disambiguate between singletons.
:bug:`4438`
Bug fixes:

View file

@ -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:

View file

@ -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 <aunique>` template, but the default
values are different. The default identifiers are ``artist title`` and the
default disambiguators are ``year trackdisambig``.
Syntax Details
--------------

View file

@ -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()