fix: look tags up by artist name rather than by MusicBrainz id
Build and publish container / build (pull_request) Successful in 2m53s

Tag lookups asked by MusicBrainz id whenever Lidarr had one, which is always.
The reasoning was that an id cannot be ambiguous the way a name can. In practice
Last.fm's mbid index is stale and partial, and it answered "the artist you
supplied could not be found" for Devo, Escape the Fate, Blasterjaxx, Frank
Carter & the Rattlesnakes and a long tail of others -- artists whose Last.fm
pages plainly exist and carry precisely the tags the moods are built from. Devo
is tagged new wave, post-punk and 80s; Escape the Fate is tagged post-hardcore,
screamo and emocore. Both were skipped.

Ask by name first, which is the index Last.fm's own site runs on, and keep the
id only as a fallback for a name Lidarr spells differently.

Failures were also being dropped without recording the attempt, so every one of
those artists was re-queried on every subsequent pass, indefinitely. They are now
distinguished: an artist that neither key resolves is recorded as fetched with
no tags and not asked about again, while a genuine failure -- a rate limit, a
bad key -- is deliberately left unrecorded so the next pass retries it. Telling
the two apart needed the service's own error number, so LastfmError now carries
it.
This commit is contained in:
Emma Thorpe
2026-08-24 18:13:23 +01:00
parent ddd126cc0d
commit 40dfce8a4c
4 changed files with 146 additions and 20 deletions
+50 -10
View File
@@ -266,7 +266,16 @@ WHITESPACE = re.compile(r"\s+")
class LastfmError(Exception):
"""A Last.fm request that failed in a way retrying will not fix."""
"""A Last.fm request that failed in a way retrying will not fix.
`code` is the service's own error number where the failure came from the
API rather than the transport. Callers need it to tell "this thing does not
exist", which is final, from "something went wrong", which is not.
"""
def __init__(self, message, code=None):
super().__init__(message)
self.code = code
class LidarrError(Exception):
@@ -447,7 +456,7 @@ class Lastfm:
return payload
detail = f"error {code}: {payload.get('message', '')}".strip()
if code not in RETRYABLE_ERRORS:
raise LastfmError(f"{method}: {detail}")
raise LastfmError(f"{method}: {detail}", code=code)
self._retry_or_raise(method, attempt, detail, None)
raise LastfmError(f"{method}: gave up after {self.attempts} attempts")
@@ -1323,6 +1332,34 @@ def parse_tags(payload):
return pairs
def fetch_tags(client, name, mbid):
"""Return an artist's tags, asking by name first.
By name, not by MusicBrainz id, despite Lidarr having an id for everything.
Last.fm's mbid index is stale and partial -- it answers "the artist you
supplied could not be found" for Devo, Escape the Fate and a few hundred
others whose pages plainly exist -- while the name index is the one its own
site runs on. The id is kept only as a fallback for a name Lidarr spells
differently.
Returns an empty list when the artist is genuinely unknown, which the caller
records so it is not asked again.
"""
attempts = [{"artist": name}] if name else []
if mbid:
attempts.append({"mbid": mbid})
for query in attempts:
try:
return parse_tags(client.call("artist.getTopTags", {**query, "autocorrect": 1}))
except LastfmError as error:
# Error 6 here means "no such artist", which the next key may still
# answer. Anything else is a real failure and belongs to the caller.
if error.code != 6:
raise
return []
def sync_tags(client, store, now, limit=0):
"""Fetch crowd tags for library artists that have none, or stale ones.
@@ -1348,20 +1385,23 @@ def sync_tags(client, store, now, limit=0):
else:
logger.info("fetching tags for %d artists", len(stale))
tagged = 0
resolved = 0
unknown = 0
for artist in stale:
query = {"mbid": artist["mbid"]} if artist["mbid"] else {"artist": artist["name"]}
try:
payload = client.call("artist.getTopTags", {**query, "autocorrect": 1})
pairs = fetch_tags(client, artist["name"], artist["mbid"])
except LastfmError as error:
# One artist Last.fm cannot answer for is not worth losing the pass.
# Something went wrong rather than the artist not existing. Left
# unrecorded on purpose, so the next pass tries again.
logger.warning("no tags for %s: %s", artist["name"], error)
continue
store.replace_tags(artist["norm_name"], parse_tags(payload), now)
tagged += 1
store.replace_tags(artist["norm_name"], pairs, now)
resolved += 1
if not pairs:
unknown += 1
logger.info("tagged %d artists", tagged)
return tagged
logger.info("tagged %d artists (%d with nothing to say about them)", resolved, unknown)
return resolved
def build_vibe_playlists(store, vibes, mirror_root, library_root, limit, now):