diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index 29600a676..d2f32fed3 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -732,6 +732,28 @@ class DiscogsPlugin(MetadataSourcePlugin): return text return DISAMBIGUATION_RE.sub("", text) + def _normalize_featured_name(self, name: str) -> str: + """Normalize a featured artist name for comparison.""" + # Reuse disambiguation stripping so "Artist (5)" and "Artist" match. + return self.strip_disambiguation(name).strip().lower() + + def _existing_featured_artists(self, artist: str) -> set[str]: + """Extract already-present featured artist names from an artist string. + + For example: + "Filteria Feat. Ukiro, Someone Else" + -> {"ukiro", "someone else"} + """ + feat_str = self.config["featured_string"].as_str() + if feat_str not in artist: + return set() + + # Split once: "Filteria Feat. Ukiro, Someone" -> + # ["Filteria ", " Ukiro, Someone"] + _, after_feat = artist.split(feat_str, 1) + raw_names = [n.strip() for n in after_feat.split(",")] + return {self._normalize_featured_name(n) for n in raw_names if n} + def get_track_info( self, track: Track, @@ -780,10 +802,29 @@ class DiscogsPlugin(MetadataSourcePlugin): featured_list, self.config["anv"]["artist_credit"] ) if featured: - artist += f" {self.config['featured_string']} {featured}" - artist_credit += ( - f" {self.config['featured_string']} {featured_credit}" - ) + feat_str = self.config["featured_string"].as_str() + + # What featured artists are *already* present in the string? + existing = self._existing_featured_artists(artist) + + # What are we trying to add now? + new = { + self._normalize_featured_name(n) + for n in featured.split(",") + if n.strip() + } + + # Only append if we'd actually introduce *new* featured names. + # This avoids "Filteria Feat. Ukiro Feat. Ukiro" and also + # fixes the ABCD/D example (ABCD feat. D + D again). + if not new.issubset(existing): + artist += f" {feat_str} {featured}" + artist_credit += f" {feat_str} {featured_credit}" + # Previous code + # artist += f" {self.config['featured_string']} {featured}" + # artist_credit += ( + # f" {self.config['featured_string']} {featured_credit}" + # ) return IntermediateTrackInfo( title=title, track_id=track_id, diff --git a/test/plugins/test_discogs.py b/test/plugins/test_discogs.py index eb65bc588..92fc5b9fb 100644 --- a/test/plugins/test_discogs.py +++ b/test/plugins/test_discogs.py @@ -601,6 +601,53 @@ def test_anv_album_artist(): }, "NEW ARTIST, VOCALIST Feat. SOLOIST, PERFORMER, MUSICIAN", ), + ( + { + "type_": "track", + "title": "Infinite Regression", + "position": "6", + "duration": "5:00", + "artists": [ + { + "name": "Filteria Feat. Ukiro", + "tracks": "", + "id": 11146, + "join": "", + } + ], + "extraartists": [ + { + "name": "Ukiro", + "id": 3, + "role": "Featuring", + }, + ], + }, + "Filteria Feat. Ukiro", + ), + ( + { + "type_": "track", + "title": "track", + "position": "1", + "duration": "5:00", + "artists": [ + { + "name": "ABCD", + "tracks": "", + "id": 11146, + } + ], + "extraartists": [ + { + "name": "D", + "id": 3, + "role": "Featuring", + } + ], + }, + "ABCD Feat. D", + ), ], ) @patch("beetsplug.discogs.DiscogsPlugin.setup", Mock()) diff --git a/test/plugins/test_plugin_mediafield.py b/test/plugins/test_plugin_mediafield.py deleted file mode 100644 index 84565b47b..000000000 --- a/test/plugins/test_plugin_mediafield.py +++ /dev/null @@ -1,127 +0,0 @@ -# This file is part of beets. -# Copyright 2016, Adrian Sampson. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -"""Tests the facility that lets plugins add custom field to MediaFile.""" - -import os -import shutil - -import mediafile -import pytest - -from beets.library import Item -from beets.plugins import BeetsPlugin -from beets.test import _common -from beets.test.helper import BeetsTestCase -from beets.util import bytestring_path, syspath - -field_extension = mediafile.MediaField( - mediafile.MP3DescStorageStyle("customtag"), - mediafile.MP4StorageStyle("----:com.apple.iTunes:customtag"), - mediafile.StorageStyle("customtag"), - mediafile.ASFStorageStyle("customtag"), -) - -list_field_extension = mediafile.ListMediaField( - mediafile.MP3ListDescStorageStyle("customlisttag"), - mediafile.MP4ListStorageStyle("----:com.apple.iTunes:customlisttag"), - mediafile.ListStorageStyle("customlisttag"), - mediafile.ASFStorageStyle("customlisttag"), -) - - -class ExtendedFieldTestMixin(BeetsTestCase): - def _mediafile_fixture(self, name, extension="mp3"): - name = bytestring_path(f"{name}.{extension}") - src = os.path.join(_common.RSRC, name) - target = os.path.join(self.temp_dir, name) - shutil.copy(syspath(src), syspath(target)) - return mediafile.MediaFile(target) - - def test_extended_field_write(self): - plugin = BeetsPlugin() - plugin.add_media_field("customtag", field_extension) - - try: - mf = self._mediafile_fixture("empty") - mf.customtag = "F#" - mf.save() - - mf = mediafile.MediaFile(mf.path) - assert mf.customtag == "F#" - - finally: - delattr(mediafile.MediaFile, "customtag") - Item._media_fields.remove("customtag") - - def test_extended_list_field_write(self): - plugin = BeetsPlugin() - plugin.add_media_field("customlisttag", list_field_extension) - - try: - mf = self._mediafile_fixture("empty") - mf.customlisttag = ["a", "b"] - mf.save() - - mf = mediafile.MediaFile(mf.path) - assert mf.customlisttag == ["a", "b"] - - finally: - delattr(mediafile.MediaFile, "customlisttag") - Item._media_fields.remove("customlisttag") - - def test_write_extended_tag_from_item(self): - plugin = BeetsPlugin() - plugin.add_media_field("customtag", field_extension) - - try: - mf = self._mediafile_fixture("empty") - assert mf.customtag is None - - item = Item(path=mf.path, customtag="Gb") - item.write() - mf = mediafile.MediaFile(mf.path) - assert mf.customtag == "Gb" - - finally: - delattr(mediafile.MediaFile, "customtag") - Item._media_fields.remove("customtag") - - def test_read_flexible_attribute_from_file(self): - plugin = BeetsPlugin() - plugin.add_media_field("customtag", field_extension) - - try: - mf = self._mediafile_fixture("empty") - mf.update({"customtag": "F#"}) - mf.save() - - item = Item.from_path(mf.path) - assert item["customtag"] == "F#" - - finally: - delattr(mediafile.MediaFile, "customtag") - Item._media_fields.remove("customtag") - - def test_invalid_descriptor(self): - with pytest.raises( - ValueError, match="must be an instance of MediaField" - ): - mediafile.MediaFile.add_field("somekey", True) - - def test_overwrite_property(self): - with pytest.raises( - ValueError, match='property "artist" already exists' - ): - mediafile.MediaFile.add_field("artist", mediafile.MediaField())