Add rudimentary support for mopidy-webhooks
This commit is contained in:
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal file
18
vrobbler/apps/music/migrations/0002_alter_album_year.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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, album_created = Album.objects.get_or_create(**album_dict)
|
||||||
album_name = data_dict.get(KEYS["ALBUM_NAME"], None)
|
if album_created:
|
||||||
if album_name:
|
logger.debug(f"Created new album {album}")
|
||||||
album_dict = {
|
else:
|
||||||
"name": album_name,
|
logger.debug(f"Found album {album}")
|
||||||
"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 = {
|
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:
|
||||||
|
|||||||
@ -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(
|
if self.playback_position_ticks and self.media_run_time_ticks:
|
||||||
(self.playback_position_ticks / self.media_run_time_ticks) * 100
|
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
|
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()
|
||||||
|
|
||||||
|
|||||||
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user