Dramatically simplify the scrobblig code

This commit is contained in:
2023-01-12 21:33:45 -05:00
parent 507b3aaaf2
commit 3f8b29f5ee
13 changed files with 273 additions and 199 deletions

View File

@ -10,10 +10,11 @@ class ScrobbleAdmin(admin.ModelAdmin):
"timestamp",
"media_name",
"media_type",
"playback_percent",
"source",
"playback_position",
"in_progress",
"is_paused",
"played_to_completion",
)
list_filter = ("is_paused", "in_progress", "source", "track__artist")
ordering = ("-timestamp",)
@ -33,3 +34,6 @@ class ScrobbleAdmin(admin.ModelAdmin):
return "Track"
if obj.podcast_episode:
return "Podcast"
def playback_percent(self, obj):
return obj.percent_played

View File

@ -45,7 +45,7 @@ class Scrobble(TimeStampedModel):
if (
self.playback_position_ticks
and self.media_obj.run_time_ticks
and source != 'Mopidy'
and self.source != 'Mopidy'
):
return int(
(self.playback_position_ticks / self.media_obj.run_time_ticks)
@ -61,26 +61,6 @@ class Scrobble(TimeStampedModel):
return 0
def is_stale(self, backoff, wait_period) -> bool:
scrobble_is_stale = self.in_progress and self.modified > wait_period
# Check if found in progress scrobble is more than a day old
if scrobble_is_stale:
logger.info(
'Found a in-progress scrobble for this item more than a day old, creating a new scrobble'
)
delete_stale_scrobbles = getattr(
settings, "DELETE_STALE_SCROBBLES", True
)
if delete_stale_scrobbles:
logger.info(
'Deleting {scrobble} that has been in-progress too long'
)
self.delete()
return scrobble_is_stale
@property
def media_obj(self):
media_obj = None
@ -171,60 +151,31 @@ class Scrobble(TimeStampedModel):
) -> 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_status = scrobble_data.pop('mopidy_status', None)
if not scrobble_status:
scrobble_status = scrobble_data.pop('jellyfin_status', None)
if not scrobble_status:
logger.warning(
f"No status update found in message, not scrobbling"
)
return
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
if scrobble:
logger.debug(f"Updating scrobble ticks")
scrobble.playback_position_ticks = scrobble_data.get(
"playback_position_ticks"
)
scrobble.save(update_fields=['playback_position_ticks'])
scrobble.update_ticks(scrobble_data)
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
# On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped":
return scrobble.stop()
# Mopidy finished a play, scrobble away
scrobble.in_progress = False
scrobble.played_to_completion = True
scrobble.save(
update_fields=['in_progress', 'played_to_completion']
)
return scrobble
# On pause, set is_paused and stop scrobbling
if scrobble_status == "paused":
return scrobble.pause()
if mopidy_status == "paused":
logger.info(f"Mopidy sent a message to pause {scrobble}")
if not scrobble:
logger.info("Message to pause while not started, ignoring")
return
if scrobble.is_paused:
logger.info("Message to pause while paused, ignoring")
return
if scrobble_status == "resumed":
return scrobble.resume()
# Mopidy finished a play, scrobble away
scrobble.is_paused = True
scrobble.save(update_fields=["is_paused"])
scrobble = check_scrobble_for_finish(scrobble)
return scrobble
if mopidy_status == "resumed":
logger.info(f"Mopidy sent a message to resume {scrobble}")
if not scrobble:
logger.info("Message to resume while not started, ignoring")
return
if not scrobble.is_paused:
logger.info("Message to resume while not paused, resuming")
# Mopidy finished a play, scrobble away
scrobble.is_paused = False
scrobble.save(update_fields=["is_paused"])
return scrobble
if scrobble and not mopidy_status:
# We're not changing the scrobble, but we don't want to walk over an existing one
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
@ -234,9 +185,7 @@ class Scrobble(TimeStampedModel):
)
return
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
if (not scrobble or scrobble_is_stale) or mopidy_status:
if not scrobble:
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
@ -252,3 +201,37 @@ class Scrobble(TimeStampedModel):
scrobble = check_scrobble_for_finish(scrobble)
return scrobble
def stop(self):
if not self.in_progress:
logger.warning("Scrobble already stopped")
return
self.in_progress = False
self.save(update_fields=['in_progress'])
return check_scrobble_for_finish(self)
def pause(self):
if self.is_paused:
logger.warning("Scrobble already paused")
return
self.is_paused = True
self.save(update_fields=["is_paused"])
return check_scrobble_for_finish(self)
def resume(self):
if self.is_paused or not self.in_progress:
self.is_paused = False
self.in_progress = True
return self.save(update_fields=["is_paused", "in_progress"])
return self
def update_ticks(self, data):
self.playback_position_ticks = data.get("playback_position_ticks")
self.playback_position = data.get("playback_position")
logger.debug(
f"Updating scrobble ticks to {self.playback_position_ticks}"
)
self.save(
update_fields=['playback_position_ticks', 'playback_position']
)
return self

