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 update_in_progress %}
Updating
{% endif %}
-
+ {% 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 %}
+
+ {% for scrobble in object_list %}
+ - {{scrobble.timestamp|date:"D, M j Y"}}: {{scrobble.video}}
+ {% endfor %}
+
+{% 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")),