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.
This commit is contained in:
2023-01-05 01:35:12 -05:00
parent ece9a68165
commit fe47d916e9
14 changed files with 194 additions and 76 deletions

17
poetry.lock generated
View File

@ -1332,6 +1332,17 @@ python-versions = ">=3.6"
[package.extras] [package.extras]
watchdog = ["watchdog"] 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]] [[package]]
name = "wsproto" name = "wsproto"
version = "1.2.0" version = "1.2.0"
@ -1358,7 +1369,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "d1a46b6435a93d8ccaffd853d3d96e3a317db0b2d19e435323d5100ad37ac1d5" content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71"
[metadata.files] [metadata.files]
amqp = [ amqp = [
@ -2091,6 +2102,10 @@ werkzeug = [
{file = "Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8"}, {file = "Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8"},
{file = "Werkzeug-2.0.3.tar.gz", hash = "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c"}, {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 = [ wsproto = [
{file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
{file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},

View File

@ -26,6 +26,7 @@ django-taggit = "^2.1.0"
django-markdownify = "^0.9.1" django-markdownify = "^0.9.1"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
django-simple-history = "^3.1.1" django-simple-history = "^3.1.1"
whitenoise = "^6.3.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
Werkzeug = "2.0.3" Werkzeug = "2.0.3"

View File

@ -5,7 +5,13 @@ from scrobbles.models import Scrobble
class ScrobbleAdmin(admin.ModelAdmin): class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp" date_hierarchy = "timestamp"
list_display = ("video", "timestamp", "source", "playback_position") list_display = (
"video",
"timestamp",
"source",
"playback_position",
"in_progress",
)
list_filter = ("video",) list_filter = ("video",)
ordering = ("-timestamp",) ordering = ("-timestamp",)

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -20,6 +20,13 @@ class Scrobble(TimeStampedModel):
played_to_completion = models.BooleanField(default=False) played_to_completion = models.BooleanField(default=False)
source = models.CharField(max_length=255, **BNULL) source = models.CharField(max_length=255, **BNULL)
source_id = models.TextField(**BNULL) source_id = models.TextField(**BNULL)
in_progress = models.BooleanField(default=True)
def percent_played(self): @property
return int((self.playback_position_ticks / video.run_time_ticks) * 100) 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}"

View File

@ -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>

View File

@ -1,7 +1,10 @@
import json import json
import logging 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.decorators.csrf import csrf_exempt
from django.views.generic.list import ListView from django.views.generic.list import ListView
from rest_framework import status from rest_framework import status
@ -30,6 +33,9 @@ TRUTHY_VALUES = [
class RecentScrobbleList(ListView): class RecentScrobbleList(ListView):
model = Scrobble model = Scrobble
def get_queryset(self):
return Scrobble.objects.filter(in_progress=False)
@csrf_exempt @csrf_exempt
@api_view(['GET', 'POST']) @api_view(['GET', 'POST'])
@ -41,28 +47,31 @@ def scrobble_list(request):
return Response(serializer.data) return Response(serializer.data)
elif request.method == 'POST': elif request.method == 'POST':
data_dict = json.loads(request.data["_content"]) data_dict = request.data
media_type = data_dict["ItemType"] media_type = data_dict["ItemType"]
# Check if it's a TV Episode # Check if it's a TV Episode
video_dict = { video_dict = {
"title": data_dict["Name"], "title": data_dict["Name"],
"imdb_id": data_dict["Provider_imdb"], "imdb_id": data_dict["Provider_imdb"],
"video_type": Video.VideoType.MOVIE, "video_type": Video.VideoType.MOVIE,
"year": data_dict["Year"],
} }
if media_type == 'Episode': if media_type == 'Episode':
series_name = data_dict["SeriesName"] 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['video_type'] = Video.VideoType.TV_EPISODE
video_dict["tv_series_id"] = series.id video_dict["tv_series_id"] = series.id
video_dict["episode_num"] = data_dict["EpisodeNumber"] video_dict["episode_number"] = data_dict["EpisodeNumber"]
video_dict["season_num"] = data_dict["SeasonNumber"] video_dict["season_number"] = data_dict["SeasonNumber"]
video_dict["tvdb_id"] = data_dict["Provider_tvdb"] video_dict["tvdb_id"] = data_dict.get("Provider_tvdb", None)
video_dict["tvrage_id"] = data_dict["Provider_tvrage"] 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"] if video_created:
video.overview = data_dict["Overview"] video.overview = data_dict["Overview"]
video.tagline = data_dict["Tagline"] video.tagline = data_dict["Tagline"]
video.run_time_ticks = data_dict["RunTimeTicks"] video.run_time_ticks = data_dict["RunTimeTicks"]
@ -70,29 +79,60 @@ def scrobble_list(request):
video.save() video.save()
# Now we run off a scrobble # Now we run off a scrobble
timestamp = parse(data_dict["UtcTimestamp"])
scrobble_dict = { scrobble_dict = {
'video_id': video.id, 'video_id': video.id,
'user_id': request.user.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, scrobble_created = Scrobble.objects.get_or_create(
**scrobble_dict **scrobble_dict
) )
if scrobble_created: if scrobble_created:
# If we newly created this, capture the client we're watching from
scrobble.source = data_dict['ClientName'] scrobble.source = data_dict['ClientName']
scrobble.source_id = data_dict['MediaSourceId'] scrobble.source_id = data_dict['MediaSourceId']
else:
last_tick = scrobble.playback_position_ticks
# Update a found scrobble with new position and timestamp # Update a found scrobble with new position and timestamp
scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"] scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
scrobble.playback_position = data_dict["PlaybackPosition"] 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.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
scrobble.played_to_completion = (
data_dict["PlayedToCompletion"] in TRUTHY_VALUES
)
scrobble.save() 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 = video.run_time_ticks
scrobble.save()
if scrobble.percent_played % 5 == 0:
logger.info(f"You are {scrobble.percent_played}% through {video}") logger.info(f"You are {scrobble.percent_played}% through {video}")
return Response(video_dict, status=status.HTTP_201_CREATED) return Response(video_dict, status=status.HTTP_201_CREATED)
# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -85,40 +85,32 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
{% if request.user.is_authenticated %}
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a> <a class="nav-link" href="{% url 'home' %}">Recent<span class="sr-only"></span></a>
</li> </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"> <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"> <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 %} {% for system in game_systems %}
<a class="dropdown-item" href="{{system.get_absolute_url}}">{{system.name}} ({{system.game_set.count}})</a> <a class="dropdown-item" href="{{system.get_absolute_url}}">{{system.name}} ({{system.game_set.count}})</a>
{% endfor %} {% endfor %}
</div> </div>
</li> </li>
<li class="nav-item dropdown"> <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"> <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 %} {% for collection in game_collections %}
<a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a> <a class="dropdown-item" href="{{collection.get_absolute_url}}">{{collection.name}} ({{collection.games.count}})</a>
{% endfor %} {% endfor %}
</div> </div>
</li> </li>
</ul> </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> </div>
{% if request.user.is_authenticated %}
<a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a> <a class="nav-link" href="{% url 'account_logout' %}">Logout<span class="sr-only"></span></a>
{% else %} {% else %}
<a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a> <a class="nav-link" href="{% url 'account_login' %}">Login<span class="sr-only"></span></a>

View File

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

View File

@ -10,6 +10,12 @@ class Series(TimeStampedModel):
overview = models.TextField(**BNULL) overview = models.TextField(**BNULL)
tagline = models.TextField(**BNULL) tagline = models.TextField(**BNULL)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = "series"
class Video(TimeStampedModel): class Video(TimeStampedModel):
class VideoType(models.TextChoices): class VideoType(models.TextChoices):
@ -37,3 +43,10 @@ class Video(TimeStampedModel):
tvdb_id = models.CharField(max_length=20, **BNULL) tvdb_id = models.CharField(max_length=20, **BNULL)
imdb_id = models.CharField(max_length=20, **BNULL) imdb_id = models.CharField(max_length=20, **BNULL)
tvrage_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
vrobbler/negotiation.py Normal file
View File

@ -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)

View File

@ -32,6 +32,8 @@ DEBUG = os.getenv("VROBBLER_DEBUG", False)
TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" 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' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
@ -72,6 +74,7 @@ SITE_ID = 1
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -143,6 +146,10 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": [ "DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend" "django_filters.rest_framework.DjangoFilterBackend"
], ],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
],
'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'vrobbler.negotiation.IgnoreClientContentNegotiation',
"PAGE_SIZE": 100, "PAGE_SIZE": 100,
} }
@ -183,30 +190,25 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = os.getenv( 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_URL = "/media/"
MEDIA_ROOT = os.getenv("vrobbler_MEDIA_ROOT", os.path.join(BASE_DIR, "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") JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", False)
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)
LOG_TYPE = "json" if JSON_LOGGING else "log" LOG_TYPE = "json" if JSON_LOGGING else "log"
default_level = "INFO" default_level = "INFO"
if DEBUG: if DEBUG:
default_level = "DEBUG" default_level = "DEBUG"
LOG_LEVEL = os.getenv("vrobbler_LOG_LEVEL", default_level) LOG_LEVEL = os.getenv("VROBBLER_LOG_LEVEL", default_level)
LOG_FILE_PATH = os.getenv("vrobbler_LOG_FILE_PATH", "/tmp/") LOG_FILE_PATH = os.getenv("VROBBLER_LOG_FILE_PATH", "/tmp/")
LOGGING = { LOGGING = {
"version": 1, "version": 1,

View File

@ -19,6 +19,7 @@ from rest_framework import routers
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
# path("api-auth/", include("rest_framework.urls")), # path("api-auth/", include("rest_framework.urls")),
# path("api/v1/", include(router.urls)), # path("api/v1/", include(router.urls)),
# path("movies/", include(movies, namespace="movies")), # path("movies/", include(movies, namespace="movies")),