View File

@ -1,11 +1,14 @@
import logging
from typing import Optional
from dateutil.parser import parse
from django.utils import timezone
from music.constants import JELLYFIN_POST_KEYS
from music.models import Track
from podcasts.models import Episode
from scrobbles.models import Scrobble
from scrobbles.utils import parse_mopidy_uri
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
from videos.models import Video
logger = logging.getLogger(__name__)
@ -41,7 +44,7 @@ def mopidy_scrobble_podcast(
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"status": data_dict.get("status"),
"mopidy_status": data_dict.get("status"),
}
scrobble = None
@ -79,10 +82,11 @@ def mopidy_scrobble_track(
"timestamp": timezone.now(),
"playback_position_ticks": data_dict.get("playback_time_ticks"),
"source": "Mopidy",
"status": data_dict.get("status"),
"mopidy_status": data_dict.get("status"),
}
scrobble = None
if track:
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
@ -91,3 +95,84 @@ def mopidy_scrobble_track(
track, user_id, mopidy_data
)
return scrobble
def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
jellyfin_status = "resumed"
if data_dict.get("IsPaused"):
jellyfin_status = "paused"
if data_dict.get("PlayedToCompletion"):
jellyfin_status = "stopped"
return {
"user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
// 10000,
"playback_position": convert_to_seconds(
data_dict.get("PlaybackPosition")
),
"source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status,
}
def jellyfin_scrobble_track(
data_dict: dict, user_id: Optional[int]
) -> Optional[Scrobble]:
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
artist_dict = {
'name': data_dict.get(JELLYFIN_POST_KEYS["ARTIST_NAME"], None),
'musicbrainz_id': data_dict.get(
JELLYFIN_POST_KEYS["ARTIST_MB_ID"], None
),
}
album_dict = {
"name": data_dict.get(JELLYFIN_POST_KEYS["ALBUM_NAME"], None),
"musicbrainz_id": data_dict.get(JELLYFIN_POST_KEYS['ALBUM_MB_ID']),
}
# Convert ticks from Jellyfin from microseconds to nanoseconds
# Ain't nobody got time for nanoseconds
track_dict = {
"title": data_dict.get("Name", ""),
"run_time_ticks": data_dict.get(
JELLYFIN_POST_KEYS["RUN_TIME_TICKS"], None
)
// 10000,
"run_time": convert_to_seconds(
data_dict.get(JELLYFIN_POST_KEYS["RUN_TIME"], None)
),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
if not track.musicbrainz_id:
track.musicbrainz_id = data_dict.get(
JELLYFIN_POST_KEYS["TRACK_MB_ID"], None
)
track.save()
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_track(track, user_id, scrobble_dict)
def jellyfin_scrobble_video(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
video = Video.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)

View File

@ -1,5 +1,5 @@
import logging
from typing import Any
from typing import Any, Optional
from urllib.parse import unquote
from dateutil.parser import ParserError, parse
@ -61,26 +61,19 @@ def parse_mopidy_uri(uri: str) -> dict:
def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 95)
if scrobble.video:
completion_percent = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
if scrobble.podcast_episode:
completion_percent = getattr(
settings, "PODCAST_COMPLETION_PERCENT", 25
)
logger.debug(f"Completion set to {completion_percent}")
if scrobble.percent_played >= completion_percent:
logger.debug(
f"{scrobble} meets completion goal of {completion_percent}, finishing"
)
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.playback_position_ticks = scrobble.media_obj.run_time_ticks
scrobble.played_to_completion = True
scrobble.save(
update_fields=[
"in_progress",
"is_paused",
"playback_position_ticks",
]
update_fields=["in_progress", "is_paused", "played_to_completion"]
)
if scrobble.percent_played % 5 == 0:

