瀏覽代碼

Add rudimentary support for mopidy-webhooks

Colin Powell 2 年之前
父節點
當前提交
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):
     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}")
-
-        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)
         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 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()
 

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

+ 76 - 3
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
+    )