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:
@ -6,8 +6,9 @@ from scrobbles.models import Scrobble
|
||||
class ScrobbleAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "timestamp"
|
||||
list_display = (
|
||||
"video",
|
||||
"timestamp",
|
||||
"video",
|
||||
"track",
|
||||
"source",
|
||||
"playback_position",
|
||||
"in_progress",
|
||||
|
||||
3
vrobbler/apps/scrobbles/constants.py
Normal file
3
vrobbler/apps/scrobbles/constants.py
Normal file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-07 20:03
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0001_initial'),
|
||||
('videos', '0003_alter_video_run_time_ticks'),
|
||||
('scrobbles', '0005_alter_scrobble_playback_position_ticks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scrobble',
|
||||
name='track',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='music.track',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scrobble',
|
||||
name='video',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to='videos.video',
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -5,17 +5,20 @@ from datetime import datetime, timedelta
|
||||
from dateutil.parser import parse
|
||||
from django.conf import settings
|
||||
from django.db.models.fields import timezone
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.list import ListView
|
||||
from music.models import Track
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from scrobbles.constants import (
|
||||
JELLYFIN_AUDIO_ITEM_TYPES,
|
||||
JELLYFIN_VIDEO_ITEM_TYPES,
|
||||
)
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.serializers import ScrobbleSerializer
|
||||
from videos.models import Series, Video
|
||||
from vrobbler.settings import DELETE_STALE_SCROBBLES
|
||||
from django.utils import timezone
|
||||
from videos.models import Video
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -38,11 +41,11 @@ class RecentScrobbleList(ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
data = super().get_context_data(**kwargs)
|
||||
now = timezone.now()
|
||||
last_ten_minutes = timezone.now() - timedelta(minutes=10)
|
||||
last_three_minutes = timezone.now() - timedelta(minutes=3)
|
||||
# Find scrobbles from the last 10 minutes
|
||||
data['now_playing_list'] = Scrobble.objects.filter(
|
||||
in_progress=True,
|
||||
timestamp__gte=last_ten_minutes,
|
||||
timestamp__gte=last_three_minutes,
|
||||
timestamp__lte=now,
|
||||
)
|
||||
return data
|
||||
@ -66,122 +69,57 @@ def scrobble_endpoint(request):
|
||||
@api_view(['POST'])
|
||||
def jellyfin_websocket(request):
|
||||
data_dict = request.data
|
||||
media_type = data_dict["ItemType"]
|
||||
imdb_id = data_dict.get("Provider_imdb", None)
|
||||
if not imdb_id:
|
||||
logger.error(
|
||||
"No IMDB ID received. This is likely because all metadata is bad, not scrobbling"
|
||||
)
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Check if it's a TV Episode
|
||||
video_dict = {
|
||||
"title": data_dict.get("Name", ""),
|
||||
"imdb_id": imdb_id,
|
||||
"video_type": Video.VideoType.MOVIE,
|
||||
"year": data_dict.get("Year", ""),
|
||||
}
|
||||
if media_type == 'Episode':
|
||||
series_name = data_dict["SeriesName"]
|
||||
series, series_created = Series.objects.get_or_create(name=series_name)
|
||||
|
||||
video_dict['video_type'] = Video.VideoType.TV_EPISODE
|
||||
video_dict["tv_series_id"] = series.id
|
||||
video_dict["episode_number"] = data_dict.get("EpisodeNumber", "")
|
||||
video_dict["season_number"] = data_dict.get("SeasonNumber", "")
|
||||
video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
|
||||
video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None)
|
||||
# For making things easier to build new input processors
|
||||
if getattr(settings, "DUMP_REQUEST_DATA", False):
|
||||
json_data = json.dumps(data_dict, indent=4)
|
||||
logger.debug(f"{json_data}")
|
||||
|
||||
video, video_created = Video.objects.get_or_create(**video_dict)
|
||||
media_type = data_dict.get("ItemType", "")
|
||||
|
||||
if video_created:
|
||||
video.overview = data_dict["Overview"]
|
||||
video.tagline = data_dict["Tagline"]
|
||||
video.run_time_ticks = data_dict["RunTimeTicks"]
|
||||
video.run_time = data_dict["RunTime"]
|
||||
video.save()
|
||||
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)
|
||||
|
||||
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
|
||||
timestamp = parse(data_dict["UtcTimestamp"])
|
||||
scrobble_dict = {
|
||||
'video_id': video.id,
|
||||
'user_id': request.user.id,
|
||||
'in_progress': True,
|
||||
jellyfin_data = {
|
||||
"user_id": request.user.id,
|
||||
"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_id": data_dict.get('MediaSourceId'),
|
||||
"is_paused": data_dict.get("IsPaused") in TRUTHY_VALUES,
|
||||
}
|
||||
|
||||
existing_scrobble = (
|
||||
Scrobble.objects.filter(video=video, user_id=request.user.id)
|
||||
.order_by('-modified')
|
||||
.first()
|
||||
)
|
||||
|
||||
minutes_from_now = timezone.now() + timedelta(minutes=15)
|
||||
a_day_from_now = timezone.now() + timedelta(days=1)
|
||||
|
||||
existing_finished_scrobble = (
|
||||
existing_scrobble
|
||||
and not existing_scrobble.in_progress
|
||||
and existing_scrobble.modified < minutes_from_now
|
||||
)
|
||||
existing_in_progress_scrobble = (
|
||||
existing_scrobble
|
||||
and existing_scrobble.in_progress
|
||||
and existing_scrobble.modified > a_day_from_now
|
||||
)
|
||||
delete_stale_scrobbles = getattr(settings, "DELETE_STALE_SCROBBLES", True)
|
||||
|
||||
if existing_finished_scrobble:
|
||||
logger.info(
|
||||
'Found a scrobble for this video less than 15 minutes ago, holding off scrobbling again'
|
||||
scrobble = None
|
||||
if video:
|
||||
scrobble = Scrobble.create_or_update_for_video(
|
||||
video, request.user.id, jellyfin_data
|
||||
)
|
||||
return Response(video_dict, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Check if found in progress scrobble is more than a day old
|
||||
if existing_in_progress_scrobble:
|
||||
logger.info(
|
||||
'Found a scrobble for this video more than a day old, creating a new scrobble'
|
||||
)
|
||||
scrobble = existing_in_progress_scrobble
|
||||
scrobble_created = False
|
||||
else:
|
||||
if existing_in_progress_scrobble and delete_stale_scrobbles:
|
||||
existing_in_progress_scrobble.delete()
|
||||
scrobble, scrobble_created = Scrobble.objects.get_or_create(
|
||||
**scrobble_dict
|
||||
if track:
|
||||
scrobble = Scrobble.create_or_update_for_track(
|
||||
track, request.user.id, jellyfin_data
|
||||
)
|
||||
|
||||
if scrobble_created:
|
||||
# If we newly created this, capture the client we're watching from
|
||||
scrobble.source = data_dict['ClientName']
|
||||
scrobble.source_id = data_dict['MediaSourceId']
|
||||
scrobble.scrobble_log = ""
|
||||
if not scrobble:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Update a found scrobble with new position and timestamp
|
||||
scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
|
||||
scrobble.playback_position = data_dict["PlaybackPosition"]
|
||||
scrobble.timestamp = parse(data_dict["UtcTimestamp"])
|
||||
scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
|
||||
scrobble.save(
|
||||
update_fields=[
|
||||
'playback_position_ticks',
|
||||
'playback_position',
|
||||
'timestamp',
|
||||
'is_paused',
|
||||
]
|
||||
return Response(
|
||||
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
# 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 = video.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'])
|
||||
logger.debug(f"You are {scrobble.percent_played}% through {video}")
|
||||
|
||||
return Response(video_dict, status=status.HTTP_201_CREATED)
|
||||
|
||||
Reference in New Issue
Block a user