View File

@ -18,16 +18,22 @@ from scrobbles.constants import (
JELLYFIN_VIDEO_ITEM_TYPES,
)
from scrobbles.models import Scrobble
from scrobbles.scrobblers import (
jellyfin_scrobble_track,
jellyfin_scrobble_video,
mopidy_scrobble_podcast,
mopidy_scrobble_track,
)
from scrobbles.serializers import ScrobbleSerializer
from scrobbles.utils import convert_to_seconds
from videos.models import Video
from vrobbler.apps.music.aggregators import (
scrobble_counts,
top_artists,
top_tracks,
week_of_scrobbles,
)
from scrobbles.scrobblers import mopidy_scrobble_podcast, mopidy_scrobble_track
logger = logging.getLogger(__name__)
@ -55,7 +61,6 @@ class RecentScrobbleList(ListView):
data['now_playing_list'] = Scrobble.objects.filter(
in_progress=True,
is_paused=False,
timestamp__gte=last_eight_minutes,
timestamp__lte=now,
)
data['video_scrobble_list'] = Scrobble.objects.filter(
@ -98,79 +103,14 @@ def jellyfin_websocket(request):
json_data = json.dumps(data_dict, indent=4)
logger.debug(f"{json_data}")
scrobble = None
media_type = data_dict.get("ItemType", "")
track = None
video = None
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)
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"]),
}
# Convert ticks from Jellyfin from microseconds to nanoseconds
# Ain't nobody got time for nanoseconds
track_dict = {
"title": data_dict.get("Name", ""),
"run_time_ticks": data_dict.get(KEYS["RUN_TIME_TICKS"], None)
// 10000,
"run_time": convert_to_seconds(
data_dict.get(KEYS["RUN_TIME"], None)
),
}
track = Track.find_or_create(artist_dict, album_dict, track_dict)
scrobble = jellyfin_scrobble_track(data_dict, request.user.id)
if media_type in JELLYFIN_VIDEO_ITEM_TYPES:
if not data_dict.get("Provider_imdb", None):
logger.error(
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return Response({}, status=status.HTTP_400_BAD_REQUEST)
video = Video.find_or_create(data_dict)
# Now we run off a scrobble
jellyfin_data = {
"user_id": request.user.id,
"timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": data_dict.get("PlaybackPositionTicks")
// 10000,
"playback_position": convert_to_seconds(
data_dict.get("PlaybackPosition")
),
"source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'),
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
}
scrobble = None
if video:
scrobble = Scrobble.create_or_update_for_video(
video, request.user.id, jellyfin_data
)
if track:
# Prefer Mopidy MD IDs to Jellyfin, so skip if we already have one
if not track.musicbrainz_id:
track.musicbrainz_id = data_dict.get(KEYS["TRACK_MB_ID"], None)
track.save()
scrobble = Scrobble.create_or_update_for_track(
track, request.user.id, jellyfin_data
)
scrobble = jellyfin_scrobble_video(data_dict, request.user.id)
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)