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 %}