diff --git a/beetsplug/stripfeat.py b/beetsplug/stripfeat.py new file mode 100644 index 0000000000..37d440978d --- /dev/null +++ b/beetsplug/stripfeat.py @@ -0,0 +1,110 @@ +# This file is part of beets. +# Copyright 2025, Austin Tinkel, +# +# 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. + +"""Splits featured artist by delimiter.""" + +import re + +from beets import plugins, ui +from beets.importer import ImportSession, ImportTask +from beets.library import Item + + +def artist_contains_feat_token(artist) -> bool: + return ( + re.search(plugins.feat_tokens(), artist, flags=re.IGNORECASE) + is not None + ) + + +def convert_feat_to_delimiter(artist: str, delimiter: str) -> str: + # split on the first "feat" + regex_result = re.compile(plugins.feat_tokens(), re.IGNORECASE) + regex_groups = regex_result.split(artist) + split_artist = regex_groups[0].strip() + delimiter + regex_groups[1].strip() + + return split_artist + + +class StripFeatPlugin(plugins.BeetsPlugin): + def __init__(self) -> None: + super().__init__() + + self.config.add( + {"auto": True, "delimiter": ";", "strip_from_album_artist": False} + ) + + self._command = ui.Subcommand( + "stripfeat", help="convert feat. in artist name to delimiter" + ) + + self._command.parser.add_option( + "-a", + "--albumartist", + dest="strip_from_album_artist", + action="store_true", + default=None, + help="convert feat. in albumartist name to delimiter as well", + ) + + if self.config["auto"]: + self.import_stages = [self.imported] + + def commands(self) -> list[ui.Subcommand]: + def func(lib, opts, args): + self.config.set_args(opts) + delimiter = self.config["delimiter"].as_str() + strip_from_album_artist = self.config[ + "strip_from_album_artist" + ].get(bool) + write = ui.should_write() + + for item in lib.items(args): + if self.strip_feat(item, delimiter, strip_from_album_artist): + item.store() + if write: + item.try_write() + + self._command.func = func + return [self._command] + + def imported(self, session: ImportSession, task: ImportTask) -> None: + strip_from_album_artist = self.config["strip_from_album_artist"].get( + bool + ) + delimiter = self.config["delimiter"].as_str() + + for item in task.imported_items(): + if self.strip_feat(item, delimiter, strip_from_album_artist): + item.store() + + def strip_feat( + self, item: Item, delimiter: str, strip_from_album_artist: bool + ) -> bool: + artist = item.artist.strip() + + if not artist_contains_feat_token(artist): + self._log.info("no featuring artist in artist") + return False + + if strip_from_album_artist: + albumartist = item.albumartist.strip() + if not artist_contains_feat_token(albumartist): + self._log.info("no featuring artist in albumartist") + item.albumartist = convert_feat_to_delimiter(albumartist, delimiter) + self._log.info("Changed " + albumartist + " to " + item.albumartist) + + item.artist = convert_feat_to_delimiter(artist, delimiter) + self._log.info("Changed " + artist + " to " + item.artist) + return True diff --git a/docs/changelog.rst b/docs/changelog.rst index 2c51470179..4bce0bc74e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,7 @@ New features: ``played_ratio_threshold``, to allow configuring the percentage the song must be played for it to be counted as played instead of skipped. - :doc:`plugins/web`: Display artist and album as part of the search results. +- :doc:`plugins/stripfeat`: Add new plugin. Bug fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1dfa3aae20..681b8954c8 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -132,6 +132,7 @@ following to your configuration: smartplaylist sonosupdate spotify + stripfeat subsonicplaylist subsonicupdate substitute @@ -240,6 +241,9 @@ Metadata :doc:`scrub ` Clean extraneous metadata from music files. +:doc:`stripfeat` + Replace featured artists token with delimiter. + :doc:`zero ` Nullify fields by pattern or unconditionally. diff --git a/docs/plugins/stripfeat.rst b/docs/plugins/stripfeat.rst new file mode 100644 index 0000000000..7593a40886 --- /dev/null +++ b/docs/plugins/stripfeat.rst @@ -0,0 +1,36 @@ +StripFeat Plugin +================ + +The ``stripfeat`` plugin automatically removes the "featured artist" token from +the ``artist`` or ``albumartist`` field and replaces it with a delimiter of your +choice. + +To use the ``stripfeat`` plugin, enable it in your configuration (see +:ref:`using-plugins`). + +Configuration +------------- + +To configure the plugin, make a ``stripfeat:`` section in your configuration +file. The available options are: + +- **auto**: Enable metadata rewriting during import. Default: ``yes``. +- **delimiter**: Defines the delimiter you want to replace the "featured artist" + token with. Default: ``;``. +- **strip_from_album_artist**: Also replace "featured artist" token in the + ``albumartist`` field. Default: ``no``. + +Running Manually +---------------- + +From the command line, type: + +:: + + $ beet stripfeat [QUERY] + +The query is optional; if it's left off, the transformation will be applied to +your entire collection. + +Use the ``-a`` flag to also apply to the ``albumartist`` field (equivalent of +the ``strip_from_album_artist`` config option). diff --git a/test/plugins/test_stripfeat.py b/test/plugins/test_stripfeat.py new file mode 100644 index 0000000000..6f77655bab --- /dev/null +++ b/test/plugins/test_stripfeat.py @@ -0,0 +1,94 @@ +# This file is part of beets. +# Copyright 2025, Austin Tinkel. +# +# 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 for the 'stripfeat' plugin.""" + +from beets.test.helper import PluginTestCase + + +class TestStripFeatPlugin(PluginTestCase): + plugin = "stripfeat" + + def _add_item(self, path, artist, title, album_artist): + return self.add_item( + path=path, + artist=artist, + artist_sort=artist, + title=title, + albumartist=album_artist, + ) + + def _set_config( + self, + delimiter=";", + strip_from_album_artist=False, + auto=True, + ): + self.config["stripfeat"]["delimiter"] = delimiter + self.config["stripfeat"]["strip_from_album_artist"] = ( + strip_from_album_artist + ) + self.config["stripfeat"]["auto"] = auto + + def test_strip_feature(self): + item = self._add_item( + "/", "Bob Ross feat. Steve Ross", "Happy Little Trees", "Bob Ross" + ) + self.run_command("stripfeat") + item.load() + assert item["artist"] == "Bob Ross;Steve Ross" + assert item["albumartist"] == "Bob Ross" + assert item["title"] == "Happy Little Trees" + + def test_strip_from_album_artist(self): + item = self._add_item( + "/", + "Bob Ross feat. Steve Ross", + "Happy Little Trees", + "Bob Ross feat. Steve Ross", + ) + self.run_command("stripfeat", "-a") + item.load() + assert item["artist"] == "Bob Ross;Steve Ross" + assert item["albumartist"] == "Bob Ross;Steve Ross" + assert item["title"] == "Happy Little Trees" + + def test_no_feature(self): + item = self._add_item("/", "Bob Ross", "Happy Little Trees", "Bob Ross") + self.run_command("stripfeat") + item.load() + assert item["artist"] == "Bob Ross" + assert item["albumartist"] == "Bob Ross" + assert item["title"] == "Happy Little Trees" + + def test_custom_delimiter(self): + self._set_config(delimiter=" and ") + item = self._add_item( + "/", "Bob Ross feat. Steve Ross", "Happy Little Trees", "Bob Ross" + ) + self.run_command("stripfeat") + item.load() + assert item["artist"] == "Bob Ross and Steve Ross" + assert item["albumartist"] == "Bob Ross" + assert item["title"] == "Happy Little Trees" + + self._set_config(delimiter=",") + item = self._add_item( + "/", "Bob Ross feat. Steve Ross", "Happy Little Trees", "Bob Ross" + ) + self.run_command("stripfeat") + item.load() + assert item["artist"] == "Bob Ross,Steve Ross" + assert item["albumartist"] == "Bob Ross" + assert item["title"] == "Happy Little Trees"