Add rudimentary support for mopidy-webhooks

This commit is contained in:
2023-01-08 00:26:24 -05:00
parent 638be0b56a
commit 07ad6005c8
5 changed files with 147 additions and 56 deletions

View File

@ -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),
),
]

View File

@ -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) if not artist_dict.get('name') or not artist_dict.get(
artist_musicbrainz_id = data_dict.get(KEYS["ARTIST_MB_ID"], None) 'musicbrainz_id'
if not artist or not artist_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( artist, artist_created = Artist.objects.get_or_create(**artist_dict)
name=artist, musicbrainz_id=artist_musicbrainz_id
)
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) album, album_created = Album.objects.get_or_create(**album_dict)
if album_created: if album_created:
logger.debug(f"Created new album {album}") logger.debug(f"Created new album {album}")
else: else:
logger.debug(f"Found album {album}") logger.debug(f"Found album {album}")
track_dict = { track_dict['album_id'] = getattr(album, "id", None)
"title": data_dict.get("Name", ""), track_dict['artist_id'] = artist.id
"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, created = cls.objects.get_or_create(**track_dict) track, created = cls.objects.get_or_create(**track_dict)
if created: if created:

View File

@ -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:
if self.playback_position_ticks and self.media_run_time_ticks:
return int( return int(
(self.playback_position_ticks / self.media_run_time_ticks) * 100 (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 scrobble_data['track_id'] = track.id
logger.debug(
f"Creating or updating scrobble for track {track}",
{"jellyfin_data": jellyfin_data},
)
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()

View File

@ -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'),
] ]

View File

@ -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
)