Browse Source

Add rudimentary support for mopidy-webhooks

Colin Powell 2 years ago
parent
commit
07ad6005c8

+ 18 - 0
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),
+        ),
+    ]

+ 16 - 36
vrobbler/apps/music/models.py

@@ -11,7 +11,7 @@ BNULL = {"blank": True, "null": True}
 
 
 class Album(TimeStampedModel):
 class Album(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
-    year = models.IntegerField()
+    year = models.IntegerField(**BNULL)
     musicbrainz_id = models.CharField(max_length=255, **BNULL)
     musicbrainz_id = models.CharField(max_length=255, **BNULL)
     musicbrainz_releasegroup_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)
     musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
@@ -40,55 +40,35 @@ class Track(TimeStampedModel):
         return f"{self.title} by {self.artist}"
         return f"{self.title} by {self.artist}"
 
 
     @classmethod
     @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
         """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
         the video and, if need, TV Series, creating both if they don't yet
         exist.
         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(
             logger.warning(
                 f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling"
                 f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling"
             )
             )
             return
             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:
         if artist_created:
             logger.debug(f"Created new album {artist}")
             logger.debug(f"Created new album {artist}")
         else:
         else:
             logger.debug(f"Found album {artist}")
             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}")
-
-        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,
-        }
+        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['album_id'] = getattr(album, "id", None)
+        track_dict['artist_id'] = artist.id
 
 
         track, created = cls.objects.get_or_create(**track_dict)
         track, created = cls.objects.get_or_create(**track_dict)
         if created:
         if created:

+ 37 - 18
vrobbler/apps/scrobbles/models.py

@@ -10,7 +10,7 @@ from django_extensions.db.models import TimeStampedModel
 from music.models import Track
 from music.models import Track
 from videos.models import Video
 from videos.models import Video
 
 
-logger = logging.getLogger('__name__')
+logger = logging.getLogger(__name__)
 User = get_user_model()
 User = get_user_model()
 BNULL = {"blank": True, "null": True}
 BNULL = {"blank": True, "null": True}
 VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
 VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
@@ -37,9 +37,12 @@ class Scrobble(TimeStampedModel):
 
 
     @property
     @property
     def percent_played(self) -> int:
     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
     @property
     def media_run_time_ticks(self) -> int:
     def media_run_time_ticks(self) -> int:
@@ -105,24 +108,24 @@ class Scrobble(TimeStampedModel):
 
 
     @classmethod
     @classmethod
     def create_or_update_for_track(
     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":
     ) -> "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 = (
             Scrobble.objects.filter(track=track, user_id=user_id)
             Scrobble.objects.filter(track=track, user_id=user_id)
             .order_by('-modified')
             .order_by('-modified')
             .first()
             .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)
         wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
 
 
         return cls.update_or_create(
         return cls.update_or_create(
-            scrobble, backoff, wait_period, jellyfin_data
+            scrobble, backoff, wait_period, scrobble_data
         )
         )
 
 
     @classmethod
     @classmethod
@@ -131,11 +134,27 @@ class Scrobble(TimeStampedModel):
         scrobble: Optional["Scrobble"],
         scrobble: Optional["Scrobble"],
         backoff,
         backoff,
         wait_period,
         wait_period,
-        jellyfin_data: dict,
+        scrobble_data: dict,
     ) -> Optional["Scrobble"]:
     ) -> 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
         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 = (
             scrobble_is_finished = (
                 not scrobble.in_progress and scrobble.modified < backoff
                 not scrobble.in_progress and scrobble.modified < backoff
             )
             )
@@ -147,14 +166,14 @@ class Scrobble(TimeStampedModel):
 
 
             scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
             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
             # If we default this to "" we can probably remove this
-            jellyfin_data['scrobble_log'] = ""
+            scrobble_data['scrobble_log'] = ""
             scrobble = cls.objects.create(
             scrobble = cls.objects.create(
-                **jellyfin_data,
+                **scrobble_data,
             )
             )
         else:
         else:
-            for key, value in jellyfin_data.items():
+            for key, value in scrobble_data.items():
                 setattr(scrobble, key, value)
                 setattr(scrobble, key, value)
             scrobble.save()
             scrobble.save()
 
 

+ 1 - 0
vrobbler/apps/scrobbles/urls.py

@@ -6,4 +6,5 @@ app_name = 'scrobbles'
 urlpatterns = [
 urlpatterns = [
     path('', views.scrobble_endpoint, name='scrobble-list'),
     path('', views.scrobble_endpoint, name='scrobble-list'),
     path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
     path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
+    path('mopidy/', views.mopidy_websocket, name='mopidy-websocket'),
 ]
 ]

+ 76 - 3
vrobbler/apps/scrobbles/views.py

@@ -20,6 +20,8 @@ from scrobbles.models import Scrobble
 from scrobbles.serializers import ScrobbleSerializer
 from scrobbles.serializers import ScrobbleSerializer
 from videos.models import Video
 from videos.models import Video
 
 
+from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 TRUTHY_VALUES = [
 TRUTHY_VALUES = [
@@ -79,14 +81,35 @@ def jellyfin_websocket(request):
 
 
     track = None
     track = None
     video = None
     video = None
-    existing_scrobble = False
     if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
     if media_type in JELLYFIN_AUDIO_ITEM_TYPES:
         if not data_dict.get("Provider_musicbrainztrack", None):
         if not data_dict.get("Provider_musicbrainztrack", None):
             logger.error(
             logger.error(
                 "No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
                 "No MBrainz Track ID received. This is likely because all metadata is bad, not scrobbling"
             )
             )
             return Response({}, status=status.HTTP_400_BAD_REQUEST)
             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 media_type in JELLYFIN_VIDEO_ITEM_TYPES:
         if not data_dict.get("Provider_imdb", None):
         if not data_dict.get("Provider_imdb", None):
@@ -102,7 +125,7 @@ def jellyfin_websocket(request):
         "timestamp": parse(data_dict.get("UtcTimestamp")),
         "timestamp": parse(data_dict.get("UtcTimestamp")),
         "playback_position_ticks": data_dict.get("PlaybackPositionTicks"),
         "playback_position_ticks": data_dict.get("PlaybackPositionTicks"),
         "playback_position": data_dict.get("PlaybackPosition"),
         "playback_position": data_dict.get("PlaybackPosition"),
-        "source": data_dict.get('ClientName'),
+        "source": "Jellyfin",
         "source_id": data_dict.get('MediaSourceId'),
         "source_id": data_dict.get('MediaSourceId'),
         "is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
         "is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
     }
     }
@@ -123,3 +146,53 @@ def jellyfin_websocket(request):
     return Response(
     return Response(
         {'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
         {'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
+    )