diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index f86ac7bc19..781aa77ca1 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -103,15 +103,27 @@ def __init__(self): "canonical": False, "source": "album", "force": True, + "keep_existing": True, "auto": True, "separator": ", ", "prefer_specific": False, "title_case": True, } ) - + self.config_validation() self.setup() + def config_validation(self): + """Quits plugin when invalid configurations are detected.""" + keep_existing = self.config["keep_existing"].get() + force = self.config["force"].get() + + if keep_existing and not force: + raise ui.UserError( + "Invalid lastgenre plugin configuration (enable force with " + "keep_existing!)" + ) + def setup(self): """Setup plugin from config options""" if self.config["auto"]: @@ -165,6 +177,20 @@ def sources(self): elif source == "artist": return ("artist",) + # More canonicalization and general helpers. + + def _to_delimited_genre_string(self, tags): + """Reduce tags list to configured count, format and return as delimited + string.""" + separator = self.config["separator"].as_str() + count = self.config["count"].get(int) + + genre_string = separator.join( + self._format_tag(tag) for tag in tags[: min(count, len(tags))] + ) + + return genre_string + def _get_depth(self, tag): """Find the depth of a tag in the genres tree.""" depth = None @@ -184,9 +210,7 @@ def _sort_by_depth(self, tags): return [p[1] for p in depth_tag_pairs] def _resolve_genres(self, tags): - """Given a list of strings, return a genre by joining them into a - single string and (optionally) canonicalizing each. - """ + """Given a list of genre strings, filters, sorts and canonicalizes.""" if not tags: return None @@ -224,13 +248,9 @@ def _resolve_genres(self, tags): # c14n only adds allowed genres but we may have had forbidden genres in # the original tags list - tags = [self._format_tag(x) for x in tags if self._is_allowed(x)] + tags = [x for x in tags if self._is_allowed(x)] - return ( - self.config["separator"] - .as_str() - .join(tags[: self.config["count"].get(int)]) - ) + return tags def _format_tag(self, tag): if self.config["title_case"]: @@ -242,19 +262,21 @@ def fetch_genre(self, lastfm_obj): can be found. Ex. 'Electronic, House, Dance' """ min_weight = self.config["min_weight"].get(int) - return self._resolve_genres(self._tags_for(lastfm_obj, min_weight)) + fetched = self._tags_for(lastfm_obj, min_weight) + return fetched def _is_allowed(self, genre): - """Determine whether the genre is present in the whitelist, - returning a boolean. - """ + """Returns True if genre in whitelist or whitelist disabled.""" + allowed = False if genre is None: - return False - if not self.whitelist or genre in self.whitelist: - return True - return False + allowed = False + elif not self.whitelist: + allowed = True + elif genre.lower() in self.whitelist: + allowed = True + return allowed - # Cached entity lookups. + # Cached last.fm entity lookups. def _last_lookup(self, entity, method, *args): """Get a genre based on the named entity using the callable `method` @@ -270,7 +292,7 @@ def _last_lookup(self, entity, method, *args): key = "{}.{}".format(entity, "-".join(str(a) for a in args)) if key in self._genre_cache: - return self._genre_cache[key] + result = self._genre_cache[key] else: args_replaced = [] for arg in args: @@ -280,7 +302,8 @@ def _last_lookup(self, entity, method, *args): genre = self.fetch_genre(method(*args_replaced)) self._genre_cache[key] = genre - return genre + result = genre + return result def fetch_album_genre(self, obj): """Return the album genre for this Item or Album.""" @@ -302,42 +325,119 @@ def fetch_track_genre(self, obj): "track", LASTFM.get_track, obj.artist, obj.title ) + # Main processing: _get_genre() and helpers. + + def _get_existing_genres(self, obj, separator): + """Return a list of genres for this Item or Album.""" + if isinstance(obj, library.Item): + item_genre = obj.get("genre", with_album=False).split(separator) + else: + item_genre = obj.get("genre").split(separator) + + if any(item_genre): + return item_genre + return [] + + def _dedup_genres(self, genres, whitelist_only=False): + """Return a list of deduplicated genres. Depending on the + whitelist_only option, gives filtered or unfiltered results. + Makes sure genres are handled all lower case.""" + if whitelist_only: + return deduplicate( + [g.lower() for g in genres if self._is_allowed(g)] + ) + return deduplicate([g.lower() for g in genres]) + + def _combine_and_label_genres( + self, new_genres: list, keep_genres: list, log_label: str + ) -> tuple: + """Combines genres and returns them with a logging label. + + Parameters: + new_genres (list): The new genre result to process. + keep_genres (list): Existing genres to combine with new ones + log_label (str): A label (like "track", "album") we possibly + combine with a prefix. For example resulting in something like + "keep + track" or just "track". + + Returns: + tuple: A tuple containing the combined genre string and the + 'logging label'. + """ + self._log.debug(f"fetched last.fm tags: {new_genres}") + combined = deduplicate(keep_genres + new_genres) + resolved = self._resolve_genres(combined) + reduced = self._to_delimited_genre_string(resolved) + + if new_genres and keep_genres: + return reduced, f"keep + {log_label}" + if new_genres: + return reduced, log_label + return None, log_label + def _get_genre(self, obj): - """Get the genre string for an Album or Item object based on - self.sources. Return a `(genre, source)` pair. The - prioritization order is: + """Get the final genre string for an Album or Item object + + `self.sources` specifies allowed genre sources, prioritized as follows: - track (for Items only) - album - artist - original - fallback - None + + Parameters: + obj: Either an Album or Item object. + + Returns: + tuple: A `(genre, label)` pair, where `label` is a string used for + logging that describes the result. For example, "keep + artist" + indicates that existing genres were combined with new last.fm + genres, while "artist" means only new last.fm genres are + included. """ - # Shortcut to existing genre if not forcing. - if not self.config["force"] and self._is_allowed(obj.genre): - return obj.genre, "keep" + separator = self.config["separator"].get() + keep_genres = [] + + genres = self._get_existing_genres(obj, separator) + if genres and not self.config["force"]: + # Without force we don't touch pre-populated tags and return early + # with the original contents. We format back to string tough. + keep_genres = self._dedup_genres(genres) + return separator.join(keep_genres), "keep" + + if self.config["force"]: + # Simply forcing doesn't keep any. + keep_genres = [] + # keep_existing remembers, according to the whitelist setting, + # any or just allowed genres. + if self.config["keep_existing"] and self.config["whitelist"]: + keep_genres = self._dedup_genres(genres, whitelist_only=True) + elif self.config["keep_existing"]: + keep_genres = self._dedup_genres(genres) # Track genre (for Items only). - if isinstance(obj, library.Item): - if "track" in self.sources: - result = self.fetch_track_genre(obj) - if result: - return result, "track" + if isinstance(obj, library.Item) and "track" in self.sources: + if new_genres := self.fetch_track_genre(obj): + return self._combine_and_label_genres( + new_genres, keep_genres, "track" + ) # Album genre. if "album" in self.sources: - result = self.fetch_album_genre(obj) - if result: - return result, "album" + if new_genres := self.fetch_album_genre(obj): + return self._combine_and_label_genres( + new_genres, keep_genres, "album" + ) # Artist (or album artist) genre. if "artist" in self.sources: - result = None + new_genres = None if isinstance(obj, library.Item): - result = self.fetch_artist_genre(obj) + new_genres = self.fetch_artist_genre(obj) elif obj.albumartist != config["va_name"].as_str(): - result = self.fetch_album_artist_genre(obj) + new_genres = self.fetch_album_artist_genre(obj) else: # For "Various Artists", pick the most popular track genre. item_genres = [] @@ -348,26 +448,34 @@ def _get_genre(self, obj): if not item_genre: item_genre = self.fetch_artist_genre(item) if item_genre: - item_genres.append(item_genre) + item_genres += item_genre if item_genres: - result, _ = plurality(item_genres) + most_popular, rank = plurality(item_genres) + new_genres = [most_popular] + self._log.debug( + 'Most popular track genre "{}" ({}) for VA album.', + most_popular, + rank, + ) - if result: - return result, "artist" + if new_genres: + return self._combine_and_label_genres( + new_genres, keep_genres, "artist" + ) - # Filter the existing genre. + # Didn't find anything, leave original if obj.genre: - result = self._resolve_genres([obj.genre]) - if result: - return result, "original" + return obj.genre, "original fallback" - # Fallback string. - fallback = self.config["fallback"].get() - if fallback: + # No original, return fallback string + if fallback := self.config["fallback"].get(): return fallback, "fallback" + # No fallback configured return None, None + # Beets plugin hooks and CLI. + def commands(self): lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres") lastgenre_cmd.parser.add_option( @@ -377,6 +485,20 @@ def commands(self): action="store_true", help="re-download genre when already present", ) + lastgenre_cmd.parser.add_option( + "-k", + "--keep-existing", + dest="keep_existing", + action="store_true", + help="keep already present genres", + ) + lastgenre_cmd.parser.add_option( + "-K", + "--keep-none", + dest="keep_existing", + action="store_false", + help="don't keep already present genres", + ) lastgenre_cmd.parser.add_option( "-s", "--source", @@ -409,9 +531,14 @@ def lastgenre_func(lib, opts, args): for album in lib.albums(ui.decargs(args)): album.genre, src = self._get_genre(album) self._log.info( - "genre for album {0} ({1}): {0.genre}", album, src + 'genre for album "{0.album}" ({1}): {0.genre}', + album, + src, ) - album.store() + if "track" in self.sources: + album.store(inherit=False) + else: + album.store() for item in album.items(): # If we're using track-level sources, also look up each @@ -420,7 +547,7 @@ def lastgenre_func(lib, opts, args): item.genre, src = self._get_genre(item) item.store() self._log.info( - "genre for track {0} ({1}): {0.genre}", + 'genre for track "{0.title}" ({1}): {0.genre}', item, src, ) @@ -432,10 +559,10 @@ def lastgenre_func(lib, opts, args): # an album for item in lib.items(ui.decargs(args)): item.genre, src = self._get_genre(item) - self._log.debug( - "added last.fm item genre ({0}): {1}", src, item.genre - ) item.store() + self._log.info( + "genre for track {0.title} ({1}): {0.genre}", item, src + ) lastgenre_cmd.func = lastgenre_func return [lastgenre_cmd] @@ -446,23 +573,32 @@ def imported(self, session, task): album = task.album album.genre, src = self._get_genre(album) self._log.debug( - "added last.fm album genre ({0}): {1}", src, album.genre + 'genre for album "{0.album}" ({1}): {0.genre}', album, src ) - album.store() + # If we're using track-level sources, store the album genre only, + # then also look up individual track genres. if "track" in self.sources: + album.store(inherit=False) for item in album.items(): item.genre, src = self._get_genre(item) self._log.debug( - "added last.fm item genre ({0}): {1}", src, item.genre + 'genre for track "{0.title}" ({1}): {0.genre}', + item, + src, ) item.store() + # Store the album genre and inherit to tracks. + else: + album.store() else: item = task.item item.genre, src = self._get_genre(item) self._log.debug( - "added last.fm item genre ({0}): {1}", src, item.genre + 'genre for track "{0.title}" ({1}): {0.genre}', + item, + src, ) item.store() diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 9ba2d4ebaf..3de7a1a378 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -111,6 +111,26 @@ Last.fm returns both of those tags, lastgenre is going to use the most popular, which is often the most generic (in this case ``folk``). By setting ``prefer_specific`` to true, lastgenre would use ``americana`` instead. +Handling pre-populated tags +^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The ``force``, ``keep_existing`` and ``whitelist`` options control how +pre-existing genres are handled. + +By default, the plugin *combines* newly fetched last.fm genres with whitelisted +pre-existing ones (``force: yes`` and ``keep_existing: yes``). + +To write new genres to empty tags only and keep pre-populated tags untouched, +set ``force: no``. + +To *overwrite* any content of pre-populated tags, set ``force: yes`` and +``keep_existing: no``. + +To *combine* newly fetched last.fm genres with any pre-existing ones, set +``force: yes``, ``keep_existing: yes`` and ``whitelist: False``. + +Combining ``force: no`` and ``keep_existing: yes`` is invalid +(since _"not forcing"_ means _not touching_ existing tags anyway). + Configuration ------------- @@ -128,9 +148,15 @@ configuration file. The available options are: - **fallback**: A string if to use a fallback genre when no genre is found. You can use the empty string ``''`` to reset the genre. Default: None. -- **force**: By default, beets will always fetch new genres, even if the files - already have one. To instead leave genres in place in when they pass the - whitelist, set the ``force`` option to ``no``. +- **force**: By default, lastgenre will fetch new genres for empty as well as + pre-populated tags. Enable the ``keep_existing`` option to combine existing + and new genres. (see `Handling pre-populated tags`_). + Default: ``no``. +- **keep_existing**: By default, genres remain in pre-populated tags. Depending + on whether or not ``whitelist`` is enabled, existing genres get "a cleanup". + Enabling ``keep_existing`` is only valid in combination with an active + ``force`` option. To ensure only fresh last.fm genres, disable this option. + (see `Handling pre-populated tags`_) Default: ``yes``. - **min_weight**: Minimum popularity factor below which genres are discarded. Default: 10. diff --git a/poetry.lock b/poetry.lock index 61413bf4e5..81d0eb2683 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1472,7 +1472,7 @@ test = ["pytest", "pytest-cov"] name = "msgpack" version = "1.1.0" description = "MessagePack serializer" -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd"}, diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 17156453ec..145b038a48 100644 --- a/test/plugins/test_lastgenre.py +++ b/test/plugins/test_lastgenre.py @@ -16,6 +16,8 @@ from unittest.mock import Mock +import pytest + from beets import config from beets.test import _common from beets.test.helper import BeetsTestCase @@ -44,46 +46,49 @@ def _setup_config( def test_default(self): """Fetch genres with whitelist and c14n deactivated""" self._setup_config() - assert self.plugin._resolve_genres(["delta blues"]) == "Delta Blues" + assert self.plugin._resolve_genres(["delta blues"]) == ["delta blues"] def test_c14n_only(self): """Default c14n tree funnels up to most common genre except for *wrong* genres that stay unchanged. """ self._setup_config(canonical=True, count=99) - assert self.plugin._resolve_genres(["delta blues"]) == "Blues" - assert self.plugin._resolve_genres(["iota blues"]) == "Iota Blues" + assert self.plugin._resolve_genres(["delta blues"]) == ["blues"] + assert self.plugin._resolve_genres(["iota blues"]) == ["iota blues"] def test_whitelist_only(self): """Default whitelist rejects *wrong* (non existing) genres.""" self._setup_config(whitelist=True) - assert self.plugin._resolve_genres(["iota blues"]) == "" + assert self.plugin._resolve_genres(["iota blues"]) == [] def test_whitelist_c14n(self): """Default whitelist and c14n both activated result in all parents genres being selected (from specific to common). """ self._setup_config(canonical=True, whitelist=True, count=99) - assert ( - self.plugin._resolve_genres(["delta blues"]) == "Delta Blues, Blues" - ) + assert self.plugin._resolve_genres(["delta blues"]) == [ + "delta blues", + "blues", + ] def test_whitelist_custom(self): """Keep only genres that are in the whitelist.""" self._setup_config(whitelist={"blues", "rock", "jazz"}, count=2) - assert self.plugin._resolve_genres(["pop", "blues"]) == "Blues" + assert self.plugin._resolve_genres(["pop", "blues"]) == ["blues"] self._setup_config(canonical="", whitelist={"rock"}) - assert self.plugin._resolve_genres(["delta blues"]) == "" + assert self.plugin._resolve_genres(["delta blues"]) == [] - def test_count(self): - """Keep the n first genres, as we expect them to be sorted from more to - less popular. + def test_to_delimited_string(self): + """Keep the n first genres, format them and return a + separator-delimited string. """ - self._setup_config(whitelist={"blues", "rock", "jazz"}, count=2) + self._setup_config(count=2) assert ( - self.plugin._resolve_genres(["jazz", "pop", "rock", "blues"]) - == "Jazz, Rock" + self.plugin._to_delimited_genre_string( + ["jazz", "pop", "rock", "blues"] + ) + == "Jazz, Pop" ) def test_count_c14n(self): @@ -93,31 +98,28 @@ def test_count_c14n(self): ) # thanks to c14n, 'blues' superseeds 'country blues' and takes the # second slot - assert ( - self.plugin._resolve_genres( - ["jazz", "pop", "country blues", "rock"] - ) - == "Jazz, Blues" - ) + assert self.plugin._resolve_genres( + ["jazz", "pop", "country blues", "rock"] + ) == ["jazz", "blues"] def test_c14n_whitelist(self): """Genres first pass through c14n and are then filtered""" self._setup_config(canonical=True, whitelist={"rock"}) - assert self.plugin._resolve_genres(["delta blues"]) == "" + assert self.plugin._resolve_genres(["delta blues"]) == [] def test_empty_string_enables_canonical(self): """For backwards compatibility, setting the `canonical` option to the empty string enables it using the default tree. """ self._setup_config(canonical="", count=99) - assert self.plugin._resolve_genres(["delta blues"]) == "Blues" + assert self.plugin._resolve_genres(["delta blues"]) == ["blues"] def test_empty_string_enables_whitelist(self): """Again for backwards compatibility, setting the `whitelist` option to the empty string enables the default set of genres. """ self._setup_config(whitelist="") - assert self.plugin._resolve_genres(["iota blues"]) == "" + assert self.plugin._resolve_genres(["iota blues"]) == [] def test_prefer_specific_loads_tree(self): """When prefer_specific is enabled but canonical is not the @@ -129,15 +131,15 @@ def test_prefer_specific_loads_tree(self): def test_prefer_specific_without_canonical(self): """Prefer_specific works without canonical.""" self._setup_config(prefer_specific=True, canonical=False, count=4) - assert ( - self.plugin._resolve_genres(["math rock", "post-rock"]) - == "Post-Rock, Math Rock" - ) + assert self.plugin._resolve_genres(["math rock", "post-rock"]) == [ + "post-rock", + "math rock", + ] def test_no_duplicate(self): """Remove duplicated genres.""" self._setup_config(count=99) - assert self.plugin._resolve_genres(["blues", "blues"]) == "Blues" + assert self.plugin._resolve_genres(["blues", "blues"]) == ["blues"] def test_tags_for(self): class MockPylastElem: @@ -163,51 +165,6 @@ def get_top_tags(self): res = plugin._tags_for(MockPylastObj(), min_weight=50) assert res == ["pop"] - def test_get_genre(self): - mock_genres = {"track": "1", "album": "2", "artist": "3"} - - def mock_fetch_track_genre(self, obj=None): - return mock_genres["track"] - - def mock_fetch_album_genre(self, obj): - return mock_genres["album"] - - def mock_fetch_artist_genre(self, obj): - return mock_genres["artist"] - - lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre - lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre - lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre - - self._setup_config(whitelist=False) - item = _common.item() - item.genre = mock_genres["track"] - - config["lastgenre"] = {"force": False} - res = self.plugin._get_genre(item) - assert res == (item.genre, "keep") - - config["lastgenre"] = {"force": True, "source": "track"} - res = self.plugin._get_genre(item) - assert res == (mock_genres["track"], "track") - - config["lastgenre"] = {"source": "album"} - res = self.plugin._get_genre(item) - assert res == (mock_genres["album"], "album") - - config["lastgenre"] = {"source": "artist"} - res = self.plugin._get_genre(item) - assert res == (mock_genres["artist"], "artist") - - mock_genres["artist"] = None - res = self.plugin._get_genre(item) - assert res == (item.genre, "original") - - config["lastgenre"] = {"fallback": "rap"} - item.genre = None - res = self.plugin._get_genre(item) - assert res == (config["lastgenre"]["fallback"].get(), "fallback") - def test_sort_by_depth(self): self._setup_config(canonical=True) # Normal case. @@ -218,3 +175,229 @@ def test_sort_by_depth(self): tags = ("electronic", "ambient", "chillout") res = self.plugin._sort_by_depth(tags) assert res == ["ambient", "electronic"] + + +@pytest.mark.parametrize( + "config_values, item_genre, mock_genres, expected_result", + [ + # 0 - force and keep whitelisted + ( + { + "force": True, + "keep_existing": True, + "source": "album", # means album or artist genre + "whitelist": True, + "canonical": False, + "prefer_specific": False, + "count": 10, + }, + "Blues", + { + "album": ["Jazz"], + }, + ("Blues, Jazz", "keep + album"), + ), + # 1 - force and keep whitelisted, unknown original + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "canonical": False, + "prefer_specific": False, + }, + "original unknown, Blues", + { + "album": ["Jazz"], + }, + ("Blues, Jazz", "keep + album"), + ), + # 2 - force and keep whitelisted on empty tag + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "canonical": False, + "prefer_specific": False, + }, + "", + { + "album": ["Jazz"], + }, + ("Jazz", "album"), + ), + # 3 force and keep, artist configured + ( + { + "force": True, + "keep_existing": True, + "source": "artist", # means artist genre, original or fallback + "whitelist": True, + "canonical": False, + "prefer_specific": False, + }, + "original unknown, Blues", + { + "album": ["Jazz"], + "artist": ["Pop"], + }, + ("Blues, Pop", "keep + artist"), + ), + # 4 - don't force, disabled whitelist + ( + { + "force": False, + "keep_existing": False, + "source": "album", + "whitelist": False, + "canonical": False, + "prefer_specific": False, + }, + "any genre", + { + "album": ["Jazz"], + }, + ("any genre", "keep"), + ), + # 5 - don't force, disabled whitelist, empty + ( + { + "force": False, + "keep_existing": False, + "source": "album", + "whitelist": False, + "canonical": False, + "prefer_specific": False, + }, + "", + { + "album": ["Jazz"], + }, + ("Jazz", "album"), + ), + # 6 - fallback to next stages until found + ( + { + "force": True, + "keep_existing": True, + "source": "track", # means track,album,artist,... + "whitelist": False, + "canonical": False, + "prefer_specific": False, + }, + "unknown genre", + { + "track": None, + "album": None, + "artist": ["Jazz"], + }, + ("Unknown Genre, Jazz", "keep + artist"), + ), + # 7 - fallback to original when nothing found + ( + { + "force": True, + "keep_existing": True, + "source": "track", + "whitelist": True, + "fallback": "fallback genre", + "canonical": False, + "prefer_specific": False, + }, + "original unknown", + { + "track": None, + "album": None, + "artist": None, + }, + ("original unknown", "original fallback"), + ), + # 8 - fallback to fallback if no original + ( + { + "force": True, + "keep_existing": True, + "source": "track", + "whitelist": True, + "fallback": "fallback genre", + "canonical": False, + "prefer_specific": False, + }, + "", + { + "track": None, + "album": None, + "artist": None, + }, + ("fallback genre", "fallback"), + ), + # 9 - null charachter as separator + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "separator": "\u0000", + "canonical": False, + "prefer_specific": False, + }, + "Blues", + { + "album": ["Jazz"], + }, + ("Blues\u0000Jazz", "keep + album"), + ), + # 10 - limit a lot of results + ( + { + "force": True, + "keep_existing": True, + "source": "album", + "whitelist": True, + "count": 5, + "canonical": False, + "prefer_specific": False, + "separator": ", ", + }, + "original unknown, Blues, Rock, Folk, Metal", + { + "album": ["Jazz", "Bebop", "Hardbop"], + }, + ("Blues, Rock, Metal, Jazz, Bebop", "keep + album"), + ), + ], +) +def test_get_genre(config_values, item_genre, mock_genres, expected_result): + """Test _get_genre with various configurations.""" + + def mock_fetch_track_genre(self, obj=None): + return mock_genres["track"] + + def mock_fetch_album_genre(self, obj): + return mock_genres["album"] + + def mock_fetch_artist_genre(self, obj): + return mock_genres["artist"] + + # Mock the last.fm fetchers. When whitelist enabled, we can assume only + # whitelisted genres get returned, the plugin's _resolve_genre method + # ensures it. + lastgenre.LastGenrePlugin.fetch_track_genre = mock_fetch_track_genre + lastgenre.LastGenrePlugin.fetch_album_genre = mock_fetch_album_genre + lastgenre.LastGenrePlugin.fetch_artist_genre = mock_fetch_artist_genre + + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() + item = _common.item() + item.genre = item_genre + + # Configure + config["lastgenre"] = config_values + + # Run + res = plugin._get_genre(item) + assert res == expected_result