diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 34c1c42e5b..81174aabdf 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -97,6 +97,11 @@ sunique: disambiguators: year trackdisambig bracket: '[]' +tunique: + keys: title + disambiguators: track disc artist + bracket: '[]' + # --------------- Tagging --------------- per_disc_numbering: no diff --git a/beets/library/models.py b/beets/library/models.py index 7d81720142..33e11fe57e 100644 --- a/beets/library/models.py +++ b/beets/library/models.py @@ -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)] + ) + + 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, @@ -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. @@ -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: @@ -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. diff --git a/docs/changelog.rst b/docs/changelog.rst index 8316ff9a3c..cb329f0861 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 + `; the default identifiers are ``title`` and the default + disambiguators are ``track disc artist``. Bug fixes ~~~~~~~~~ diff --git a/docs/reference/config.rst b/docs/reference/config.rst index f434ba8028..e59edbcc7a 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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 diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 41fb1e9346..f02f74ddad 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -179,6 +179,19 @@ 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``. +.. _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 ` template, but the +default values are different. The default identifiers are ``title`` and the +default disambiguators are ``track disc artist``. + Syntax Details -------------- diff --git a/test/test_library.py b/test/test_library.py index 03422536cd..52f06ec9fe 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -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) + 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) + 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):