From fe47d916e94408f0e7909eca4ab286b43ec8a893 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 5 Jan 2023 01:35:12 -0500 Subject: [PATCH] Add templates and clean up views for scrobbling Biggest thing here is adding the ability to scrobble until the video is 95% done and then not scrobble the same video file again for 15 minutes. This seems hacky, but in practice works pretty well, so long as you don't monkey around with the 95% completion thing. Unlike music, videos are generally watched all the way through. --- poetry.lock | 17 +++- pyproject.toml | 1 + scrobbles/admin.py | 8 +- scrobbles/migrations/0002_scrobble_counted.py | 18 +++++ ...e_scrobble_counted_scrobble_in_progress.py | 22 +++++ scrobbles/models.py | 11 ++- .../templates/scrobbles/scrobble_list.html | 25 ------ scrobbles/views.py | 80 ++++++++++++++----- templates/base.html | 18 ++--- templates/scrobbles/scrobble_list.html | 11 +++ videos/models.py | 13 +++ vrobbler/negotiation.py | 15 ++++ vrobbler/settings.py | 30 +++---- vrobbler/urls.py | 1 + 14 files changed, 194 insertions(+), 76 deletions(-) create mode 100644 scrobbles/migrations/0002_scrobble_counted.py create mode 100644 scrobbles/migrations/0003_remove_scrobble_counted_scrobble_in_progress.py delete mode 100644 scrobbles/templates/scrobbles/scrobble_list.html create mode 100644 templates/scrobbles/scrobble_list.html create mode 100644 vrobbler/negotiation.py diff --git a/poetry.lock b/poetry.lock index 7073218..3efd83b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1332,6 +1332,17 @@ python-versions = ">=3.6" [package.extras] watchdog = ["watchdog"] +[[package]] +name = "whitenoise" +version = "6.3.0" +description = "Radically simplified static file serving for WSGI applications" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +brotli = ["Brotli"] + [[package]] name = "wsproto" version = "1.2.0" @@ -1358,7 +1369,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "d1a46b6435a93d8ccaffd853d3d96e3a317db0b2d19e435323d5100ad37ac1d5" +content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71" [metadata.files] amqp = [ @@ -2091,6 +2102,10 @@ werkzeug = [ {file = "Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8"}, {file = "Werkzeug-2.0.3.tar.gz", hash = "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c"}, ] +whitenoise = [ + {file = "whitenoise-6.3.0-py3-none-any.whl", hash = "sha256:cf8ecf56d86ba1c734fdb5ef6127312e39e92ad5947fef9033dc9e43ba2777d9"}, + {file = "whitenoise-6.3.0.tar.gz", hash = "sha256:fe0af31504ab08faa1ec7fc02845432096e40cc1b27e6a7747263d7b30fb51fa"}, +] wsproto = [ {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, diff --git a/pyproject.toml b/pyproject.toml index d8cac12..0d8d45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ django-taggit = "^2.1.0" django-markdownify = "^0.9.1" gunicorn = "^20.1.0" django-simple-history = "^3.1.1" +whitenoise = "^6.3.0" [tool.poetry.dev-dependencies] Werkzeug = "2.0.3" diff --git a/scrobbles/admin.py b/scrobbles/admin.py index 6ddbd72..69b29ec 100644 --- a/scrobbles/admin.py +++ b/scrobbles/admin.py @@ -5,7 +5,13 @@ from scrobbles.models import Scrobble class ScrobbleAdmin(admin.ModelAdmin): date_hierarchy = "timestamp" - list_display = ("video", "timestamp", "source", "playback_position") + list_display = ( + "video", + "timestamp", + "source", + "playback_position", + "in_progress", + ) list_filter = ("video",) ordering = ("-timestamp",) diff --git a/scrobbles/migrations/0002_scrobble_counted.py b/scrobbles/migrations/0002_scrobble_counted.py new file mode 100644 index 0000000..7c23474 --- /dev/null +++ b/scrobbles/migrations/0002_scrobble_counted.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-01-04 23:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scrobbles', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='scrobble', + name='counted', + field=models.BooleanField(default=False), + ), + ] diff --git a/scrobbles/migrations/0003_remove_scrobble_counted_scrobble_in_progress.py b/scrobbles/migrations/0003_remove_scrobble_counted_scrobble_in_progress.py new file mode 100644 index 0000000..76ea6fb --- /dev/null +++ b/scrobbles/migrations/0003_remove_scrobble_counted_scrobble_in_progress.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.5 on 2023-01-04 23:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scrobbles', '0002_scrobble_counted'), + ] + + operations = [ + migrations.RemoveField( + model_name='scrobble', + name='counted', + ), + migrations.AddField( + model_name='scrobble', + name='in_progress', + field=models.BooleanField(default=True), + ), + ] diff --git a/scrobbles/models.py b/scrobbles/models.py index 06b92eb..108dac0 100644 --- a/scrobbles/models.py +++ b/scrobbles/models.py @@ -20,6 +20,13 @@ class Scrobble(TimeStampedModel): played_to_completion = models.BooleanField(default=False) source = models.CharField(max_length=255, **BNULL) source_id = models.TextField(**BNULL) + in_progress = models.BooleanField(default=True) - def percent_played(self): - return int((self.playback_position_ticks / video.run_time_ticks) * 100) + @property + def percent_played(self) -> int: + return int( + (self.playback_position_ticks / self.video.run_time_ticks) * 100 + ) + + def __str__(self): + return f"Scrobble of {self.video} {self.timestamp.year}-{self.timestamp.month}" diff --git a/scrobbles/templates/scrobbles/scrobble_list.html b/scrobbles/templates/scrobbles/scrobble_list.html deleted file mode 100644 index 332dc7e..0000000 --- a/scrobbles/templates/scrobbles/scrobble_list.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - Untitled - - - - - - - - - - - - - diff --git a/scrobbles/views.py b/scrobbles/views.py index e989a4f..f8738f6 100644 --- a/scrobbles/views.py +++ b/scrobbles/views.py @@ -1,7 +1,10 @@ import json import logging +from datetime import datetime, timedelta -import dateutil +from dateutil.parser import parse +from django.conf import settings +from django.db.models.fields import timezone from django.views.decorators.csrf import csrf_exempt from django.views.generic.list import ListView from rest_framework import status @@ -30,6 +33,9 @@ TRUTHY_VALUES = [ class RecentScrobbleList(ListView): model = Scrobble + def get_queryset(self): + return Scrobble.objects.filter(in_progress=False) + @csrf_exempt @api_view(['GET', 'POST']) @@ -41,58 +47,92 @@ def scrobble_list(request): return Response(serializer.data) elif request.method == 'POST': - data_dict = json.loads(request.data["_content"]) + data_dict = request.data media_type = data_dict["ItemType"] # Check if it's a TV Episode video_dict = { "title": data_dict["Name"], "imdb_id": data_dict["Provider_imdb"], "video_type": Video.VideoType.MOVIE, + "year": data_dict["Year"], } if media_type == 'Episode': series_name = data_dict["SeriesName"] - series = Series.objects.get_or_create(name=series_name) + 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_num"] = data_dict["EpisodeNumber"] - video_dict["season_num"] = data_dict["SeasonNumber"] - video_dict["tvdb_id"] = data_dict["Provider_tvdb"] - video_dict["tvrage_id"] = data_dict["Provider_tvrage"] + video_dict["episode_number"] = data_dict["EpisodeNumber"] + video_dict["season_number"] = data_dict["SeasonNumber"] + video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None) + video_dict["tvrage_id"] = data_dict.get("Provider_tvrage", None) - video, _created = Video.objects.get_or_create(**video_dict) + video, video_created = Video.objects.get_or_create(**video_dict) - video.year = data_dict["Year"] - 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() + 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() # 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, } + + existing_finished_scrobble = ( + Scrobble.objects.filter( + video=video, user_id=request.user.id, in_progress=False + ) + .order_by('-modified') + .first() + ) + + minutes_from_now = timezone.now() + timedelta(minutes=15) + + if ( + existing_finished_scrobble + and existing_finished_scrobble.modified < minutes_from_now + ): + logger.info( + 'Found a scrobble for this video less than 15 minutes ago, holding off scrobbling again' + ) + return Response(video_dict, status=status.HTTP_204_NO_CONTENT) + scrobble, scrobble_created = Scrobble.objects.get_or_create( **scrobble_dict ) 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'] + else: + last_tick = scrobble.playback_position_ticks # Update a found scrobble with new position and timestamp scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"] scrobble.playback_position = data_dict["PlaybackPosition"] - scrobble.timestamp = dateutil.parser.parse(data_dict["UtcTimestamp"]) + scrobble.timestamp = parse(data_dict["UtcTimestamp"]) scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES - scrobble.played_to_completion = ( - data_dict["PlayedToCompletion"] in TRUTHY_VALUES - ) scrobble.save() - logger.info(f"You are {scrobble.percent_played}% through {video}") + # 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: + logger.info(f"You are {scrobble.percent_played}% through {video}") return Response(video_dict, status=status.HTTP_201_CREATED) - # return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/templates/base.html b/templates/base.html index 1a48590..40befee 100644 --- a/templates/base.html +++ b/templates/base.html @@ -85,40 +85,32 @@ - {% if request.user.is_authenticated %} + {% if request.user.is_authenticated %} Logout {% else %} Login diff --git a/templates/scrobbles/scrobble_list.html b/templates/scrobbles/scrobble_list.html new file mode 100644 index 0000000..ce67fac --- /dev/null +++ b/templates/scrobbles/scrobble_list.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Last watched{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/videos/models.py b/videos/models.py index dff0ca4..53a73cf 100644 --- a/videos/models.py +++ b/videos/models.py @@ -10,6 +10,12 @@ class Series(TimeStampedModel): overview = models.TextField(**BNULL) tagline = models.TextField(**BNULL) + def __str__(self): + return self.name + + class Meta: + verbose_name_plural = "series" + class Video(TimeStampedModel): class VideoType(models.TextChoices): @@ -37,3 +43,10 @@ class Video(TimeStampedModel): tvdb_id = models.CharField(max_length=20, **BNULL) imdb_id = models.CharField(max_length=20, **BNULL) tvrage_id = models.CharField(max_length=20, **BNULL) + + # Metadata fields from TMDB + + def __str__(self): + if self.video_type == self.VideoType.TV_EPISODE: + return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}" + return self.title diff --git a/vrobbler/negotiation.py b/vrobbler/negotiation.py new file mode 100644 index 0000000..98d04d9 --- /dev/null +++ b/vrobbler/negotiation.py @@ -0,0 +1,15 @@ +from rest_framework.negotiation import BaseContentNegotiation + + +class IgnoreClientContentNegotiation(BaseContentNegotiation): + def select_parser(self, request, parsers): + """ + Select the first parser in the `.parser_classes` list. + """ + return parsers[0] + + def select_renderer(self, request, renderers, format_suffix): + """ + Select the first renderer in the `.renderer_classes` list. + """ + return (renderers[0], renderers[0].media_type) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 96d394a..a668713 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -32,6 +32,8 @@ DEBUG = os.getenv("VROBBLER_DEBUG", False) TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" +TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") + DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' ALLOWED_HOSTS = ["*"] @@ -72,6 +74,7 @@ SITE_ID = 1 MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -143,6 +146,10 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": [ "django_filters.rest_framework.DjangoFilterBackend" ], + 'DEFAULT_PARSER_CLASSES': [ + 'rest_framework.parsers.JSONParser', + ], + 'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation', "PAGE_SIZE": 100, } @@ -183,30 +190,25 @@ USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = os.getenv( - "vrobbler_STATIC_ROOT", os.path.join(BASE_DIR, "static") + "VROBBLER_STATIC_ROOT", os.path.join(BASE_DIR, "static") ) +if not DEBUG: + STATICFILES_STORAGE = ( + "whitenoise.storage.CompressedManifestStaticFilesStorage" + ) MEDIA_URL = "/media/" -MEDIA_ROOT = os.getenv("vrobbler_MEDIA_ROOT", os.path.join(BASE_DIR, "media")) -ROMS_DIR = os.path.join(MEDIA_ROOT, "roms") -COLLECTIONS_DIR = os.path.join(ROMS_DIR, "emulationstation-collections") +MEDIA_ROOT = os.getenv("VROBBLER_MEDIA_ROOT", os.path.join(BASE_DIR, "media")) -SCRAPER_BIN_PATH = os.getenv("vrobbler_SCRAPER_BINPATH", "Skyscraper") -SCRAPER_CONFIG_FILE = os.getenv( - "vrobbler_SCRAPER_CONFIG_FILE", "skyscraper.ini" -) -SCRAPER_SITE = os.getenv("vrobbler_SCRAPER_SITE", "screenscraper") -SCRAPER_FRONTEND = os.getenv("vrobbler_FRONTEND", "emulationstation") - -JSON_LOGGING = os.getenv("vrobbler_JSON_LOGGING", False) +JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", False) LOG_TYPE = "json" if JSON_LOGGING else "log" default_level = "INFO" if DEBUG: default_level = "DEBUG" -LOG_LEVEL = os.getenv("vrobbler_LOG_LEVEL", default_level) -LOG_FILE_PATH = os.getenv("vrobbler_LOG_FILE_PATH", "/tmp/") +LOG_LEVEL = os.getenv("VROBBLER_LOG_LEVEL", default_level) +LOG_FILE_PATH = os.getenv("VROBBLER_LOG_FILE_PATH", "/tmp/") LOGGING = { "version": 1, diff --git a/vrobbler/urls.py b/vrobbler/urls.py index edc9554..94571d5 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -19,6 +19,7 @@ from rest_framework import routers urlpatterns = [ path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), # path("api-auth/", include("rest_framework.urls")), # path("api/v1/", include(router.urls)), # path("movies/", include(movies, namespace="movies")),