Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions beets/config_default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ sunique:
disambiguators: year trackdisambig
bracket: '[]'

tunique:
keys: title
disambiguators: track disc artist
bracket: '[]'

# --------------- Tagging ---------------

per_disc_numbering: no
Expand Down
66 changes: 59 additions & 7 deletions beets/library/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,56 @@ def tmpl_sunique(
lambda i: i.album_id is not None,
)

def tmpl_tunique(
self,
keys: str | None = None,
disam: str | None = None,
bracket: str | None = None,
) -> str:
"""Generate a string that is guaranteed to be unique among all
items in the same album that share the same set of keys.

A field from "disam" is used in the string if one is sufficient to
disambiguate the tracks. 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.
"""
if not self.item or not self.lib:
return ""

if isinstance(self.item, Item):
item_id = self.item.id
else:
raise NotImplementedError("tunique is only implemented for items")

if item_id is None:
return ""

# Only applies to items in an album.
album_id = self.item.get("album_id")
if album_id is None:
return ""

resolved_keys = keys or beets.config["tunique"]["keys"].as_str()
key_fields = resolved_keys.split() or ["title"]
query = dbcore.AndQuery(
[dbcore.MatchQuery(f, self.item.get(f)) for f in key_fields]
+ [dbcore.MatchQuery("album_id", album_id)]
)
Comment thread
arsaboo marked this conversation as resolved.

return self._tmpl_unique(
"tunique",
keys,
disam,
bracket,
item_id,
self.item,
lambda i: i.album_id is None,
query=query,
)

