from datetime import datetime, timedelta from unittest.mock import MagicMock, patch import pytest import time_machine from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from music.models import Album, Artist, Track from podcasts.models import PodcastEpisode from scrobbles.models import Scrobble from tasks.models import Task @pytest.mark.django_db def test_get_not_allowed_from_mopidy(client, valid_auth_token): url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.get(url, headers=headers) assert response.status_code == 405 @pytest.mark.django_db def test_get_not_allowed_from_jellyfin(client, valid_auth_token): url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.get(url, headers=headers) assert response.status_code == 405 @pytest.mark.django_db def test_bad_mopidy_request_data(client, valid_auth_token): url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, "not valid json", content_type="application/json", headers=headers ) assert response.status_code == 400 assert ( response.data["detail"] == "JSON parse error - Expecting value: line 1 column 1 (char 0)" ) @pytest.mark.django_db def test_bad_jellyfin_request_data(client, valid_auth_token): url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, "not valid json", content_type="application/json", headers=headers ) assert response.status_code == 400 assert ( response.data["detail"] == "JSON parse error - Expecting value: line 1 column 1 (char 0)" ) @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_create_scrobble_from_mopidy_track_webhook( mock_track_fc, mock_artist_fc, client, valid_auth_token, mopidy_track, ): mock_artist = MagicMock(spec=Artist) mock_artist.id = 1 mock_artist_fc.return_value = mock_artist mock_track = MagicMock(spec=Track) mock_track.id = 1 mock_track.scrobble_for_user.return_value = Scrobble( id=1, track_id=1, user_id=1, in_progress=True ) mock_track_fc.return_value = mock_track url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_track.request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} mock_track.scrobble_for_user.assert_called_once() @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_create_scrobble_from_jellyfin_track_webhook( mock_track_fc, mock_artist_fc, client, valid_auth_token, jellyfin_track, ): mock_artist = MagicMock(spec=Artist) mock_artist.id = 1 mock_artist_fc.return_value = mock_artist mock_track = MagicMock(spec=Track) mock_track.id = 1 mock_track.scrobble_for_user.return_value = Scrobble( id=1, track_id=1, user_id=1, in_progress=True ) mock_track_fc.return_value = mock_track url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} mock_track.scrobble_for_user.assert_called_once() @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_mopidy_track_webhook_creates_track_and_scrobble( mock_track_fc, mock_artist_fc, client, valid_auth_token, mopidy_track, ): artist = Artist.objects.create(name="Sublime") album = Album.objects.create(name="Sublime", album_artist=artist) track = Track.objects.create( title="Same in the End", artist_fk=artist, base_run_time_seconds=60, ) track.artists.add(artist) track.albums.add(album) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_track.request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.track == track assert scrobble.source == "Mopidy" @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_jellyfin_track_webhook_creates_track_and_scrobble( mock_track_fc, mock_artist_fc, client, valid_auth_token, jellyfin_track, ): artist = Artist.objects.create(name="Carly Rae Jepsen") album = Album.objects.create(name="Emotion", album_artist=artist) track = Track.objects.create( title="Emotion", artist_fk=artist, base_run_time_seconds=60, ) track.artists.add(artist) track.albums.add(album) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.track == track assert scrobble.source == "Jellyfin" assert "raw_data" in scrobble.log @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_mopidy_track_webhook_stores_raw_data( mock_track_fc, mock_artist_fc, client, valid_auth_token, mopidy_track, ): artist = Artist.objects.create(name="Sublime") album = Album.objects.create(name="Sublime", album_artist=artist) track = Track.objects.create( title="Same in the End", artist_fk=artist, base_run_time_seconds=60, ) track.artists.add(artist) track.albums.add(album) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_track.request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.track == track assert scrobble.source == "Mopidy" assert "raw_data" in scrobble.log assert scrobble.log["raw_data"]["name"] == "Same in the End" @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_mopidy_track_webhook_stores_album_id( mock_track_fc, mock_artist_fc, client, valid_auth_token, mopidy_track, ): artist = Artist.objects.create(name="Sublime") album = Album.objects.create(name="Sublime", album_artist=artist) track = Track.objects.create( title="Same in the End", artist_fk=artist, album=album, base_run_time_seconds=60, ) track.artists.add(artist) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_track.request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 scrobble = Scrobble.objects.get(id=1) assert "album_id" in scrobble.log assert scrobble.log["album_id"] == album.id assert "album" not in scrobble.log @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_jellyfin_track_webhook_stores_raw_data( mock_track_fc, mock_artist_fc, client, valid_auth_token, jellyfin_track, ): artist = Artist.objects.create(name="Carly Rae Jepsen") album = Album.objects.create(name="Emotion", album_artist=artist) track = Track.objects.create( title="Emotion", artist_fk=artist, base_run_time_seconds=60, ) track.artists.add(artist) track.albums.add(album) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 scrobble = Scrobble.objects.get(id=1) assert scrobble.track == track assert scrobble.source == "Jellyfin" assert "raw_data" in scrobble.log assert scrobble.log["raw_data"]["Name"] == "Emotion" @pytest.mark.django_db @patch("music.models.Artist.find_or_create") @patch("music.models.Track.find_or_create") def test_jellyfin_track_webhook_stores_album_id( mock_track_fc, mock_artist_fc, client, valid_auth_token, jellyfin_track, ): artist = Artist.objects.create(name="Carly Rae Jepsen") album = Album.objects.create(name="Emotion", album_artist=artist) track = Track.objects.create( title="Emotion", artist_fk=artist, album=album, base_run_time_seconds=60, ) track.artists.add(artist) mock_artist_fc.return_value = artist mock_track_fc.return_value = track url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 scrobble = Scrobble.objects.get(id=1) assert "album_id" in scrobble.log assert scrobble.log["album_id"] == album.id @pytest.mark.skip("Need to refactor") @pytest.mark.django_db @patch("music.utils.lookup_artist_from_mb", return_value={}) @patch( "music.utils.lookup_album_dict_from_mb", return_value={"year": "1999", "mb_group_id": 1}, ) @patch("music.utils.lookup_track_from_mb", return_value={}) @patch("music.models.lookup_artist_from_tadb", return_value={}) @patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"}) @patch("music.models.Album.fetch_artwork", return_value=None) @patch("music.models.Album.scrape_allmusic", return_value=None) def test_scrobble_mopidy_same_track_different_album( mock_lookup_artist, mock_lookup_album, mock_lookup_track, mock_lookup_artist_tadb, mock_lookup_album_tadb, mock_fetch_artwork, mock_scrape_allmusic, client, mopidy_track, mopidy_track_diff_album_request_data, valid_auth_token, ): url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_track.request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.last() assert scrobble.media_obj.album.name == "Sublime" response = client.post( url, mopidy_track_diff_album_request_data, content_type="application/json", ) assert response.status_code == 200 assert response.data == {"scrobble_id": 2} scrobble = Scrobble.objects.last() assert scrobble.media_obj.__class__ == Track assert scrobble.media_obj.album.name == "Sublime" assert scrobble.media_obj.title == "Same in the End" @pytest.mark.django_db @patch( "podcasts.sources.podcastindex.lookup_podcast_from_podcastindex", return_value={}, ) def test_scrobble_mopidy_podcast( mock_lookup_podcast, client, mopidy_podcast_request_data, valid_auth_token ): url = reverse("scrobbles:mopidy-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.post( url, mopidy_podcast_request_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.media_obj.__class__ == PodcastEpisode assert scrobble.media_obj.title == "Up First" @pytest.mark.skip("Need to refactor") @pytest.mark.django_db @patch("music.utils.lookup_artist_from_mb", return_value={}) @patch( "music.utils.lookup_album_dict_from_mb", return_value={"year": "1999", "mb_group_id": 1}, ) @patch("music.utils.lookup_track_from_mb", return_value={}) @patch("music.models.lookup_artist_from_tadb", return_value={}) @patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"}) @patch("music.models.Album.fetch_artwork", return_value=None) @patch("music.models.Album.scrape_allmusic", return_value=None) def test_scrobble_jellyfin_track( mock_lookup_artist, mock_lookup_album, mock_lookup_track, mock_lookup_artist_tadb, mock_lookup_album_tadb, mock_fetch_artwork, mock_scrape_allmusic, client, jellyfin_track, valid_auth_token, ): url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)): jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.media_obj.__class__ == Track assert scrobble.media_obj.title == "Emotion" @pytest.mark.django_db def test_scrobble_detail_view_with_notes_as_flat_list(client): user = get_user_model().objects.create_user( username="testuser", email="test@example.com", password="testpass" ) task = Task.objects.create(title="Test Task", description="Test description") scrobble = Scrobble.objects.create( task=task, media_type="Task", user=user, log={ "notes": ["First note", "Second note"], "description": "Test description", }, ) url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid}) response = client.get(url) assert response.status_code == 200 assert "First note" in response.content.decode() assert "Second note" in response.content.decode() @pytest.mark.django_db def test_scrobble_detail_view_with_notes_as_dict_timestamps(client): user = get_user_model().objects.create_user( username="testuser", email="test@example.com", password="testpass" ) task = Task.objects.create(title="Test Task", description="Test description") scrobble = Scrobble.objects.create( task=task, media_type="Task", user=user, log={ "notes": [ {"2024-01-01 10:00:00": "Note at first timestamp"}, {"2024-01-02 11:30:00": "Note at second timestamp"}, ], "description": "Test description", }, ) url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid}) response = client.get(url) assert response.status_code == 200 content = response.content.decode() assert "2024-01-01 10:00:00" in content assert "Note at first timestamp" in content assert "2024-01-02 11:30:00" in content assert "Note at second timestamp" in content @pytest.mark.django_db def test_scrobble_detail_view_with_notes_and_labels(client): user = get_user_model().objects.create_user( username="testuser", email="test@example.com", password="testpass" ) task = Task.objects.create(title="Test Task", description="Test description") scrobble = Scrobble.objects.create( task=task, media_type="Task", user=user, log={ "notes": [ {"2024-01-01 10:00:00": "Note with label"}, ], "labels": ["work", "urgent"], "description": "Test description", }, ) url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid}) response = client.get(url) assert response.status_code == 200 content = response.content.decode() assert "work" in content assert "urgent" in content @pytest.mark.django_db def test_scrobble_detail_view_post_updates_log(client): user = get_user_model().objects.create_user( username="testuser", email="test@example.com", password="testpass" ) task = Task.objects.create(title="Test Task", description="Test description") scrobble = Scrobble.objects.create( task=task, media_type="Task", user=user, log={ "notes": ["Original note"], "description": "Original description", }, ) url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid}) client.force_login(user) response = client.post( url, { "description": "Updated description", "notes": "Updated note", }, ) assert response.status_code == 302 scrobble.refresh_from_db() assert scrobble.log["description"] == "Updated description" assert isinstance(scrobble.log["notes"], dict) assert list(scrobble.log["notes"].values()) == ["Updated note"] @pytest.mark.skip("Need to refactor") @pytest.mark.django_db @patch("music.utils.lookup_artist_from_mb", return_value={}) @patch( "music.utils.lookup_album_dict_from_mb", return_value={"year": "1999", "mb_group_id": 1}, ) @patch("music.utils.lookup_track_from_mb", return_value={}) @patch("music.models.lookup_artist_from_tadb", return_value={}) @patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"}) @patch("music.models.Album.fetch_artwork", return_value=None) @patch("music.models.Album.scrape_allmusic", return_value=None) def test_scrobble_jellyfin_track_update( mock_lookup_artist, mock_lookup_album, mock_lookup_track, mock_lookup_artist_tadb, mock_lookup_album_tadb, mock_fetch_artwork, mock_scrape_allmusic, test_track, client, jellyfin_track, valid_auth_token, ): Scrobble.objects.create( timestamp=timezone.now() - timedelta(minutes=0.5), track=Track.objects.first(), user_id=1, ) url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 1} scrobble = Scrobble.objects.get(id=1) assert scrobble.media_obj.__class__ == Track assert scrobble.media_obj.title == "Emotion" @pytest.mark.skip("Need to refactor") @pytest.mark.django_db @patch("music.utils.lookup_artist_from_mb", return_value={}) @patch( "music.utils.lookup_album_dict_from_mb", return_value={"year": "1999", "mb_group_id": 1}, ) @patch("music.utils.lookup_track_from_mb", return_value={}) @patch("music.models.lookup_artist_from_tadb", return_value={}) @patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"}) @patch("music.models.Album.fetch_artwork", return_value=None) @patch("music.models.Album.scrape_allmusic", return_value=None) def test_scrobble_jellyfin_track_create_new( mock_lookup_artist, mock_lookup_album, mock_lookup_track, mock_lookup_artist_tadb, mock_lookup_album_tadb, mock_fetch_artwork, mock_scrape_allmusic, test_track, client, jellyfin_track, valid_auth_token, ): url = reverse("scrobbles:jellyfin-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} Scrobble.objects.create( timestamp=timezone.now() - timedelta(minutes=1), track=Track.objects.first(), user_id=1, ) jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime( "%Y-%m-%d %H:%M:%S" ) response = client.post( url, jellyfin_track.request_json, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert response.data == {"scrobble_id": 2} scrobble = Scrobble.objects.get(id=1) assert scrobble.media_obj.__class__ == Track assert scrobble.media_obj.title == "Emotion" @pytest.mark.django_db def test_get_not_allowed_from_gps(client, valid_auth_token): url = reverse("scrobbles:gps-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} response = client.get(url, headers=headers) assert response.status_code == 405 @pytest.mark.django_db def test_gps_webhook_creates_location(client, valid_auth_token): url = reverse("scrobbles:gps-webhook") headers = {"Authorization": f"Token {valid_auth_token}"} gps_data = { "lat": "40.7128", "lon": "-74.0060", "alt": "10.5", "time": "2024-01-14T12:00:00Z", "prov": "gps", } response = client.post( url, gps_data, content_type="application/json", headers=headers, ) assert response.status_code == 200 assert "scrobble_id" in response.data