From d5a753146f19d1e6b5d94faaefdcd3b8e79c9507 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 8 Aug 2026 00:40:29 -0400 Subject: [PATCH] Fix LastFM rate limiting dropping scrobbles (f91fdd53 follow-up) --- CHANGELOG.org | 9 +++++ tests/scrobbles_tests/test_lastfm.py | 39 +++++++++++++++++++++ vrobbler/apps/scrobbles/importers/lastfm.py | 27 +++++++++----- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.org b/CHANGELOG.org index 2f10ff2..df5530f 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -35,6 +35,15 @@ Two real bugs were found and fixed: Tests added in `tests/scrobbles_tests/test_lastfm.py` (5 tests) covering dedup-by-timestamp, creation, and dispatch resume/no-resume behavior. +A follow-up fix on 2026-08-08: the per-scrobble `track.getDuration`/ +`getMbid` API calls in `get_last_scrobbles` exhausted Last.fm's rate limit +during large imports, raising `WSError` for almost every track and dropping +the whole scrobble (the artist lookup lived in the same `try`, so any failure +left `artist` unset). Artist now comes straight from the recent-tracks +response (no API call), duration/MBID enrichment is best-effort, and +enrichment calls are throttled to ~0.2s each. Test added for the +artist-survives-enrichment-failure path. + ** DONE [#B] Fix LookupError on scrobble start/long-plays views :bug:scrobbles:videos: :PROPERTIES: :ID: 868e2a91-c974-4656-a8ae-b90a1f94ae74 diff --git a/tests/scrobbles_tests/test_lastfm.py b/tests/scrobbles_tests/test_lastfm.py index 505590f..78d0479 100644 --- a/tests/scrobbles_tests/test_lastfm.py +++ b/tests/scrobbles_tests/test_lastfm.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +import pylast import pytest import pytz from django.contrib.auth import get_user_model @@ -62,6 +63,44 @@ def mock_track_find_or_create(): yield mock_find +def make_pylast_scrobble(artist, title, timestamp, album="Emotion"): + track = MagicMock() + artist_mock = MagicMock() + artist_mock.name = artist + track.artist = artist_mock + track.title = title + scrobble = MagicMock() + scrobble.track = track + scrobble.album = album + scrobble.timestamp = str(int(timestamp.timestamp())) + return scrobble + + +class TestGetLastScrobbles: + @pytest.mark.django_db + def test_artist_used_when_enrichment_fails(self, lfm_user, mock_lastfm_network): + timestamp = datetime(2023, 2, 15, 12, 0, 0, tzinfo=UTC) + scrobble = make_pylast_scrobble("Carly Rae Jepsen", "Emotion", timestamp) + # Enrichment (duration/mbid) hits Last.fm's rate limit, but the + # artist from the recent-tracks response must still be used. + scrobble.track.get_duration.side_effect = pylast.WSError( + None, 429, "rate limited" + ) + scrobble.track.get_mbid.side_effect = pylast.WSError(None, 429, "rate limited") + + lastfm = LastFM(lfm_user) + lastfm.user.get_recent_tracks.return_value = [scrobble] + with patch("scrobbles.importers.lastfm.time.sleep"): + parsed = lastfm.get_last_scrobbles() + + assert len(parsed) == 1 + assert parsed[0]["artist"] == "Carly Rae Jepsen" + assert parsed[0]["title"] == "Emotion" + assert parsed[0]["run_time_seconds"] is None + assert parsed[0]["mbid"] is None + assert parsed[0]["timestamp"] == timestamp + + class TestImportFromLastfm: @pytest.mark.django_db def test_import_creates_scrobble( diff --git a/vrobbler/apps/scrobbles/importers/lastfm.py b/vrobbler/apps/scrobbles/importers/lastfm.py index 1f5554e..6c8af85 100644 --- a/vrobbler/apps/scrobbles/importers/lastfm.py +++ b/vrobbler/apps/scrobbles/importers/lastfm.py @@ -1,5 +1,6 @@ import calendar import logging +import time from datetime import datetime, timedelta import pylast @@ -138,30 +139,38 @@ class LastFM: for scrobble in found_scrobbles: logger.info(f"Processing {scrobble}") + log_dict = {"scrobble": scrobble} + + # Artist/title/album come from the recent-tracks response itself, + # no extra API call required. + artist = scrobble.track.artist.name if scrobble.track.artist else None + log_dict["artist"] = artist + + # Duration and MBID require a per-track `getInfo` API call, which + # quickly exhausts Last.fm's rate limit for large historical + # imports. Treat them as best-effort so one failure doesn't drop + # the whole scrobble. run_time = None mbid = None - artist = None - - log_dict = {"scrobble": scrobble} try: run_time = int(scrobble.track.get_duration() / 1000) mbid = scrobble.track.get_mbid() - artist = scrobble.track.get_artist().name - log_dict["artist"] = artist log_dict["mbid"] = mbid log_dict["run_time"] = run_time except pylast.MalformedResponseError as e: logger.warning(e) except pylast.WSError as e: logger.info( - "LastFM barfed trying to get the track for {scrobble.track}", - extra=log_dict, + f"LastFM barfed trying to enrich {scrobble.track}", extra=log_dict ) except pylast.NetworkError as e: logger.info( - "LastFM barfed trying to get the track for {scrobble.track}", - extra=log_dict, + f"LastFM barfed trying to enrich {scrobble.track}", extra=log_dict ) + finally: + # Last.fm rate limit is ~5 req/sec; keep per-track enrichment + # calls spaced out so large imports don't trip it. + time.sleep(0.2) if not artist: logger.info(