mirror of
https://github.com/beetbox/beets.git
synced 2026-03-18 19:33:01 +01:00
146 lines
5 KiB
Python
146 lines
5 KiB
Python
# This file is part of beets.
|
|
# Copyright 2016, Adrian Sampson.
|
|
# Copyright 2026, J0J0 Todos.
|
|
#
|
|
# 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.
|
|
|
|
|
|
"""Last.fm API client for genre lookups."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import traceback
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pylast
|
|
|
|
from beets import plugins
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from beets.logging import BeetsLogger
|
|
|
|
GenreCache = dict[str, list[str]]
|
|
"""Cache mapping entity keys to their genre lists.
|
|
Keys are formatted as 'entity.arg1-arg2-...' (e.g., 'album.artist-title').
|
|
Values are lists of lowercase genre strings."""
|
|
|
|
|
|
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
|
|
|
|
PYLAST_EXCEPTIONS = (
|
|
pylast.WSError,
|
|
pylast.MalformedResponseError,
|
|
pylast.NetworkError,
|
|
)
|
|
|
|
|
|
class LastFmClient:
|
|
"""Client for fetching genres from Last.fm."""
|
|
|
|
def __init__(self, log: BeetsLogger, min_weight: int):
|
|
"""Initialize the client.
|
|
|
|
The min_weight parameter filters tags by their minimum weight.
|
|
"""
|
|
self._log = log
|
|
self._min_weight = min_weight
|
|
self._genre_cache: GenreCache = {}
|
|
|
|
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.
|
|
"""
|
|
return self._tags_for(lastfm_obj, self._min_weight)
|
|
|
|
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
|
|
tag names for that entity. Return an empty list if the entity is
|
|
not found or another error occurs.
|
|
|
|
If `min_weight` is specified, tags are filtered by weight.
|
|
"""
|
|
# 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_to_query = super(pylast.Album, obj)
|
|
|
|
try:
|
|
res: Any = obj_to_query.get_top_tags()
|
|
except PYLAST_EXCEPTIONS as exc:
|
|
self._log.debug("last.fm error: {}", exc)
|
|
return []
|
|
except Exception as exc:
|
|
# Isolate bugs in pylast.
|
|
self._log.debug("{}", traceback.format_exc())
|
|
self._log.error("error in pylast library: {}", exc)
|
|
return []
|
|
|
|
# Filter by weight (optionally).
|
|
if min_weight:
|
|
res = [el for el in res if (int(el.weight or 0)) >= min_weight]
|
|
|
|
# Get strings from tags.
|
|
tags: list[str] = [el.item.get_name().lower() for el in res]
|
|
|
|
return tags
|
|
|
|
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.
|
|
|
|
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 []
|
|
|
|
key = f"{entity}.{'-'.join(str(a) for a in args)}"
|
|
if key not in self._genre_cache:
|
|
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._log.extra_debug("last.fm (unfiltered) {} tags: {}", entity, genre)
|
|
return genre
|
|
|
|
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, albumartist, albumtitle
|
|
)
|
|
|
|
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_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, trackartist, tracktitle
|
|
)
|