def _tmpl_unique_memokey(
self,
name: str | None,
Expand All @@ -1488,6 +1538,7 @@ def _tmpl_unique(
item_id: int,
db_item: LibModel,
skip_item: Callable[[LibModel], bool],
query: dbcore.Query | None = None,
) -> str:
"""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.
Expand All @@ -1506,8 +1557,8 @@ def _tmpl_unique(
"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 ambiguous items.
"query" is a pre-built query to use instead of building one from
``db_item.duplicates_query(keys)``.
"""
lib = self.lib
if lib is None:
Expand Down Expand Up @@ -1538,26 +1589,27 @@ def _tmpl_unique(
bracket_r = ""

# Find matching items to disambiguate with.
query = db_item.duplicates_query(keys_list)
ambigous_items = (
if query is None:
query = db_item.duplicates_query(keys_list)
ambiguous_items = (
lib.items(query) if isinstance(db_item, Item) else lib.albums(query)
)

# If there's only one item to matching these details, then do
# nothing.
if len(ambigous_items) == 1:
if len(ambiguous_items) == 1:
lib._memotable[memokey] = ""
return ""

# Find the first disambiguator that distinguishes the items.
for disambiguator in disam_list:
# Get the value for each item for the current field.
disam_values = {s.get(disambiguator, "") for s in ambigous_items}
disam_values = {s.get(disambiguator, "") for s in ambiguous_items}

# If the set of unique values is equal to the number of
# items in the disambiguation set, we're done -- this is
# sufficient disambiguation.
if len(disam_values) == len(ambigous_items):
if len(disam_values) == len(ambiguous_items):
break
else:
# No disambiguator distinguished all fields.
Expand Down
11 changes: 8 additions & 3 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ below!
Unreleased
----------

..
New features
~~~~~~~~~~~~
New features
~~~~~~~~~~~~

- :ref:`tunique` (``%tunique{}``): New path template function to disambiguate
tracks within the same album that share the same title (e.g., identical-titled
tracks on different discs). It has the same arguments as :ref:`%aunique
<aunique>`; the default identifiers are ``title`` and the default
disambiguators are ``track disc artist``.

Bug fixes
~~~~~~~~~
Expand Down
20 changes: 20 additions & 0 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,26 @@ The defaults look like this:

See :ref:`sunique` for more details.

.. _config-tunique:

tunique
~~~~~~~

Like :ref:`config-aunique` above for albums, these options control the
generation of a unique string to disambiguate tracks that share the same title
within an album.

The defaults look like this:

::

tunique:
keys: title
disambiguators: track disc artist
bracket: '[]'

See :ref:`tunique` for more details.

.. _terminal_encoding:

terminal_encoding
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/pathformat.rst
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ 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``.

.. _tunique:

Track Disambiguation
--------------------

Some tracks on the same album might share the same title (e.g., identical-titled
tracks on different discs). Beets provides the ``%tunique{}`` template to avoid
giving these tracks 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 ``title`` and the
default disambiguators are ``track disc artist``.

Syntax Details
--------------

Expand Down
117 changes: 117 additions & 0 deletions test/test_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,123 @@ def test_key_flexible_attribute(self, items):
self._assert_dest(b"/base/foo/the title", i1)


class TestTrackDisambiguation(TestHelper, PathFormattingMixin):
@pytest.fixture(autouse=True)
def items(self, setup):
self.lib.directory = b"/base"
self.lib.path_formats = [("default", "path")]

i1 = item()
i1.title = "Common Title"
i1.track = 7
i1.disc = 1
i1.artist = "Some Artist"
i2 = item()
i2.title = "Common Title"
i2.track = 11
i2.disc = 1
i2.artist = "Some Artist"
self.lib.add_album([i1, i2])
self.lib._connection().commit()

self._setf("$title%tunique{title,track}")
return i1, i2

def test_tunique_expands_to_disambiguating_track(self, items):
i1, i2 = items
self._assert_dest(b"/base/Common Title [07]", i1)
self._assert_dest(b"/base/Common Title [11]", i2)

def test_tunique_with_default_arguments_uses_track(self, items):
i1, i2 = items
self._setf("$title%tunique{}")
self._assert_dest(b"/base/Common Title [07]", i1)
Comment thread
arsaboo marked this conversation as resolved.
self._assert_dest(b"/base/Common Title [11]", i2)

def test_tunique_expands_to_nothing_for_unique_titles(self, items):
i1, i2 = items
i2.title = "Different"
i2.store()

self._assert_dest(b"/base/Common Title", i1)

def test_tunique_does_not_match_singletons(self, items):
i1, _i2 = items
i3 = item()
i3.title = "Common Title"
self.lib.add(i3)

# i1 still needs disambiguation from i2 in the same album
self._assert_dest(b"/base/Common Title [07]", i1)
# Singleton should NOT get track disambiguation
self._assert_dest(b"/base/Common Title", i3)

def test_tunique_does_not_match_cross_album(self, items):
i1, _i2 = items
i3 = item()
i3.title = "Common Title"
i3.track = 7
self.lib.add_album([i3])

# i1 still needs disambiguation from i2 in the same album
self._assert_dest(b"/base/Common Title [07]", i1)
# Other album's tracks should NOT be matched
self._assert_dest(b"/base/Common Title", i3)

def test_tunique_use_fallback_numbers_when_identical(self, items):
i1, i2 = items
i2.track = i1.track
i2.disc = i1.disc
i2.artist = i1.artist
i2.store()

self._assert_dest(b"/base/Common Title [%d]" % i1.id, i1)
self._assert_dest(b"/base/Common Title [%d]" % i2.id, i2)

def test_tunique_falls_back_to_second_distinguishing_field(self, items):
i1, i2 = items
i2.track = i1.track
i2.disc = 2
i2.store()

self._setf("$title%tunique{title,track disc}")
self._assert_dest(b"/base/Common Title [01]", i1)
Comment thread
arsaboo marked this conversation as resolved.
self._assert_dest(b"/base/Common Title [02]", i2)

def test_tunique_expands_to_disambiguating_disc(self, items):
i1, i2 = items
i2.track = i1.track
i2.disc = 2
i2.store()

self._setf("$title%tunique{title,disc}")
self._assert_dest(b"/base/Common Title [01]", i1)
self._assert_dest(b"/base/Common Title [02]", i2)

def test_tunique_change_brackets(self, items):
i1, _i2 = items
self._setf("$title%tunique{title,track,()}")
self._assert_dest(b"/base/Common Title (07)", i1)

def test_tunique_remove_brackets(self, items):
i1, _i2 = items
self._setf("$title%tunique{title,track,}")
self._assert_dest(b"/base/Common Title 07", i1)

def test_tunique_drop_empty_disambig(self, items):
i1, i2 = items
i1.trackdisambig = "live version"
i2.trackdisambig = None
i1.store()
i2.store()

self._setf("$title%tunique{title,trackdisambig}")
# i1 has trackdisambig, so gets suffixed
self._assert_dest(b"/base/Common Title [live version]", i1)
# i2 has no trackdisambig, so no suffix
self._assert_dest(b"/base/Common Title", i2)


class TestPluginDestination(TestHelper):
@pytest.fixture(autouse=True)
def item(self, setup):
Expand Down
Loading