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 from django.utils import timezone from music.models import Track from scrobbles.importers.lastfm import LastFM, dispatch_historical_imports from scrobbles.models import LastFmImport, Scrobble User = get_user_model() UTC = pytz.utc @pytest.fixture def lfm_user(): user = User.objects.create(username="lfmuser") profile = user.profile profile.lastfm_username = "lfmuser" profile.lastfm_password = "secret" profile.save() return user @pytest.fixture def mock_lastfm_network(): with patch("scrobbles.importers.lastfm.pylast.LastFMNetwork") as network, patch( "scrobbles.importers.lastfm.pylast.md5", side_effect=lambda x: x ): network.return_value.get_user.return_value = MagicMock() yield network def make_scrobble_dict(timestamp, title="Emotion", artist="Carly Rae Jepsen"): return { "artist": artist, "album": "Emotion", "title": title, "mbid": None, "run_time_seconds": 60, "timestamp": timestamp, } @pytest.fixture def mock_track_find_or_create(): with patch("scrobbles.importers.lastfm.Track.find_or_create") as mock_find: def _create(**kwargs): title = kwargs.get("title", "Emotion") track = Track.objects.filter(title=title).first() if not track: track = Track.objects.create( title=title, base_run_time_seconds=kwargs.get("run_time_seconds"), ) return track mock_find.side_effect = _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( self, lfm_user, mock_lastfm_network, mock_track_find_or_create ): timestamp = datetime(2023, 2, 15, 12, 0, 0, tzinfo=UTC) lastfm = LastFM(lfm_user) lastfm.get_last_scrobbles = MagicMock( return_value=[make_scrobble_dict(timestamp)] ) created = lastfm.import_from_lastfm() assert len(created) == 1 scrobble = created[0] assert scrobble.user == lfm_user assert scrobble.timestamp == timestamp assert scrobble.source == "Last.fm" assert scrobble.media_type == "Track" @pytest.mark.django_db def test_import_skips_existing_scrobble_by_timestamp( self, lfm_user, mock_lastfm_network, mock_track_find_or_create ): timestamp = datetime(2023, 2, 15, 12, 0, 0, tzinfo=UTC) track = Track.objects.create(title="Emotion", base_run_time_seconds=60) existing = Scrobble.objects.create( user=lfm_user, timestamp=timestamp, stop_timestamp=timestamp + timedelta(seconds=60), source="Mopidy", track=track, media_type="Track", ) # The existing scrobble was created long ago, not at import time. Scrobble.objects.filter(pk=existing.pk).update( created=timestamp - timedelta(days=365) ) lastfm = LastFM(lfm_user) lastfm.get_last_scrobbles = MagicMock( return_value=[make_scrobble_dict(timestamp)] ) created = lastfm.import_from_lastfm() assert created == [] assert Scrobble.objects.filter(track=track).count() == 1 @pytest.mark.django_db def test_import_creates_scrobble_when_only_created_close( self, lfm_user, mock_lastfm_network, mock_track_find_or_create ): timestamp = datetime(2023, 2, 15, 12, 0, 0, tzinfo=UTC) track = Track.objects.create(title="Emotion", base_run_time_seconds=60) # An existing scrobble for the same track but at a *different* play # time (its row was created recently) must not block the new one. Scrobble.objects.create( user=lfm_user, timestamp=timestamp - timedelta(hours=5), stop_timestamp=timestamp - timedelta(hours=4, minutes=59), source="Mopidy", track=track, media_type="Track", ) lastfm = LastFM(lfm_user) lastfm.get_last_scrobbles = MagicMock( return_value=[make_scrobble_dict(timestamp)] ) created = lastfm.import_from_lastfm() assert len(created) == 1 assert Scrobble.objects.filter(track=track).count() == 2 class TestDispatchHistoricalImports: def _seed_import(self, user, scrobble, processed_finished): lfm_import = LastFmImport.objects.create(user=user) lfm_import.record_log([scrobble]) lfm_import.mark_started() LastFmImport.objects.filter(pk=lfm_import.pk).update( processed_finished=processed_finished ) return lfm_import @pytest.mark.django_db def test_resumes_before_earliest_scrobble_not_earliest_processed( self, lfm_user, mock_lastfm_network ): # Import A finished first but covers a LATER month; import B finished # second but holds the EARLIEST scrobbles. This is the exact scenario # from production (import 1 = April finished 19:33, import 5 = February). feb_scrobble = Scrobble.objects.create( user=lfm_user, timestamp=datetime(2023, 2, 15, 12, 0, 0, tzinfo=UTC), source="Last.fm", media_type="Track", ) apr_scrobble = Scrobble.objects.create( user=lfm_user, timestamp=datetime(2023, 4, 15, 12, 0, 0, tzinfo=UTC), source="Last.fm", media_type="Track", ) now = timezone.now() self._seed_import(lfm_user, apr_scrobble, now - timedelta(hours=2)) self._seed_import(lfm_user, feb_scrobble, now - timedelta(hours=1)) mock_lastfm = MagicMock() mock_lastfm.get_earliest_scrobble_timestamp.return_value = datetime( 2022, 6, 1, tzinfo=UTC ) def fake_get_last_scrobbles(time_from=None, time_to=None, check=False): if time_from and time_from.month == 1 and time_from.year == 2023: return True return False mock_lastfm.get_last_scrobbles = fake_get_last_scrobbles with patch( "scrobbles.importers.lastfm.LastFM", return_value=mock_lastfm ), patch("scrobbles.tasks.process_lastfm_import.delay") as mock_delay: dispatched = dispatch_historical_imports(lfm_user.id) assert dispatched == 1 mock_delay.assert_called_once() args, kwargs = mock_delay.call_args assert kwargs["time_from"] == datetime(2023, 1, 1, tzinfo=UTC) assert kwargs["time_to"] == datetime( 2023, 1, 31, 23, 59, 59, 999999, tzinfo=UTC ) # Only one new import (January 2023); February and April are untouched. new_imports = LastFmImport.objects.filter( user=lfm_user, processed_finished__isnull=True ) assert new_imports.count() == 1 @pytest.mark.django_db def test_no_existing_imports_starts_from_now(self, lfm_user, mock_lastfm_network): mock_lastfm = MagicMock() mock_lastfm.get_earliest_scrobble_timestamp.return_value = datetime( 2022, 6, 1, tzinfo=UTC ) mock_lastfm.get_last_scrobbles = MagicMock(return_value=False) with patch( "scrobbles.importers.lastfm.LastFM", return_value=mock_lastfm ), patch("scrobbles.tasks.process_lastfm_import.delay") as mock_delay: dispatched = dispatch_historical_imports(lfm_user.id) assert dispatched == 0 mock_delay.assert_not_called() assert not LastFmImport.objects.filter(user=lfm_user).exists()