mirror of
https://github.com/beetbox/beets.git
synced 2026-01-15 20:51:38 +01:00
merge with master... again!
This commit is contained in:
commit
e9c3dd1c5c
3 changed files with 85 additions and 47 deletions
|
|
@ -28,7 +28,7 @@ import os
|
|||
import traceback
|
||||
from functools import singledispatchmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
import pylast
|
||||
import yaml
|
||||
|
|
@ -38,6 +38,8 @@ from beets.library import Album, Item
|
|||
from beets.util import plurality, unique_list
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import optparse
|
||||
|
||||
from beets.library import LibModel
|
||||
|
||||
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
|
||||
|
|
@ -52,7 +54,11 @@ PYLAST_EXCEPTIONS = (
|
|||
# Canonicalization tree processing.
|
||||
|
||||
|
||||
def flatten_tree(elem, path, branches):
|
||||
def flatten_tree(
|
||||
elem: dict[Any, Any] | list[Any] | str,
|
||||
path: list[str],
|
||||
branches: list[list[str]],
|
||||
) -> None:
|
||||
"""Flatten nested lists/dictionaries into lists of strings
|
||||
(branches).
|
||||
"""
|
||||
|
|
@ -69,7 +75,7 @@ def flatten_tree(elem, path, branches):
|
|||
branches.append(path + [str(elem)])
|
||||
|
||||
|
||||
def find_parents(candidate, branches):
|
||||
def find_parents(candidate: str, branches: list[list[str]]) -> list[str]:
|
||||
"""Find parents genre of a given genre, ordered from the closest to
|
||||
the further parent.
|
||||
"""
|
||||
|
|
@ -89,7 +95,7 @@ C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml")
|
|||
|
||||
|
||||
class LastGenrePlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.config.add(
|
||||
|
|
@ -111,12 +117,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
)
|
||||
self.setup()
|
||||
|
||||
def setup(self):
|
||||
def setup(self) -> None:
|
||||
"""Setup plugin from config options"""
|
||||
if self.config["auto"]:
|
||||
self.import_stages = [self.imported]
|
||||
|
||||
self._genre_cache = {}
|
||||
self._genre_cache: dict[str, list[str]] = {}
|
||||
self.whitelist = self._load_whitelist()
|
||||
self.c14n_branches, self.canonicalize = self._load_c14n_tree()
|
||||
|
||||
|
|
@ -161,7 +167,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
flatten_tree(genres_tree, [], c14n_branches)
|
||||
return c14n_branches, canonicalize
|
||||
|
||||
def _tunelog(self, msg, *args, **kwargs):
|
||||
def _tunelog(self, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log tuning messages at DEBUG level when verbosity level is high enough."""
|
||||
if config["verbose"].as_number() >= 3:
|
||||
self._log.debug(msg, *args, **kwargs)
|
||||
|
|
@ -182,7 +188,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
# More canonicalization and general helpers.
|
||||
|
||||
def _get_depth(self, tag):
|
||||
def _get_depth(self, tag: str) -> int | None:
|
||||
"""Find the depth of a tag in the genres tree."""
|
||||
depth = None
|
||||
for key, value in enumerate(self.c14n_branches):
|
||||
|
|
@ -191,7 +197,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
break
|
||||
return depth
|
||||
|
||||
def _sort_by_depth(self, tags):
|
||||
def _sort_by_depth(self, tags: list[str]) -> list[str]:
|
||||
"""Given a list of tags, sort the tags by their depths in the
|
||||
genre tree.
|
||||
"""
|
||||
|
|
@ -259,9 +265,11 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
valid_tags = [t for t in tags if self._is_valid(t)]
|
||||
return valid_tags[:count]
|
||||
|
||||
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'
|
||||
def fetch_genre(
|
||||
self, lastfm_obj: pylast.Album | pylast.Artist | pylast.Track
|
||||
) -> list[str]:
|
||||
"""Return genres for a pylast entity. Returns an empty list if
|
||||
no suitable genres are found.
|
||||
"""
|
||||
min_weight = self.config["min_weight"].get(int)
|
||||
return self._tags_for(lastfm_obj, min_weight)
|
||||
|
|
@ -278,8 +286,10 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
# Cached last.fm entity lookups.
|
||||
|
||||
def _last_lookup(self, entity, method, *args):
|
||||
"""Get a genre based on the named entity using the callable `method`
|
||||
def _last_lookup(
|
||||
self, entity: str, method: Callable[..., Any], *args: str
|
||||
) -> list[str]:
|
||||
"""Get genres 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.
|
||||
|
||||
|
|
@ -293,31 +303,27 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
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))
|
||||
args_replaced = [a.replace("\u2010", "-") for a in args]
|
||||
self._genre_cache[key] = self.fetch_genre(method(*args_replaced))
|
||||
|
||||
genre = self._genre_cache[key]
|
||||
self._tunelog("last.fm (unfiltered) {} tags: {}", entity, genre)
|
||||
return genre
|
||||
|
||||
def fetch_album_genre(self, obj):
|
||||
"""Return raw album genres from Last.fm for this Item or Album."""
|
||||
def fetch_album_genre(self, albumartist: str, albumtitle: str) -> list[str]:
|
||||
"""Return genres from Last.fm for the album by albumartist."""
|
||||
return self._last_lookup(
|
||||
"album", LASTFM.get_album, obj.albumartist, obj.album
|
||||
"album", LASTFM.get_album, albumartist, albumtitle
|
||||
)
|
||||
|
||||
def fetch_album_artist_genre(self, obj):
|
||||
"""Return raw album artist genres from Last.fm for this Item or Album."""
|
||||
return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist)
|
||||
def fetch_artist_genre(self, artist: str) -> list[str]:
|
||||
"""Return genres from Last.fm for the artist."""
|
||||
return self._last_lookup("artist", LASTFM.get_artist, artist)
|
||||
|
||||
def fetch_artist_genre(self, item):
|
||||
"""Returns raw track artist genres from Last.fm for this Item."""
|
||||
return self._last_lookup("artist", LASTFM.get_artist, item.artist)
|
||||
|
||||
def fetch_track_genre(self, obj):
|
||||
"""Returns raw track genres from Last.fm for this Item."""
|
||||
def fetch_track_genre(self, trackartist: str, tracktitle: str) -> list[str]:
|
||||
"""Return genres from Last.fm for the track by artist."""
|
||||
return self._last_lookup(
|
||||
"track", LASTFM.get_track, obj.artist, obj.title
|
||||
"track", LASTFM.get_track, trackartist, tracktitle
|
||||
)
|
||||
|
||||
# Main processing: _get_genre() and helpers.
|
||||
|
|
@ -372,7 +378,9 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
and the whitelist feature was disabled.
|
||||
"""
|
||||
|
||||
def _try_resolve_stage(stage_label: str, keep_genres, new_genres):
|
||||
def _try_resolve_stage(
|
||||
stage_label: str, keep_genres: list[str], new_genres: list[str]
|
||||
) -> tuple[str, str] | None:
|
||||
"""Try to resolve genres for a given stage and log the result."""
|
||||
resolved_genres = self._combine_resolve_and_log(
|
||||
keep_genres, new_genres
|
||||
|
|
@ -405,14 +413,14 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
# Run through stages: track, album, artist,
|
||||
# album artist, or most popular track genre.
|
||||
if isinstance(obj, library.Item) and "track" in self.sources:
|
||||
if new_genres := self.fetch_track_genre(obj):
|
||||
if new_genres := self.fetch_track_genre(obj.artist, obj.title):
|
||||
if result := _try_resolve_stage(
|
||||
"track", keep_genres, new_genres
|
||||
):
|
||||
return result
|
||||
|
||||
if "album" in self.sources:
|
||||
if new_genres := self.fetch_album_genre(obj):
|
||||
if new_genres := self.fetch_album_genre(obj.albumartist, obj.album):
|
||||
if result := _try_resolve_stage(
|
||||
"album", keep_genres, new_genres
|
||||
):
|
||||
|
|
@ -421,20 +429,36 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
if "artist" in self.sources:
|
||||
new_genres = []
|
||||
if isinstance(obj, library.Item):
|
||||
new_genres = self.fetch_artist_genre(obj)
|
||||
new_genres = self.fetch_artist_genre(obj.artist)
|
||||
stage_label = "artist"
|
||||
elif obj.albumartist != config["va_name"].as_str():
|
||||
new_genres = self.fetch_album_artist_genre(obj)
|
||||
new_genres = self.fetch_artist_genre(obj.albumartist)
|
||||
stage_label = "album artist"
|
||||
if not new_genres:
|
||||
self._tunelog(
|
||||
'No album artist genre found for "{}", '
|
||||
"trying multi-valued field...",
|
||||
obj.albumartist,
|
||||
)
|
||||
for albumartist in obj.albumartists:
|
||||
self._tunelog(
|
||||
'Fetching artist genre for "{}"', albumartist
|
||||
)
|
||||
new_genres += self.fetch_artist_genre(albumartist)
|
||||
if new_genres:
|
||||
stage_label = "multi-valued album artist"
|
||||
else:
|
||||
# For "Various Artists", pick the most popular track genre.
|
||||
item_genres = []
|
||||
assert isinstance(obj, Album) # Type narrowing for mypy
|
||||
for item in obj.items():
|
||||
item_genre = None
|
||||
if "track" in self.sources:
|
||||
item_genre = self.fetch_track_genre(item)
|
||||
item_genre = self.fetch_track_genre(
|
||||
item.artist, item.title
|
||||
)
|
||||
if not item_genre:
|
||||
item_genre = self.fetch_artist_genre(item)
|
||||
item_genre = self.fetch_artist_genre(item.artist)
|
||||
if item_genre:
|
||||
item_genres += item_genre
|
||||
if item_genres:
|
||||
|
|
@ -500,7 +524,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
write=write, move=False, inherit="track" not in self.sources
|
||||
)
|
||||
|
||||
def commands(self):
|
||||
def commands(self) -> list[ui.Subcommand]:
|
||||
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
|
||||
lastgenre_cmd.parser.add_option(
|
||||
"-p",
|
||||
|
|
@ -559,7 +583,9 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
)
|
||||
lastgenre_cmd.parser.set_defaults(album=True)
|
||||
|
||||
def lastgenre_func(lib, opts, args):
|
||||
def lastgenre_func(
|
||||
lib: library.Library, opts: optparse.Values, args: list[str]
|
||||
) -> None:
|
||||
self.config.set_args(opts)
|
||||
|
||||
method = lib.albums if opts.album else lib.items
|
||||
|
|
@ -569,10 +595,16 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
lastgenre_cmd.func = lastgenre_func
|
||||
return [lastgenre_cmd]
|
||||
|
||||
def imported(self, session, task):
|
||||
def imported(
|
||||
self, session: library.Session, task: library.ImportTask
|
||||
) -> None:
|
||||
self._process(task.album if task.is_album else task.item, write=False)
|
||||
|
||||
def _tags_for(self, obj, min_weight=None):
|
||||
def _tags_for(
|
||||
self,
|
||||
obj: pylast.Album | pylast.Artist | pylast.Track,
|
||||
min_weight: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Core genre identification routine.
|
||||
|
||||
Given a pylast entity (album or track), return a list of
|
||||
|
|
@ -584,11 +616,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
# Work around an inconsistency in pylast where
|
||||
# Album.get_top_tags() does not return TopItem instances.
|
||||
# https://github.com/pylast/pylast/issues/86
|
||||
obj_to_query: Any = obj
|
||||
if isinstance(obj, pylast.Album):
|
||||
obj = super(pylast.Album, obj)
|
||||
obj_to_query = super(pylast.Album, obj)
|
||||
|
||||
try:
|
||||
res = obj.get_top_tags()
|
||||
res: Any = obj_to_query.get_top_tags()
|
||||
except PYLAST_EXCEPTIONS as exc:
|
||||
self._log.debug("last.fm error: {}", exc)
|
||||
return []
|
||||
|
|
@ -603,6 +636,6 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
res = [el for el in res if (int(el.weight or 0)) >= min_weight]
|
||||
|
||||
# Get strings from tags.
|
||||
res = [el.item.get_name().lower() for el in res]
|
||||
tags: list[str] = [el.item.get_name().lower() for el in res]
|
||||
|
||||
return res
|
||||
return tags
|
||||
|
|
|
|||
|
|
@ -74,6 +74,11 @@ Bug fixes:
|
|||
cancelling an edit session during import. :bug:`6104`
|
||||
- :ref:`update-cmd` :doc:`plugins/edit` fix display formatting of field changes
|
||||
to clearly show added and removed flexible fields.
|
||||
- :doc:`plugins/lastgenre`: Fix the issue where last.fm doesn't return any
|
||||
result in the artist genre stage because "concatenation" words in the artist
|
||||
name (like "feat.", "+", or "&") prevent it. Using the albumartists list field
|
||||
and fetching a genre for each artist separately improves the chance of
|
||||
receiving valid results in that stage.
|
||||
- :doc:`plugins/discogs`: Fixed an unexpected flexible attribute originating
|
||||
from the Discogs plugin. :bug:`6177`
|
||||
|
||||
|
|
|
|||
|
|
@ -546,13 +546,13 @@ class LastGenrePluginTest(PluginTestCase):
|
|||
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):
|
||||
def mock_fetch_track_genre(self, trackartist, tracktitle):
|
||||
return mock_genres["track"]
|
||||
|
||||
def mock_fetch_album_genre(self, obj):
|
||||
def mock_fetch_album_genre(self, albumartist, albumtitle):
|
||||
return mock_genres["album"]
|
||||
|
||||
def mock_fetch_artist_genre(self, obj):
|
||||
def mock_fetch_artist_genre(self, artist):
|
||||
return mock_genres["artist"]
|
||||
|
||||
# Mock the last.fm fetchers. When whitelist enabled, we can assume only
|
||||
|
|
|
|||
Loading…
Reference in a new issue