Refactor scrobbling code and add Music

If you send Track data from the Jellyfin Webhook plugin, we'll do the
right thing with it. Lots more to do to clean this up, but it also
involved moduralizing the code for scrobbling so it's a little simpler
to understand what's going on.
This commit is contained in:
2023-01-07 19:34:11 -05:00
parent 9c0d85c5d2
commit 638be0b56a
15 changed files with 635 additions and 129 deletions

View File

@ -1,15 +1,27 @@
import logging
from datetime import timedelta
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from music.models import Track
from videos.models import Video
logger = logging.getLogger('__name__')
User = get_user_model()
BNULL = {"blank": True, "null": True}
VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
class Scrobble(TimeStampedModel):
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
@ -26,8 +38,138 @@ class Scrobble(TimeStampedModel):
@property
def percent_played(self) -> int:
return int(
(self.playback_position_ticks / self.video.run_time_ticks) * 100
(self.playback_position_ticks / self.media_run_time_ticks) * 100
)
@property
def media_run_time_ticks(self) -> int:
if self.video:
return self.video.run_time_ticks
if self.track:
return self.track.run_time_ticks
# this is hacky, but want to avoid divide by zero
return 1
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
def __str__(self):
return f"Scrobble of {self.video} {self.timestamp.year}-{self.timestamp.month}"
media = None
if self.video:
media = self.video
if self.track:
media = self.track
return (
f"Scrobble of {media} {self.timestamp.year}-{self.timestamp.month}"
)
@classmethod
def create_or_update_for_video(
cls, video: "Video", user_id: int, jellyfin_data: dict
) -> "Scrobble":
jellyfin_data['video_id'] = video.id
logger.debug(
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
)
scrobble = (
Scrobble.objects.filter(video=video, user_id=user_id)
.order_by('-modified')
.first()
)
# Backoff is how long until we consider this a new scrobble
backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF)
wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, jellyfin_data
)
@classmethod
def create_or_update_for_track(
cls, track: "Track", user_id: int, jellyfin_data: dict
) -> "Scrobble":
jellyfin_data['track_id'] = track.id
logger.debug(
f"Creating or updating scrobble for track {track}",
{"jellyfin_data": jellyfin_data},
)
scrobble = (
Scrobble.objects.filter(track=track, user_id=user_id)
.order_by('-modified')
.first()
)
backoff = timezone.now() + timedelta(minutes=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, jellyfin_data
)
@classmethod
def update_or_create(
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
jellyfin_data: dict,
) -> Optional["Scrobble"]:
scrobble_is_stale = False
if scrobble:
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
if scrobble_is_finished:
logger.info(
'Found a very recent scrobble for this item, holding off scrobbling again'
)
return
scrobble_is_stale = scrobble.is_stale(backoff, wait_period)
if not scrobble or scrobble_is_stale:
# If we default this to "" we can probably remove this
jellyfin_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**jellyfin_data,
)
else:
for key, value in jellyfin_data.items():
setattr(scrobble, key, value)
scrobble.save()
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
if scrobble.percent_played >= getattr(
settings, "PERCENT_FOR_COMPLETION", 95
):
scrobble.in_progress = False
scrobble.playback_position_ticks = scrobble.media_run_time_ticks
scrobble.save()
if scrobble.percent_played % 5 == 0:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
return scrobble