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")),