From 638be0b56a11b022c6863668b68567e5b9304b3c Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 7 Jan 2023 19:34:11 -0500 Subject: [PATCH] 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. --- vrobbler/apps/music/__init__.py | 0 vrobbler/apps/music/admin.py | 29 ++++ vrobbler/apps/music/apps.py | 5 + vrobbler/apps/music/constants.py | 16 ++ .../apps/music/migrations/0001_initial.py | 156 +++++++++++++++++ vrobbler/apps/music/migrations/__init__.py | 0 vrobbler/apps/music/models.py | 99 +++++++++++ vrobbler/apps/scrobbles/admin.py | 3 +- vrobbler/apps/scrobbles/constants.py | 3 + ...006_scrobble_track_alter_scrobble_video.py | 36 ++++ vrobbler/apps/scrobbles/models.py | 150 +++++++++++++++- vrobbler/apps/scrobbles/views.py | 164 ++++++------------ vrobbler/apps/videos/models.py | 45 +++++ vrobbler/settings.py | 15 ++ .../templates/scrobbles/scrobble_list.html | 43 +++-- 15 files changed, 635 insertions(+), 129 deletions(-) create mode 100644 vrobbler/apps/music/__init__.py create mode 100644 vrobbler/apps/music/admin.py create mode 100644 vrobbler/apps/music/apps.py create mode 100644 vrobbler/apps/music/constants.py create mode 100644 vrobbler/apps/music/migrations/0001_initial.py create mode 100644 vrobbler/apps/music/migrations/__init__.py create mode 100644 vrobbler/apps/music/models.py create mode 100644 vrobbler/apps/scrobbles/constants.py create mode 100644 vrobbler/apps/scrobbles/migrations/0006_scrobble_track_alter_scrobble_video.py diff --git a/vrobbler/apps/music/__init__.py b/vrobbler/apps/music/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/music/admin.py b/vrobbler/apps/music/admin.py new file mode 100644 index 0000000..4e69688 --- /dev/null +++ b/vrobbler/apps/music/admin.py @@ -0,0 +1,29 @@ +from django.contrib import admin + +from music.models import Artist, Album, Track + +@admin.register(Album) +class AlbumAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("name", "year", "musicbrainz_id") + list_filter = ("year",) + ordering = ("name",) + +@admin.register(Artist) +class ArtistAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ("name", "musicbrainz_id") + ordering = ("name",) + +@admin.register(Track) +class TrackAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "title", + "album", + "artist", + "run_time", + "musicbrainz_id", + ) + list_filter = ("album", "artist") + ordering = ("-created",) diff --git a/vrobbler/apps/music/apps.py b/vrobbler/apps/music/apps.py new file mode 100644 index 0000000..d909c7f --- /dev/null +++ b/vrobbler/apps/music/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MusicConfig(AppConfig): + name = 'music' diff --git a/vrobbler/apps/music/constants.py b/vrobbler/apps/music/constants.py new file mode 100644 index 0000000..b08feb1 --- /dev/null +++ b/vrobbler/apps/music/constants.py @@ -0,0 +1,16 @@ +JELLYFIN_POST_KEYS = { + 'ITEM_TYPE': 'ItemType', + 'RUN_TIME_TICKS': 'RunTimeTicks', + 'RUN_TIME': 'RunTime', + 'TITLE': 'Name', + 'TIMESTAMP': 'UtcTimestamp', + 'YEAR': 'Year', + 'PLAYBACK_POSITION_TICKS': 'PlaybackPositionTicks', + 'PLAYBACK_POSITION': 'PlaybackPosition', + 'ARTIST_MB_ID': 'Provider_musicbrainzartist', + 'ALBUM_MB_ID': 'Provider_musicbrainzalbum', + 'RELEASEGROUP_MB_ID': 'Provider_musicbrainzreleasegroup', + 'TRACK_MB_ID': 'Provider_musicbrainztrack', + 'ALBUM_NAME': 'Album', + 'ARTIST_NAME': 'Artist', +} diff --git a/vrobbler/apps/music/migrations/0001_initial.py b/vrobbler/apps/music/migrations/0001_initial.py new file mode 100644 index 0000000..ecbc8d3 --- /dev/null +++ b/vrobbler/apps/music/migrations/0001_initial.py @@ -0,0 +1,156 @@ +# Generated by Django 4.1.5 on 2023-01-07 19:37 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Album', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('name', models.CharField(max_length=255)), + ('year', models.IntegerField()), + ( + 'musicbrainz_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'musicbrainz_releasegroup_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'musicbrainz_albumartist_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Artist', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('name', models.CharField(max_length=255)), + ( + 'musicbrainz_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Track', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ( + 'title', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'musicbrainz_id', + models.CharField(blank=True, max_length=255, null=True), + ), + ( + 'run_time', + models.CharField(blank=True, max_length=8, null=True), + ), + ( + 'run_time_ticks', + models.PositiveBigIntegerField(blank=True, null=True), + ), + ( + 'album', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='music.album', + ), + ), + ( + 'artist', + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + to='music.artist', + ), + ), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/vrobbler/apps/music/migrations/__init__.py b/vrobbler/apps/music/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py new file mode 100644 index 0000000..1acfe42 --- /dev/null +++ b/vrobbler/apps/music/models.py @@ -0,0 +1,99 @@ +import logging +from typing import Dict, Optional +from django.db import models +from django_extensions.db.models import TimeStampedModel +from django.utils.translation import gettext_lazy as _ +from vrobbler.apps.music.constants import JELLYFIN_POST_KEYS as KEYS + +logger = logging.getLogger(__name__) +BNULL = {"blank": True, "null": True} + + +class Album(TimeStampedModel): + name = models.CharField(max_length=255) + year = models.IntegerField() + musicbrainz_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) + + def __str__(self): + return self.name + + +class Artist(TimeStampedModel): + name = models.CharField(max_length=255) + musicbrainz_id = models.CharField(max_length=255, **BNULL) + + def __str__(self): + return self.name + + +class Track(TimeStampedModel): + title = models.CharField(max_length=255, **BNULL) + artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING) + album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL) + musicbrainz_id = models.CharField(max_length=255, **BNULL) + run_time = models.CharField(max_length=8, **BNULL) + run_time_ticks = models.PositiveBigIntegerField(**BNULL) + + def __str__(self): + return f"{self.title} by {self.artist}" + + @classmethod + def find_or_create(cls, data_dict: Dict) -> Optional["Track"]: + """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 + exist. + + """ + artist = data_dict.get(KEYS["ARTIST_NAME"], None) + artist_musicbrainz_id = data_dict.get(KEYS["ARTIST_MB_ID"], None) + if not artist or not artist_musicbrainz_id: + logger.warning( + f"No artist or artist musicbrainz ID found in message from Jellyfin, not scrobbling" + ) + return + artist, artist_created = Artist.objects.get_or_create( + name=artist, musicbrainz_id=artist_musicbrainz_id + ) + if artist_created: + logger.debug(f"Created new album {artist}") + else: + 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) + if album_created: + logger.debug(f"Created new album {album}") + else: + logger.debug(f"Found album {album}") + + 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), + "album_id": getattr(album, "id", None), + "artist_id": artist.id, + } + + track, created = cls.objects.get_or_create(**track_dict) + if created: + logger.debug(f"Created new track: {track}") + else: + logger.debug(f"Found track{track}") + + return track diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 69b29ec..2853272 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -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", diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.py new file mode 100644 index 0000000..5b90052 --- /dev/null +++ b/vrobbler/apps/scrobbles/constants.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"] +JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"] diff --git a/vrobbler/apps/scrobbles/migrations/0006_scrobble_track_alter_scrobble_video.py b/vrobbler/apps/scrobbles/migrations/0006_scrobble_track_alter_scrobble_video.py new file mode 100644 index 0000000..3a5ef9c --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0006_scrobble_track_alter_scrobble_video.py @@ -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', + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 7cdb9dd..5259e74 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -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 diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index b1d6abd..6789e07 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -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) diff --git a/vrobbler/apps/videos/models.py b/vrobbler/apps/videos/models.py index ff4c6fe..8e226f9 100644 --- a/vrobbler/apps/videos/models.py +++ b/vrobbler/apps/videos/models.py @@ -1,7 +1,10 @@ +import logging +from typing import Dict, Tuple from django.db import models from django_extensions.db.models import TimeStampedModel from django.utils.translation import gettext_lazy as _ +logger = logging.getLogger(__name__) BNULL = {"blank": True, "null": True} @@ -50,3 +53,45 @@ class Video(TimeStampedModel): if self.video_type == self.VideoType.TV_EPISODE: return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}" return self.title + + + @classmethod + def find_or_create(cls, data_dict: Dict) -> "Video": + """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 + exist. + + """ + video_dict = { + "title": data_dict.get("Name", ""), + "imdb_id": data_dict.get("Provider_imdb", None), + "video_type": Video.VideoType.MOVIE, + "year": data_dict.get("Year", ""), + "overview": data_dict.get("Overview", None), + "tagline": data_dict.get("Tagline", None), + "run_time_ticks": data_dict.get("RunTimeTicks", None), + "run_time": data_dict.get("RunTime", None), + } + + if data_dict.get("ItemType", "") == "Episode": + series_name = data_dict.get("SeriesName", "") + series, series_created = Series.objects.get_or_create(name=series_name) + if series_created: + logger.debug(f"Created new series {series}") + else: + logger.debug(f"Found series {series}") + video_dict['video_type'] = Video.VideoType.TV_EPISODE + video_dict["tv_series_id"] = series.id + video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None) + video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None) + video_dict["episode_number"] = data_dict.get("EpisodeNumber", "") + video_dict["season_number"] = data_dict.get("SeasonNumber", "") + + + video, created = cls.objects.get_or_create(**video_dict) + if created: + logger.debug(f"Created new video: {video}") + else: + logger.debug(f"Found video {video}") + + return video diff --git a/vrobbler/settings.py b/vrobbler/settings.py index c24580b..8f0ca86 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -37,8 +37,22 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv( "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False ) +# Should we cull old in-progress scrobbles that are beyond the wait period for resuming? DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True) +# Used to dump data coming from srobbling sources, helpful for building new inputs +DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False) + +VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15) +MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 5) + +# If you stop waching or listening to a track, how long should we wait before we +# give up on the old scrobble and start a new one? This could also be considered +# a "continue in progress scrobble" time period. So if you pause the media and +# start again, should it be a new scrobble. +VIDEO_WAIT_PERIOD_DAYS = os.getenv("VROBBLER_VIDEO_WAIT_PERIOD_DAYS", 1) +MUSIC_WAIT_PERIOD_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 1) + TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -70,6 +84,7 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', "scrobbles", "videos", + "music", "rest_framework", "allauth", "allauth.account", diff --git a/vrobbler/templates/scrobbles/scrobble_list.html b/vrobbler/templates/scrobbles/scrobble_list.html index 0dd5de5..9b573ba 100644 --- a/vrobbler/templates/scrobbles/scrobble_list.html +++ b/vrobbler/templates/scrobbles/scrobble_list.html @@ -6,23 +6,44 @@ {% if now_playing_list %}

Now playing

{% for scrobble in now_playing_list %} -
-
{{scrobble.video.title}} - {{scrobble.video}}
-
- {{scrobble.timestamp|date:"D, M j Y"}} | - IMDB -
- -
-
-
+ {% if scrobble.video %} +
+
{{scrobble.video.title}} - {{scrobble.video}}
+
+ {{scrobble.timestamp|date:"D, M j Y"}} | + IMDB +
+ +
+
+
+ {% endif %} + {% if scrobble.track %} +
+
{{scrobble.track.title}} by {{scrobble.track.artist}} from {{scrobble.track.album}}
+
+ {{scrobble.timestamp|date:"D, M j Y"}} | + MusicBrainz +
+ +
+
+
+ {% endif %}
{% endfor %} {% endif %}

Last scrobbles

{% endblock %}