From 07ad6005c8aaf369925bbf544817cb57ab415ce3 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sun, 8 Jan 2023 00:26:24 -0500 Subject: [PATCH] Add rudimentary support for mopidy-webhooks --- .../music/migrations/0002_alter_album_year.py | 18 +++++ vrobbler/apps/music/models.py | 50 ++++-------- vrobbler/apps/scrobbles/models.py | 55 ++++++++----- vrobbler/apps/scrobbles/urls.py | 1 + vrobbler/apps/scrobbles/views.py | 79 ++++++++++++++++++- 5 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 vrobbler/apps/music/migrations/0002_alter_album_year.py diff --git a/vrobbler/apps/music/migrations/0002_alter_album_year.py b/vrobbler/apps/music/migrations/0002_alter_album_year.py new file mode 100644 index 0000000..3c33ed9 --- /dev/null +++ b/vrobbler/apps/music/migrations/0002_alter_album_year.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-01-08 01:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='album', + name='year', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py index 1acfe42..b79202b 100644 --- a/vrobbler/apps/music/models.py +++ b/vrobbler/apps/music/models.py @@ -11,7 +11,7 @@ BNULL = {"blank": True, "null": True} class Album(TimeStampedModel): name = models.CharField(max_length=255) - year = models.IntegerField() + year = models.IntegerField(**BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL) musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL) @@ -40,55 +40,35 @@ class Track(TimeStampedModel): return f"{self.title} by {self.artist}" @classmethod - def find_or_create(cls, data_dict: Dict) -> Optional["Track"]: + def find_or_create( + cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict + ) -> Optional["Track"]: """Given a data dict from Jellyfin, does the heavy lifting of looking up the video and, if need, TV Series, creating both if they don't yet exist. """ - artist = data_dict.get(KEYS["ARTIST_NAME"], None) - artist_musicbrainz_id = data_dict.get(KEYS["ARTIST_MB_ID"], None) - if not artist or not artist_musicbrainz_id: + if not artist_dict.get('name') or not artist_dict.get( + 'musicbrainz_id' + ): logger.warning( f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling" ) return - artist, artist_created = Artist.objects.get_or_create( - name=artist, musicbrainz_id=artist_musicbrainz_id - ) + artist, artist_created = Artist.objects.get_or_create(**artist_dict) if artist_created: logger.debug(f"Created new album {artist}") else: logger.debug(f"Found album {artist}") - album = None - album_name = data_dict.get(KEYS["ALBUM_NAME"], None) - if album_name: - album_dict = { - "name": album_name, - "year": data_dict.get(KEYS["YEAR"], ""), - "musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']), - "musicbrainz_releasegroup_id": data_dict.get( - KEYS["RELEASEGROUP_MB_ID"] - ), - "musicbrainz_albumartist_id": data_dict.get( - KEYS["ARTIST_MB_ID"] - ), - } - album, album_created = Album.objects.get_or_create(**album_dict) - if album_created: - logger.debug(f"Created new album {album}") - else: - logger.debug(f"Found album {album}") + album, album_created = Album.objects.get_or_create(**album_dict) + if album_created: + logger.debug(f"Created new album {album}") + else: + logger.debug(f"Found album {album}") - track_dict = { - "title": data_dict.get("Name", ""), - "musicbrainz_id": data_dict.get(KEYS["TRACK_MB_ID"], None), - "run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None), - "run_time": data_dict.get(KEYS["RUN_TIME"], None), - "album_id": getattr(album, "id", None), - "artist_id": artist.id, - } + track_dict['album_id'] = getattr(album, "id", None) + track_dict['artist_id'] = artist.id track, created = cls.objects.get_or_create(**track_dict) if created: diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 5259e74..7dacf96 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -10,7 +10,7 @@ from django_extensions.db.models import TimeStampedModel from music.models import Track from videos.models import Video -logger = logging.getLogger('__name__') +logger = logging.getLogger(__name__) User = get_user_model() BNULL = {"blank": True, "null": True} VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES') @@ -37,9 +37,12 @@ class Scrobble(TimeStampedModel): @property def percent_played(self) -> int: - return int( - (self.playback_position_ticks / self.media_run_time_ticks) * 100 - ) + if self.playback_position_ticks and self.media_run_time_ticks: + return int( + (self.playback_position_ticks / self.media_run_time_ticks) + * 100 + ) + return 0 @property def media_run_time_ticks(self) -> int: @@ -105,24 +108,24 @@ class Scrobble(TimeStampedModel): @classmethod def create_or_update_for_track( - cls, track: "Track", user_id: int, jellyfin_data: dict + cls, track: "Track", user_id: int, scrobble_data: dict ) -> "Scrobble": - jellyfin_data['track_id'] = track.id - logger.debug( - f"Creating or updating scrobble for track {track}", - {"jellyfin_data": jellyfin_data}, - ) + scrobble_data['track_id'] = track.id scrobble = ( Scrobble.objects.filter(track=track, user_id=user_id) .order_by('-modified') .first() ) + logger.debug( + f"Found existing scrobble for track {track}, updating", + {"scrobble_data": scrobble_data}, + ) - backoff = timezone.now() + timedelta(minutes=TRACK_BACKOFF) + backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF) wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD) return cls.update_or_create( - scrobble, backoff, wait_period, jellyfin_data + scrobble, backoff, wait_period, scrobble_data ) @classmethod @@ -131,11 +134,27 @@ class Scrobble(TimeStampedModel): scrobble: Optional["Scrobble"], backoff, wait_period, - jellyfin_data: dict, + scrobble_data: dict, ) -> Optional["Scrobble"]: + + # Status is a field we get from Mopidy, which refuses to poll us + mopidy_status = scrobble_data.pop('status', None) scrobble_is_stale = False - if scrobble: + if mopidy_status == "stopped": + logger.info(f"Mopidy sent a message to stop {scrobble}") + if not scrobble: + logger.warning( + 'Mopidy sent us a stopped message, without ever starting' + ) + return + + # Mopidy finished a play, scrobble away + scrobble.in_progress = False + scrobble.save(update_fields=['in_progress']) + return scrobble + + if scrobble and not mopidy_status: scrobble_is_finished = ( not scrobble.in_progress and scrobble.modified < backoff ) @@ -147,14 +166,14 @@ class Scrobble(TimeStampedModel): scrobble_is_stale = scrobble.is_stale(backoff, wait_period) - if not scrobble or scrobble_is_stale: + if (not scrobble or scrobble_is_stale) or mopidy_status: # If we default this to "" we can probably remove this - jellyfin_data['scrobble_log'] = "" + scrobble_data['scrobble_log'] = "" scrobble = cls.objects.create( - **jellyfin_data, + **scrobble_data, ) else: - for key, value in jellyfin_data.items(): + for key, value in scrobble_data.items(): setattr(scrobble, key, value) scrobble.save() diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 8cff0fc..7f551e4 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -6,4 +6,5 @@ app_name = 'scrobbles' urlpatterns = [ path('', views.scrobble_endpoint, name='scrobble-list'), path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'), + path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'), ] diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 6789e07..007f85b 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -20,6 +20,8 @@ from scrobbles.models import Scrobble from scrobbles.serializers import ScrobbleSerializer from videos.models import Video +from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS + logger = logging.getLogger(__name__) TRUTHY_VALUES = [ @@ -79,14 +81,35 @@ def jellyfin_websocket(request): track = None video = None - existing_scrobble = False if media_type in JELLYFIN_AUDIO_ITEM_TYPES: if not data_dict.get("Provider_musicbrainztrack", None): logger.error( "No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling" ) return Response({}, status=status.HTTP_400_BAD_REQUEST) - track = Track.find_or_create(data_dict) + + artist_dict = { + 'name': data_dict.get(KEYS["ARTIST_NAME"], None), + 'musicbrainz_id': data_dict.get(KEYS["ARTIST_MB_ID"], None), + } + + album_dict = { + "name": data_dict.get(KEYS["ALBUM_NAME"], None), + "year": data_dict.get(KEYS["YEAR"], ""), + "musicbrainz_id": data_dict.get(KEYS['ALBUM_MB_ID']), + "musicbrainz_releasegroup_id": data_dict.get( + KEYS["RELEASEGROUP_MB_ID"] + ), + "musicbrainz_albumartist_id": data_dict.get(KEYS["ARTIST_MB_ID"]), + } + + track_dict = { + "title": data_dict.get("Name", ""), + "musicbrainz_id": data_dict.get(KEYS["TRACK_MB_ID"], None), + "run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None), + "run_time": data_dict.get(KEYS["RUN_TIME"], None), + } + track = Track.find_or_create(artist_dict, album_dict, track_dict) if media_type in JELLYFIN_VIDEO_ITEM_TYPES: if not data_dict.get("Provider_imdb", None): @@ -102,7 +125,7 @@ def jellyfin_websocket(request): "timestamp": parse(data_dict.get("UtcTimestamp")), "playback_position_ticks": data_dict.get("PlaybackPositionTicks"), "playback_position": data_dict.get("PlaybackPosition"), - "source": data_dict.get('ClientName'), + "source": "Jellyfin", "source_id": data_dict.get('MediaSourceId'), "is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES, } @@ -123,3 +146,53 @@ def jellyfin_websocket(request): return Response( {'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED ) + + +@csrf_exempt +@api_view(['POST']) +def mopidy_websocket(request): + data_dict = request.data + + # For making things easier to build new input processors + if getattr(settings, "DUMP_REQUEST_DATA", False): + json_data = json.dumps(data_dict, indent=4) + + artist_dict = { + "name": data_dict.get("artist", None), + "musicbrainz_id": data_dict.get("musicbrainz_artist_id", None), + } + + album_dict = { + "name": data_dict.get("album"), + "musicbrainz_id": data_dict.get("musicbrainz_album_id"), + } + + track_dict = { + "title": data_dict.get("name"), + "musicbrainz_id": data_dict.get("musicbrainz_track_id"), + "run_time_ticks": data_dict.get("run_time_ticks"), + "run_time": data_dict.get("run_time"), + } + + track = Track.find_or_create(artist_dict, album_dict, track_dict) + + # Now we run off a scrobble + mopidy_data = { + "user_id": request.user.id, + "timestamp": timezone.now(), + "source": "Mopidy", + "status": data_dict.get("status"), + } + + scrobble = None + if track: + scrobble = Scrobble.create_or_update_for_track( + track, request.user.id, mopidy_data + ) + + if not scrobble: + return Response({}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + {'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED + )