Big reorg to support pacakging

This commit is contained in:
2023-01-05 14:39:47 -05:00
parent 41a74f46c8
commit 819723b927
26 changed files with 57 additions and 29 deletions

View File

View File

@ -0,0 +1,19 @@
from django.contrib import admin
from scrobbles.models import Scrobble
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
list_display = (
"video",
"timestamp",
"source",
"playback_position",
"in_progress",
)
list_filter = ("video",)
ordering = ("-timestamp",)
admin.site.register(Scrobble, ScrobbleAdmin)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ScrobblesConfig(AppConfig):
name = 'scrobbles'

View File

@ -0,0 +1,81 @@
# Generated by Django 4.1.5 on 2023-01-04 21:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
('videos', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Scrobble',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
(
'created',
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name='created'
),
),
(
'modified',
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name='modified'
),
),
('timestamp', models.DateTimeField(blank=True, null=True)),
(
'playback_position_ticks',
models.PositiveIntegerField(blank=True, null=True),
),
(
'playback_position',
models.CharField(blank=True, max_length=8, null=True),
),
('is_paused', models.BooleanField(default=False)),
('played_to_completion', models.BooleanField(default=False)),
(
'source',
models.CharField(blank=True, max_length=255, null=True),
),
('source_id', models.TextField(blank=True, null=True)),
(
'user',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
(
'video',
models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
to='videos.video',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
]

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

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-05 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0003_remove_scrobble_counted_scrobble_in_progress'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='scrobble_log',
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-05 19:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scrobbles', '0004_scrobble_scrobble_log'),
]
operations = [
migrations.AlterField(
model_name='scrobble',
name='playback_position_ticks',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,33 @@
from django.contrib.auth import get_user_model
from django.db import models
from django_extensions.db.models import TimeStampedModel
from videos.models import Video
User = get_user_model()
BNULL = {"blank": True, "null": True}
class Scrobble(TimeStampedModel):
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING
)
timestamp = models.DateTimeField(**BNULL)
playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
playback_position = models.CharField(max_length=8, **BNULL)
is_paused = models.BooleanField(default=False)
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)
scrobble_log = models.TextField(**BNULL)
@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}"

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from scrobbles.models import Scrobble
class ScrobbleSerializer(serializers.ModelSerializer):
class Meta:
model = Scrobble
fields = "__all__"

View File

@ -0,0 +1,9 @@
from django.urls import path
from scrobbles import views
app_name = 'scrobbles'
urlpatterns = [
path('', views.scrobble_endpoint, name='scrobble-list'),
path('jellyfin/', views.jellyfin_websocket, name='jellyfin-websocket'),
]

View File

@ -0,0 +1,174 @@
import json
import logging
from datetime import datetime, timedelta
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
from rest_framework.decorators import api_view
from rest_framework.response import Response
from scrobbles.models import Scrobble
from scrobbles.serializers import ScrobbleSerializer
from videos.models import Series, Video
from vrobbler.settings import DELETE_STALE_SCROBBLES
logger = logging.getLogger(__name__)
TRUTHY_VALUES = [
'true',
'1',
't',
'y',
'yes',
'yeah',
'yup',
'certainly',
'uh-huh',
]
class RecentScrobbleList(ListView):
model = Scrobble
def get_queryset(self):
return Scrobble.objects.filter(in_progress=False).order_by(
'-timestamp'
)
@csrf_exempt
@api_view(['GET'])
def scrobble_endpoint(request):
"""List all Scrobbles, or create a new Scrobble"""
scrobble = Scrobble.objects.all()
serializer = ScrobbleSerializer(scrobble, many=True)
return Response(serializer.data)
@csrf_exempt
@api_view(['POST'])
def jellyfin_websocket(request):
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_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_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, video_created = Video.objects.get_or_create(**video_dict)
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()
)
existing_in_progress_scrobble = (
Scrobble.objects.filter(
video=video, user_id=request.user.id, in_progress=True
)
.order_by('-modified')
.first()
)
minutes_from_now = timezone.now() + timedelta(minutes=15)
a_day_from_now = timezone.now() + timedelta(days=1)
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)
existing_scrobble_more_than_a_day_old = (
existing_in_progress_scrobble
and existing_in_progress_scrobble.modified > a_day_from_now
)
delete_stale_scrobbles = getattr(settings, "DELETE_STALE_SCROBBLES", True)
# Check if found in progress scrobble is more than a day old
if existing_scrobble_more_than_a_day_old:
logger.info(
'Found a scrobble for this video more than a day old, creating a new scrobble'
)
scrobble = existing_in_progress_scrobble
scrobble_created = False
else:
if existing_scrobble_more_than_a_day_old and delete_stale_scrobbles:
existing_in_progress_scrobble.delete()
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']
scrobble.scrobble_log = ""
# Update a found scrobble with new position and timestamp
scrobble.playback_position_ticks = data_dict["PlaybackPositionTicks"]
scrobble.playback_position = data_dict["PlaybackPosition"]
scrobble.timestamp = parse(data_dict["UtcTimestamp"])
scrobble.is_paused = data_dict["IsPaused"] in TRUTHY_VALUES
scrobble.save(
update_fields=[
'playback_position_ticks',
'playback_position',
'timestamp',
'is_paused',
]
)
# 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:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False):
scrobble.scrobble_log += f"\n{str(scrobble.timestamp)} - {scrobble.playback_position} - {str(scrobble.playback_position_ticks)} - {str(scrobble.percent_played)}%"
scrobble.save(update_fields=['scrobble_log'])
logger.debug(f"You are {scrobble.percent_played}% through {video}")
return Response(video_dict, status=status.HTTP_201_CREATED)