Browse Source

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.
Colin Powell 2 years ago
parent
commit
fe47d916e9

+ 16 - 1
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"},

+ 1 - 0
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"

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

+ 18 - 0
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),
+        ),
+    ]

+ 22 - 0
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),
+        ),
+    ]

+ 9 - 2
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}"

+ 0 - 25
scrobbles/templates/scrobbles/scrobble_list.html

@@ -1,25 +0,0 @@
-<!doctype html>
-<html class="no-js" lang="">
-    <head>
-        <meta charset="utf-8">
-        <meta http-equiv="x-ua-compatible" content="ie=edge">
-        <title>Untitled</title>
-        <meta name="description" content="">
-        <meta name="viewport" content="width=device-width, initial-scale=1">
-
-        <link rel="apple-touch-icon" href="/apple-touch-icon.png">
-        <!-- Place favicon.ico in the root directory -->
-
-    </head>
-    <body>
-        <!--[if lt IE 8]>
-            <p class="browserupgrade">
-            You are using an <strong>outdated</strong> browser. Please
-            <a href="http://browsehappy.com/">upgrade your browser</a> to improve
-            your experience.
-            </p>
-        <![endif]-->
-
-
-    </body>
-</html>

+ 60 - 20
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)

+ 5 - 13
templates/base.html

@@ -85,40 +85,32 @@
                 <span class="navbar-toggler-icon"></span>
             </button>
 
-            {% if request.user.is_authenticated %}
             <div class="collapse navbar-collapse" id="navbarSupportedContent">
                 <ul class="navbar-nav mr-auto">
                 <li class="nav-item">
                     <a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a>
                 </li>
-                <li class="nav-item ">
-                    <a class="nav-link" href="{% url 'games:game_list' %}">Library<span class="sr-only"></span></a>
-                </li>
                 <li class="nav-item dropdown">
-                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Systems</a>
+                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Movies</a>
                     <div class="dropdown-menu" aria-labelledby="navbarDropdown">
-                        <a class="dropdown-item" href="{% url "games:gamesystem_list" %}">All</a>
+                        <a class="dropdown-item" href="{ url "games:gamesystem_list" %}">All</a>
                         {% for system in game_systems %}
                         <a class="dropdown-item" href="{{system.get_absolute_url}}">{{system.name}} ({{system.game_set.count}})</a>
                         {% endfor %}
                     </div>
                 </li>
                 <li class="nav-item dropdown">
-                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Collections</a>
+                    <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Shows</a>
                     <div class="dropdown-menu" aria-labelledby="navbarDropdown">
-                        <a class="dropdown-item" href="{% url "games:gamecollection_list" %}">All</a>
+                        <a class="dropdown-item" href="{ url "games:gamecollection_list" %}">All</a>
                         {% for collection in game_collections %}
                         <a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a>
                         {% endfor %}
                     </div>
                 </li>
                 </ul>
-                {% if update_in_progress %}<em class="updating">Updating</em><img id="library-update-status" src="{% static 'img/spinner.gif'%}"" width=30 />{% endif %}
-                <form class="form-inline my-2 my-lg-0" method="get" action="{% url 'search:search' %}">
-                <input class="form-control mr-sm-2" name="q" type="search" placeholder="Search" aria-label="Search">
-                <button class="btn btn-primary my-2 my-sm-0" type="submit">Search</button>
-                </form>
             </div>
+            {% if request.user.is_authenticated %}
             <a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
             {% else %}
             <a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>

+ 11 - 0
templates/scrobbles/scrobble_list.html

@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block title %}Last watched{% endblock %}
+
+{% block content %}
+    <ul>
+        {% for scrobble in object_list %}
+        <li>{{scrobble.timestamp|date:"D, M j Y"}}: <a href="https://www.imdb.com/title/{{scrobble.video.imdb_id}}">{{scrobble.video}}</a></li>
+        {% endfor %}
+    </ul>
+{% endblock %}

+ 13 - 0
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

+ 15 - 0
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)

+ 16 - 14
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")
-
-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")
+MEDIA_ROOT = os.getenv("VROBBLER_MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
 
-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,

+ 1 - 0
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")),