diff --git a/beetsplug/lastgenre/__init__.py b/beetsplug/lastgenre/__init__.py index 0498c4c526..33e40d859f 100644 --- a/beetsplug/lastgenre/__init__.py +++ b/beetsplug/lastgenre/__init__.py @@ -25,12 +25,14 @@ import codecs import os import traceback +from typing import Union import pylast import yaml from beets import config, library, plugins, ui -from beets.util import normpath, plurality +from beets.library import Album, Item +from beets.util import normpath, plurality, unique_list LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY) @@ -45,12 +47,6 @@ } -def deduplicate(seq): - """Remove duplicates from sequence while preserving order.""" - seen = set() - return [x for x in seq if x not in seen and not seen.add(x)] - - # Canonicalization tree processing. @@ -102,14 +98,14 @@ def __init__(self): "fallback": None, "canonical": False, "source": "album", - "force": True, + "force": False, + "keep_existing": False, "auto": True, "separator": ", ", "prefer_specific": False, "title_case": True, } ) - self.setup() def setup(self): @@ -153,17 +149,32 @@ def setup(self): flatten_tree(genres_tree, [], self.c14n_branches) @property - def sources(self): + def sources(self) -> tuple[str, ...]: """A tuple of allowed genre sources. May contain 'track', 'album', or 'artist.' """ source = self.config["source"].as_choice(("track", "album", "artist")) if source == "track": return "track", "album", "artist" - elif source == "album": + if source == "album": return "album", "artist" - elif source == "artist": + if source == "artist": return ("artist",) + return tuple() + + # More canonicalization and general helpers. + + def _to_delimited_genre_string(self, tags: list[str]) -> str: + """Reduce tags list to configured count, format and return as delimited + string.""" + separator = self.config["separator"].as_str() + max_count = self.config["count"].get(int) + + genres = tags[:max_count] + if self.config["title_case"]: + genres = [g.title() for g in genres] + + return separator.join(genres) def _get_depth(self, tag): """Find the depth of a tag in the genres tree.""" @@ -183,12 +194,10 @@ def _sort_by_depth(self, tags): depth_tag_pairs.sort(reverse=True) 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. - """ + def _resolve_genres(self, tags: list[str]) -> list[str]: + """Filter, deduplicate, sort and canonicalize the given genres.""" if not tags: - return None + return [] count = self.config["count"].get(int) if self.canonicalize: @@ -201,7 +210,7 @@ def _resolve_genres(self, tags): parents = [ x for x in find_parents(tag, self.c14n_branches) - if self._is_allowed(x) + if self._is_valid(x) ] else: parents = [find_parents(tag, self.c14n_branches)[-1]] @@ -216,7 +225,7 @@ def _resolve_genres(self, tags): break tags = tags_all - tags = deduplicate(tags) + tags = unique_list(tags) # Sort the tags by specificity. if self.config["prefer_specific"]: @@ -224,63 +233,46 @@ 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)] - - return ( - self.config["separator"] - .as_str() - .join(tags[: self.config["count"].get(int)]) - ) - - def _format_tag(self, tag): - if self.config["title_case"]: - return tag.title() - return tag + return [x for x in tags if self._is_valid(x)] def fetch_genre(self, lastfm_obj): """Return the genre for a pylast entity or None if no suitable genre 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)) + return self._tags_for(lastfm_obj, min_weight) - def _is_allowed(self, genre): - """Determine whether the genre is present in the whitelist, - returning a boolean. + def _is_valid(self, genre: str) -> bool: + """Check if the genre is valid. + + Depending on the whitelist property, valid means a genre is in the + whitelist or any genre is allowed. """ - if genre is None: - return False - if not self.whitelist or genre in self.whitelist: + if genre and (not self.whitelist or genre.lower() in self.whitelist): return True return False - # 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` whose arguments are given in the sequence `args`. The genre lookup - is cached based on the entity name and the arguments. Before the - lookup, each argument is has some Unicode characters replaced with - rough ASCII equivalents in order to return better results from the - Last.fm database. + is cached based on the entity name and the arguments. + + Before the lookup, each argument has the "-" Unicode character replaced + with its rough ASCII equivalents in order to return better results from + the Last.fm database. """ # Shortcut if we're missing metadata. if any(not s for s in args): return None - key = "{}.{}".format(entity, "-".join(str(a) for a in args)) - if key in self._genre_cache: - return self._genre_cache[key] - else: - args_replaced = [] - for arg in args: - for k, v in REPLACE.items(): - arg = arg.replace(k, v) - args_replaced.append(arg) + key = f"{entity}.{'-'.join(str(a) for a in args)}" + if key not in self._genre_cache: + args = [a.replace("\u2010", "-") for a in args] + self._genre_cache[key] = self.fetch_genre(method(*args)) - genre = self.fetch_genre(method(*args_replaced)) - self._genre_cache[key] = genre - return genre + return self._genre_cache[key] def fetch_album_genre(self, obj): """Return the album genre for this Item or Album.""" @@ -302,42 +294,86 @@ def fetch_track_genre(self, obj): "track", LASTFM.get_track, obj.artist, obj.title ) - 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: + # Main processing: _get_genre() and helpers. + + def _get_existing_genres(self, obj: Union[Album, Item]) -> list[str]: + """Return a list of genres for this Item or Album. Empty string genres + are removed.""" + separator = self.config["separator"].get() + if isinstance(obj, library.Item): + item_genre = obj.get("genre", with_album=False).split(separator) + else: + item_genre = obj.get("genre").split(separator) + + # Filter out empty strings + return [g for g in item_genre if g] + + def _combine_genres( + self, old: list[str], new: list[str] + ) -> Union[str, None]: + """Combine old and new genres.""" + self._log.debug(f"fetched last.fm tags: {new}") + combined = old + new + resolved = self._resolve_genres(combined) + return self._to_delimited_genre_string(resolved) or None + + def _get_genre( + self, obj: Union[Album, Item] + ) -> tuple[Union[str, None], ...]: + """Get the final genre string for an Album or Item object. + + `self.sources` specifies allowed genre sources. Starting with the first + source in this tuple, the following stages run through until a genre is + found or no options are left: - track (for Items only) - album - - artist - - original - - fallback + - artist, albumartist or "most popular track genre" (for VA-albums) + - original fallback + - configured fallback - None - """ - # Shortcut to existing genre if not forcing. - if not self.config["force"] and self._is_allowed(obj.genre): - return obj.genre, "keep" + A `(genre, label)` pair is returned, where `label` is a string used for + logging. For example, "keep + artist, whitelist" indicates that existing + genres were combined with new last.fm genres and whitelist filtering was + applied, while "artist, any" means only new last.fm genres are included + and the whitelist feature was disabled. + """ + keep_genres = [] + label = "" + genres = self._get_existing_genres(obj) - # 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" - - # Album genre. - if "album" in self.sources: - result = self.fetch_album_genre(obj) - if result: - return result, "album" - - # Artist (or album artist) genre. - if "artist" in self.sources: - result = None + if genres and not self.config["force"]: + # Without force pre-populated tags are returned as-is. if isinstance(obj, library.Item): - result = self.fetch_artist_genre(obj) + return obj.get("genre", with_album=False), "keep any, no-force" + return obj.get("genre"), "keep any, no-force" + + if self.config["force"]: + # Force doesn't keep any unless keep_existing is set. + # Whitelist validation is handled in _resolve_genres. + if self.config["keep_existing"]: + keep_genres = [g.lower() for g in genres] + + # Run through stages: track, album, artist, + # album artist, or most popular track genre. + if ( + isinstance(obj, library.Item) + and "track" in self.sources + and (new_genres := self.fetch_track_genre(obj)) + ): + label = "track" + elif "album" in self.sources and ( + new_genres := self.fetch_album_genre(obj) + ): + label = "album" + elif "artist" in self.sources: + new_genres = None + if isinstance(obj, library.Item): + new_genres = self.fetch_artist_genre(obj) + label = "artist" elif obj.albumartist != config["va_name"].as_str(): - result = self.fetch_album_artist_genre(obj) + new_genres = self.fetch_album_artist_genre(obj) + label = "album artist" else: # For "Various Artists", pick the most popular track genre. item_genres = [] @@ -348,26 +384,39 @@ 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] + label = "most popular track" + self._log.debug( + 'Most popular track genre "{}" ({}) for VA album.', + most_popular, + rank, + ) - if result: - return result, "artist" + # Return with a combined or freshly fetched genre list. + if new_genres: + suffix = "whitelist" if self.whitelist else "any" + label += f", {suffix}" - # Filter the existing genre. + if keep_genres: + label = f"keep + {label}" + return self._combine_genres(keep_genres, new_genres), label + + # Nothing found, 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( @@ -375,7 +424,28 @@ def commands(self): "--force", dest="force", action="store_true", - help="re-download genre when already present", + help="modify existing genres", + ) + lastgenre_cmd.parser.add_option( + "-F", + "--no-force", + dest="force", + action="store_false", + help="don't modify existing genres", + ) + lastgenre_cmd.parser.add_option( + "-k", + "--keep-existing", + dest="keep_existing", + action="store_true", + help="combine with existing genres when modifying", + ) + lastgenre_cmd.parser.add_option( + "-K", + "--no-keep-existing", + dest="keep_existing", + action="store_false", + help="don't combine with existing genres when modifying", ) lastgenre_cmd.parser.add_option( "-s", @@ -396,7 +466,7 @@ def commands(self): "--albums", action="store_true", dest="album", - help="match albums instead of items", + help="match albums instead of items (default)", ) lastgenre_cmd.parser.set_defaults(album=True) diff --git a/docs/plugins/lastgenre.rst b/docs/plugins/lastgenre.rst index 9ba2d4ebaf..48a8686fe8 100644 --- a/docs/plugins/lastgenre.rst +++ b/docs/plugins/lastgenre.rst @@ -25,7 +25,7 @@ tags can be considered genres. This way, tags like "my favorite music" or "seen live" won't be considered genres. The plugin ships with a fairly extensive `internal whitelist`_, but you can set your own in the config file using the ``whitelist`` configuration value or forgo a whitelist altogether by setting -the option to `false`. +the option to ``no``. The genre list file should contain one genre per line. Blank lines are ignored. For the curious, the default genre list is generated by a `script that scrapes @@ -111,6 +111,53 @@ 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. + +As you would assume, setting ``force: no`` **won't touch pre-existing genre +tags** and will only **fetch new genres for empty tags**. When ``force`` is +``yes`` the setting of the ``whitelist`` option (as documented in `Usage`_) +applies to any existing or newly fetched genres. + +The follwing configurations are possible: + +**Setup 1** (default) + +Add new last.fm genres when **empty**. Any present tags stay **untouched**. + +.. code-block:: yaml + + force: no + keep_existing: no + +**Setup 2** + +**Overwrite all**. Only fresh last.fm genres remain. + +.. code-block:: yaml + + force: yes + keep_existing: no + +**Setup 3** + +**Combine** genres in present tags with new ones (be aware of that with an +enabled ``whitelist`` setting, of course some genres might get cleaned up. To +make sure any existing genres remain, set ``whitelist: no``). + +.. code-block:: yaml + + force: yes + keep_existing: yes + +If ``force`` is disabled the ``keep_existing`` option is simply ignored (since ``force: +no`` means `not touching` existing tags anyway). + + + Configuration ------------- @@ -128,14 +175,22 @@ 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``. - Default: ``yes``. +- **force**: By default, lastgenre will fetch new genres for empty tags only. + Enable the ``keep_existing`` option to combine existing and new genres. (see + `Handling pre-populated tags`_). + Default: ``no``. +- **keep_existing**: This option alters the ``force`` behaviour. + If both ``force`` and ``keep_existing`` are enabled, existing genres are + combined with new ones. Depending on the ``whitelist`` setting, existing and + new genres are filtered accordingly. To ensure only fresh last.fm genres, + disable this option. (see `Handling pre-populated tags`_) + Default: ``no``. - **min_weight**: Minimum popularity factor below which genres are discarded. Default: 10. - **prefer_specific**: Sort genres by the most to least specific, rather than - most to least popular. Default: ``no``. + most to least popular. Note that this option requires a ``canonical`` tree, + and if not configured it will automatically enable and use the built-in tree. + Default: ``no``. - **source**: Which entity to look up in Last.fm. Can be either ``artist``, ``album`` or ``track``. Default: ``album``. diff --git a/test/plugins/test_lastgenre.py b/test/plugins/test_lastgenre.py index 17156453ec..345e6a4f9d 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,245 @@ 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, whitelist"), + ), + # 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, whitelist"), + ), + # 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, whitelist"), + ), + # 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, whitelist"), + ), + # 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 any, no-force"), + ), + # 5 - don't force and empty is regular last.fm fetch; no whitelist too + ( + { + "force": False, + "keep_existing": False, + "source": "album", + "whitelist": False, + "canonical": False, + "prefer_specific": False, + }, + "", + { + "album": ["Jazzin"], + }, + ("Jazzin", "album, any"), + ), + # 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, any"), + ), + # 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, whitelist"), + ), + # 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, whitelist"), + ), + # 11 - force off does not rely on configured separator + ( + { + "force": False, + "keep_existing": False, + "source": "album", + "whitelist": True, + "count": 2, + "separator": ", ", + }, + "not ; configured | separator", + { + "album": ["Jazz", "Bebop"], + }, + ("not ; configured | separator", "keep any, no-force"), + ), + ], +) +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 + + # Configure + config["lastgenre"] = config_values + + # Initialize plugin instance and item + plugin = lastgenre.LastGenrePlugin() + item = _common.item() + item.genre = item_genre + + # Run + res = plugin._get_genre(item) + assert res == expected_result