Compare commits

..

16 Commits
0.5.0 ... 0.6.1

27 changed files with 871 additions and 193 deletions

View File

@ -1,4 +1,7 @@
#+title: Readme Vrobbler
========
[![Build Status](https://ci.unbl.ink/api/badges/secstate/vrobbler/status.svg)](https://ci.unbl.ink/secstate/vrobbler)
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation. Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.

50
poetry.lock generated
View File

@ -667,6 +667,19 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
[[package]]
name = "mock"
version = "5.0.1"
description = "Rolling backport of unittest.mock for all Pythons"
category = "main"
optional = false
python-versions = ">=3.6"
[package.extras]
build = ["blurb", "twine", "wheel"]
docs = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]] [[package]]
name = "musicbrainzngs" name = "musicbrainzngs"
version = "0.7.1" version = "0.7.1"
@ -861,6 +874,18 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pysportsdb"
version = "0.1.0"
description = "An simple Python interface to thesportsdb.com's API"
category = "main"
optional = false
python-versions = ">=3.7,<4.0"
[package.dependencies]
mock = ">=5.0.1,<6.0.0"
requests = ">=2.28.2,<3.0.0"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.2.0" version = "7.2.0"
@ -1095,7 +1120,7 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.28.1" version = "2.28.2"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
category = "main" category = "main"
optional = false optional = false
@ -1103,7 +1128,7 @@ python-versions = ">=3.7, <4"
[package.dependencies] [package.dependencies]
certifi = ">=2017.4.17" certifi = ">=2017.4.17"
charset-normalizer = ">=2,<3" charset-normalizer = ">=2,<4"
idna = ">=2.5,<4" idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<1.27" urllib3 = ">=1.21.1,<1.27"
@ -1451,7 +1476,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 = "0d571d0abd62d2c4614bc29fb0475bd0bad0b0ef99b789c8c9d46fde87515a89" content-hash = "39b4ea9b1b67a317d159760bff3def91ead580997052759a3f64521044e0b5ae"
[metadata.files] [metadata.files]
amqp = [ amqp = [
@ -1946,6 +1971,10 @@ mccabe = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
] ]
mock = [
{file = "mock-5.0.1-py3-none-any.whl", hash = "sha256:c41cfb1e99ba5d341fbcc5308836e7d7c9786d302f995b2c271ce2144dece9eb"},
{file = "mock-5.0.1.tar.gz", hash = "sha256:e3ea505c03babf7977fd21674a69ad328053d414f05e6433c30d8fa14a534a6b"},
]
musicbrainzngs = [ musicbrainzngs = [
{file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"},
{file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"}, {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"},
@ -2000,6 +2029,13 @@ pbr = [
{file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"}, {file = "pbr-5.11.0.tar.gz", hash = "sha256:b97bc6695b2aff02144133c2e7399d5885223d42b7912ffaec2ca3898e673bfe"},
] ]
pillow = [ pillow = [
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"}, {file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"}, {file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"}, {file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
@ -2116,6 +2152,10 @@ pysocks = [
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
{file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"},
] ]
pysportsdb = [
{file = "pysportsdb-0.1.0-py3-none-any.whl", hash = "sha256:9e3a6654e1270a176cdb4bfcec123240bb9061f80db5bf421e5422b547efe7aa"},
{file = "pysportsdb-0.1.0.tar.gz", hash = "sha256:d495ec5d1c416af8192be127ece500d4d2fd6224bb9a001044b823ac158d22b5"},
]
pytest = [ pytest = [
{file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"},
{file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"},
@ -2226,8 +2266,8 @@ redis = [
{file = "redis-4.4.0.tar.gz", hash = "sha256:7b8c87d19c45d3f1271b124858d2a5c13160c4e74d4835e28273400fa34d5228"}, {file = "redis-4.4.0.tar.gz", hash = "sha256:7b8c87d19c45d3f1271b124858d2a5c13160c4e74d4835e28273400fa34d5228"},
] ]
requests = [ requests = [
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"},
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"},
] ]
requests-oauthlib = [ requests-oauthlib = [
{file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"},

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "0.5.0" version = "0.6.1"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]
@ -29,6 +29,7 @@ django-simple-history = "^3.1.1"
whitenoise = "^6.3.0" whitenoise = "^6.3.0"
musicbrainzngs = "^0.7.1" musicbrainzngs = "^0.7.1"
cinemagoer = "^2022.12.27" cinemagoer = "^2022.12.27"
pysportsdb = "^0.1.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
Werkzeug = "2.0.3" Werkzeug = "2.0.3"

View File

@ -4,6 +4,7 @@ from uuid import uuid4
import musicbrainzngs import musicbrainzngs
from django.apps.config import cached_property from django.apps.config import cached_property
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -93,6 +94,9 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin): class Track(ScrobblableMixin):
RESUME_LIMIT = getattr(settings, 'MUSIC_RESUME_LIMIT', 60 * 60)
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
class Opinion(models.IntegerChoices): class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down' DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion' NEUTRAL = 0, 'No opinion'
@ -132,7 +136,7 @@ class Track(ScrobblableMixin):
artist, artist_created = Artist.objects.get_or_create(**artist_dict) artist, artist_created = Artist.objects.get_or_create(**artist_dict)
if artist_created: if artist_created:
logger.debug(f"Created new album {artist}") logger.debug(f"Created new artist {artist}")
else: else:
logger.debug(f"Found album {artist}") logger.debug(f"Found album {artist}")

View File

@ -2,6 +2,7 @@ import logging
from typing import Dict, Optional from typing import Dict, Optional
from uuid import uuid4 from uuid import uuid4
from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
@ -34,6 +35,9 @@ class Podcast(TimeStampedModel):
class Episode(ScrobblableMixin): class Episode(ScrobblableMixin):
RESUME_LIMIT = getattr(settings, 'PODCAST_RESUME_LIMIT', 180 * 60)
COMPLETION_PERCENT = getattr(settings, 'PODCAST_COMPLETION_PERCENT', 90)
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING) podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)
number = models.IntegerField(**BNULL) number = models.IntegerField(**BNULL)
pub_date = models.DateField(**BNULL) pub_date = models.DateField(**BNULL)

View File

@ -23,7 +23,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"is_paused", "is_paused",
"played_to_completion", "played_to_completion",
) )
raw_id_fields = ('video', 'podcast_episode', 'track') raw_id_fields = ('video', 'podcast_episode', 'track', 'sport_event')
list_filter = ("is_paused", "in_progress", "source", "track__artist") list_filter = ("is_paused", "in_progress", "source", "track__artist")
ordering = ("-timestamp",) ordering = ("-timestamp",)
@ -34,6 +34,8 @@ class ScrobbleAdmin(admin.ModelAdmin):
return obj.track return obj.track
if obj.podcast_episode: if obj.podcast_episode:
return obj.podcast_episode return obj.podcast_episode
if obj.sport_event:
return obj.sport_event
def media_type(self, obj): def media_type(self, obj):
if obj.video: if obj.video:
@ -42,6 +44,8 @@ class ScrobbleAdmin(admin.ModelAdmin):
return "Track" return "Track"
if obj.podcast_episode: if obj.podcast_episode:
return "Podcast" return "Podcast"
if obj.sport_event:
return "Sport Event"
def playback_percent(self, obj): def playback_percent(self, obj):
return obj.percent_played return obj.percent_played

View File

@ -1,5 +1,14 @@
from django import forms from django import forms
class ImdbScrobbleForm(forms.Form): class ScrobbleForm(forms.Form):
imdb_id = forms.CharField(label="IMDB", max_length=30) item_id = forms.CharField(
label="",
widget=forms.TextInput(
attrs={
'class': "form-control form-control-dark w-100",
'placeholder': "Scrobble something (IMDB ID, String, TVDB ID ...)",
'aria-label': "Scrobble something",
}
),
)

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-01-14 21:23
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('sports', '0002_rename_start_utc_sportevent_start'),
('scrobbles', '0007_scrobble_podcast_episode'),
]
operations = [
migrations.AddField(
model_name='scrobble',
name='sport_event',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='sports.sportevent',
),
),
]

View File

@ -1,8 +1,6 @@
import logging import logging
from datetime import timedelta from datetime import timedelta
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -11,14 +9,11 @@ from music.models import Track
from podcasts.models import Episode from podcasts.models import Episode
from scrobbles.utils import check_scrobble_for_finish from scrobbles.utils import check_scrobble_for_finish
from videos.models import Video from videos.models import Video
from sports.models import SportEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
VIDEO_BACKOFF = getattr(settings, 'VIDEO_BACKOFF_MINUTES')
TRACK_BACKOFF = getattr(settings, 'MUSIC_BACKOFF_SECONDS')
VIDEO_WAIT_PERIOD = getattr(settings, 'VIDEO_WAIT_PERIOD_DAYS')
TRACK_WAIT_PERIOD = getattr(settings, 'MUSIC_WAIT_PERIOD_MINUTES')
class Scrobble(TimeStampedModel): class Scrobble(TimeStampedModel):
@ -27,6 +22,9 @@ class Scrobble(TimeStampedModel):
podcast_episode = models.ForeignKey( podcast_episode = models.ForeignKey(
Episode, on_delete=models.DO_NOTHING, **BNULL Episode, on_delete=models.DO_NOTHING, **BNULL
) )
sport_event = models.ForeignKey(
SportEvent, on_delete=models.DO_NOTHING, **BNULL
)
user = models.ForeignKey( user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.DO_NOTHING User, blank=True, null=True, on_delete=models.DO_NOTHING
) )
@ -42,26 +40,20 @@ class Scrobble(TimeStampedModel):
@property @property
def percent_played(self) -> int: def percent_played(self) -> int:
playback_ticks = None
percent_played = 100
if not self.media_obj.run_time_ticks: if not self.media_obj.run_time_ticks:
logger.warning( logger.warning(
f"{self} has no run_time_ticks value, cannot show percent played" f"{self} has no run_time_ticks value, cannot show percent played"
) )
return percent_played return 100
playback_ticks = self.playback_position_ticks playback_ticks = self.playback_position_ticks
if not playback_ticks: if not playback_ticks:
logger.info(
"No playback_position_ticks, estimating based on creation time"
)
playback_ticks = (timezone.now() - self.timestamp).seconds * 1000 playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100) if self.played_to_completion:
playback_ticks = self.media_obj.run_time_ticks
if percent > 100: percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
percent = 100
return percent return percent
@property @property
@ -73,162 +65,247 @@ class Scrobble(TimeStampedModel):
media_obj = self.track media_obj = self.track
if self.podcast_episode: if self.podcast_episode:
media_obj = self.podcast_episode media_obj = self.podcast_episode
if self.sport_event:
media_obj = self.sport_event
return media_obj return media_obj
def __str__(self): def __str__(self):
return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}" return f"Scrobble of {self.media_obj} {self.timestamp.year}-{self.timestamp.month}"
def resumable(self, playback_ticks):
"""Check if a scrobble is not finished or beyond the configured resume limit.
The idea here is to check whether a scrobble should be resumed, or a new
one created. If this method returns true, we should update an existing
scrobble, suggesting the user just paused their scrobble. This limit
should be different for different media. We are more likely to pause a video
or sports event for a while, and expect to resume it than an audio track or
a podcast.
"""
diff = None
# Default finish expectation
percent_for_completion = 100
# By default, assume we're not beyond resume limits
# This is to avoid spam scrobbles if webhooks go crazy
beyond_resume_limit = False
now = timezone.now()
if self.playback_position_ticks == playback_ticks:
# shortcircut in the case where we've resumed a track at the same playback ticks
return True
if self.video:
diff = timedelta(seconds=Video.RESUME_LIMIT)
percent_for_completion = Video.COMPLETION_PERCENT
if self.track:
diff = timedelta(seconds=Track.RESUME_LIMIT)
percent_for_completion = Track.COMPLETION_PERCENT
if self.podcast_episode:
diff = timedelta(seconds=Episode.RESUME_LIMIT)
percent_for_completion = Episode.COMPLETION_PERCENT
if self.sport_event:
diff = timedelta(seconds=SportEvent.RESUME_LIMIT)
percent_for_completion = SportEvent.COMPLETION_PERCENT
if diff and self.timestamp:
beyond_resume_limit = self.timestamp + diff <= now
finished = self.percent_played >= percent_for_completion
resumable = not finished or not beyond_resume_limit
if not finished:
logger.debug(
f"{self} resumable, percent played {self.percent_played} is less than {percent_for_completion}"
)
if not beyond_resume_limit:
logger.debug(
f"{self} resumable, started less than {diff.seconds} seconds ago"
)
return not finished and not beyond_resume_limit
@classmethod @classmethod
def create_or_update_for_video( def create_or_update_for_video(
cls, video: "Video", user_id: int, jellyfin_data: dict cls, video: "Video", user_id: int, scrobble_data: dict
) -> "Scrobble": ) -> "Scrobble":
jellyfin_data['video_id'] = video.id scrobble_data['video_id'] = video.id
logger.debug(
f"Creating or updating scrobble for video {video} with data {jellyfin_data}"
)
scrobble = ( scrobble = (
cls.objects.filter(video=video, user_id=user_id) cls.objects.filter(video=video, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
if scrobble and scrobble.resumable(
scrobble_data['playback_position_ticks']
):
logger.info(
f"Found existing scrobble for video {video}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
# Backoff is how long until we consider this a new scrobble logger.debug(
backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF) f"No existing scrobble for video {video}, creating",
wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD) {"scrobble_data": scrobble_data},
return cls.update_or_create(
scrobble, backoff, wait_period, jellyfin_data
) )
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod @classmethod
def create_or_update_for_track( def create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict cls, track: "Track", user_id: int, scrobble_data: dict
) -> "Scrobble": ) -> "Scrobble":
"""Look up any existing scrobbles for a track and compare
the appropriate backoff time for music tracks to the setting
so we can avoid duplicating scrobbles."""
scrobble_data['track_id'] = track.id scrobble_data['track_id'] = track.id
scrobble = ( scrobble = (
cls.objects.filter(track=track, user_id=user_id) cls.objects.filter(track=track, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
if scrobble and scrobble.resumable(
scrobble_data['playback_position_ticks']
):
logger.debug(
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug( logger.debug(
f"Found existing scrobble for track {track}, updating", f"No existing scrobble for track {track}, creating",
{"scrobble_data": scrobble_data}, {"scrobble_data": scrobble_data},
) )
# If creating a new scrobble, we don't need status
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF) scrobble_data.pop('mopidy_status')
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD) return cls.create(scrobble_data)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
)
@classmethod @classmethod
def create_or_update_for_podcast_episode( def create_or_update_for_podcast_episode(
cls, episode: "Episode", user_id: int, scrobble_data: dict cls, episode: "Episode", user_id: int, scrobble_data: dict
) -> "Scrobble": ) -> "Scrobble":
scrobble_data['podcast_episode_id'] = episode.id scrobble_data['podcast_episode_id'] = episode.id
scrobble = ( scrobble = (
cls.objects.filter(podcast_episode=episode, user_id=user_id) cls.objects.filter(podcast_episode=episode, user_id=user_id)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
if scrobble and scrobble.resumable(
scrobble_data['playback_position_ticks']
):
logger.debug(
f"Found existing scrobble for podcast {episode}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug( logger.debug(
f"Found existing scrobble for podcast {episode}, updating", f"No existing scrobble for podcast epsiode {episode}, creating",
{"scrobble_data": scrobble_data}, {"scrobble_data": scrobble_data},
) )
# If creating a new scrobble, we don't need status
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF) scrobble_data.pop('mopidy_status')
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD) return cls.create(scrobble_data)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
)
@classmethod @classmethod
def update_or_create( def create_or_update_for_sport_event(
cls, cls, event: "SportEvent", user_id: int, scrobble_data: dict
scrobble: Optional["Scrobble"], ) -> "Scrobble":
backoff, scrobble_data['sport_event_id'] = event.id
wait_period, scrobble = (
scrobble_data: dict, cls.objects.filter(sport_event=event, user_id=user_id)
) -> Optional["Scrobble"]: .order_by('-modified')
.first()
)
if scrobble and scrobble.resumable(
scrobble_data['playback_position_ticks']
):
logger.debug(
f"Found existing scrobble for sport event {event}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
logger.debug(
f"No existing scrobble for sport event {event}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('jellyfin_status')
return cls.create(scrobble_data)
@classmethod
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
# Status is a field we get from Mopidy, which refuses to poll us # Status is a field we get from Mopidy, which refuses to poll us
scrobble_status = scrobble_data.pop('mopidy_status', None) scrobble_status = scrobble_data.pop('mopidy_status', None)
if not scrobble_status: if not scrobble_status:
scrobble_status = scrobble_data.pop('jellyfin_status', None) scrobble_status = scrobble_data.pop('jellyfin_status', None)
if not scrobble_status: if not scrobble_status:
logger.warning( scrobble_status = 'resumed'
f"No status update found in message, not scrobbling"
)
return
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}") logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
if scrobble: scrobble.update_ticks(scrobble_data)
scrobble.update_ticks(scrobble_data)
# On stop, stop progress and send it to the check for completion # On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped": if scrobble_status == "stopped":
return scrobble.stop() scrobble.stop()
# On pause, set is_paused and stop scrobbling
# On pause, set is_paused and stop scrobbling if scrobble_status == "paused":
if scrobble_status == "paused": scrobble.pause()
return scrobble.pause() if scrobble_status == "resumed":
scrobble.resume()
if scrobble_status == "resumed":
return scrobble.resume()
# We're not changing the scrobble, but we don't want to walk over an existing one
scrobble_is_finished = (
not scrobble.in_progress and scrobble.modified < backoff
)
if scrobble_is_finished:
logger.info(
'Found a very recent scrobble for this item, holding off scrobbling again'
)
return
if not scrobble:
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
else:
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
# If we hit our completion threshold, save it and get ready
# to scrobble again if we re-watch this.
scrobble = check_scrobble_for_finish(scrobble)
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
check_scrobble_for_finish(scrobble)
return scrobble return scrobble
def stop(self): @classmethod
def create(
cls,
scrobble_data: dict,
) -> "Scrobble":
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
return scrobble
def stop(self) -> None:
if not self.in_progress: if not self.in_progress:
logger.warning("Scrobble already stopped") logger.warning("Scrobble already stopped")
return return
self.in_progress = False self.in_progress = False
self.save(update_fields=['in_progress']) self.save(update_fields=['in_progress'])
return check_scrobble_for_finish(self) check_scrobble_for_finish(self)
def pause(self): def pause(self) -> None:
if self.is_paused: if self.is_paused and not self.played_to_completion:
logger.warning("Scrobble already paused") logger.warning("Scrobble already paused")
return return
self.is_paused = True self.is_paused = True
self.save(update_fields=["is_paused"]) self.save(update_fields=["is_paused"])
return check_scrobble_for_finish(self) check_scrobble_for_finish(self)
def resume(self): def resume(self) -> None:
if self.is_paused or not self.in_progress: if self.is_paused or not self.played_to_completion:
self.is_paused = False self.is_paused = False
self.in_progress = True self.in_progress = True
return self.save(update_fields=["is_paused", "in_progress"]) return self.save(
return self update_fields=[
"is_paused",
"in_progress",
]
)
def update_ticks(self, data): def update_ticks(self, data) -> None:
self.playback_position_ticks = data.get("playback_position_ticks") self.playback_position_ticks = data.get("playback_position_ticks")
self.playback_position = data.get("playback_position") self.playback_position = data.get("playback_position")
logger.debug( logger.debug(
@ -237,4 +314,3 @@ class Scrobble(TimeStampedModel):
self.save( self.save(
update_fields=['playback_position_ticks', 'playback_position'] update_fields=['playback_position_ticks', 'playback_position']
) )
return self

View File

@ -9,6 +9,7 @@ from podcasts.models import Episode
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.utils import convert_to_seconds, parse_mopidy_uri from scrobbles.utils import convert_to_seconds, parse_mopidy_uri
from videos.models import Video from videos.models import Video
from sports.models import SportEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -85,15 +86,12 @@ def mopidy_scrobble_track(
"mopidy_status": data_dict.get("status"), "mopidy_status": data_dict.get("status"),
} }
scrobble = None # Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track(track, user_id, mopidy_data)
if track:
# Jellyfin MB ids suck, so always overwrite with Mopidy if they're offering
track.musicbrainz_id = data_dict.get("musicbrainz_track_id")
track.save()
scrobble = Scrobble.create_or_update_for_track(
track, user_id, mopidy_data
)
return scrobble return scrobble
@ -104,18 +102,23 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict:
if data_dict.get("PlayedToCompletion"): if data_dict.get("PlayedToCompletion"):
jellyfin_status = "stopped" jellyfin_status = "stopped"
playback_position_ticks = data_dict.get("PlaybackPositionTicks") // 10000 playback_position_ticks = None
if playback_position_ticks <= 0: if data_dict.get("PlaybackPositionTicks"):
playback_position_ticks = None playback_position_ticks = (
data_dict.get("PlaybackPositionTicks") // 10000
)
if playback_position_ticks <= 0:
playback_position_ticks = None
playback_position = data_dict.get("PlaybackPosition")
if playback_position:
playback_position = convert_to_seconds(playback_position)
logger.debug(playback_position_ticks)
return { return {
"user_id": user_id, "user_id": user_id,
"timestamp": parse(data_dict.get("UtcTimestamp")), "timestamp": parse(data_dict.get("UtcTimestamp")),
"playback_position_ticks": playback_position_ticks, "playback_position_ticks": playback_position_ticks,
"playback_position": convert_to_seconds( "playback_position": playback_position,
data_dict.get("PlaybackPosition")
),
"source": "Jellyfin", "source": "Jellyfin",
"source_id": data_dict.get('MediaSourceId'), "source_id": data_dict.get('MediaSourceId'),
"jellyfin_status": jellyfin_status, "jellyfin_status": jellyfin_status,
@ -193,3 +196,18 @@ def manual_scrobble_video(data_dict: dict, user_id: Optional[int]):
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id) scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict) return Scrobble.create_or_update_for_video(video, user_id, scrobble_dict)
def manual_scrobble_event(data_dict: dict, user_id: Optional[int]):
if not data_dict.get("Provider_thesportsdb", None):
logger.error(
"No TheSportsDB ID received. This is likely because all metadata is bad, not scrobbling"
)
return
event = SportEvent.find_or_create(data_dict)
scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id)
return Scrobble.create_or_update_for_sport_event(
event, user_id, scrobble_dict
)

View File

@ -0,0 +1,44 @@
import logging
from dateutil.parser import parse
from django.conf import settings
from django.utils import timezone
from pysportsdb import TheSportsDbClient
from sports.models import SportEvent
logger = logging.getLogger(__name__)
API_KEY = getattr(settings, "THESPORTSDB_API_KEY", "2")
client = TheSportsDbClient(api_key=API_KEY)
def lookup_event_from_thesportsdb(event_id: str) -> dict:
event = client.lookup_event(event_id)['events'][0]
league = {} # client.lookup_league(league_id=event.get('idLeague'))
event_type = SportEvent.Type.GAME
run_time_seconds = 11700
run_time_ticks = run_time_seconds * 1000
data_dict = {
"ItemType": event_type,
"Name": event.get('strEventAlternate'),
"Start": parse(event.get('strTimestamp')),
"Provider_thesportsdb": event.get('idEvent'),
"RunTime": run_time_seconds,
"RunTimeTicks": run_time_ticks,
"LeagueId": event.get('idLeague'),
"LeagueName": event.get('strLeague'),
"HomeTeamId": event.get('idHomeTeam'),
"HomeTeamName": event.get('strHomeTeam'),
"AwayTeamId": event.get('idAwayTeam'),
"AwayTeamName": event.get('strAwayTeam'),
"RoundId": event.get('intRound'),
"PlaybackPositionTicks": None,
"PlaybackPosition": None,
"UtcTimestamp": timezone.now().strftime('%Y-%m-%d %H:%M:%S.%f%z'),
"IsPaused": False,
"PlayedToCompletion": False,
}
return data_dict

View File

@ -76,6 +76,9 @@ def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
settings, "PODCAST_COMPLETION_PERCENT", 25 settings, "PODCAST_COMPLETION_PERCENT", 25
) )
if scrobble.percent_played >= completion_percent: if scrobble.percent_played >= completion_percent:
logger.debug(
f"Beyond completion percent {completion_percent}, finishing scrobble"
)
scrobble.in_progress = False scrobble.in_progress = False
scrobble.is_paused = False scrobble.is_paused = False
scrobble.played_to_completion = True scrobble.played_to_completion = True
@ -87,5 +90,3 @@ def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
if getattr(settings, "KEEP_DETAILED_SCROBBLE_LOGS", False): 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.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']) scrobble.save(update_fields=['scrobble_log'])
return scrobble

View File

@ -16,16 +16,19 @@ from scrobbles.constants import (
JELLYFIN_AUDIO_ITEM_TYPES, JELLYFIN_AUDIO_ITEM_TYPES,
JELLYFIN_VIDEO_ITEM_TYPES, JELLYFIN_VIDEO_ITEM_TYPES,
) )
from scrobbles.forms import ScrobbleForm
from scrobbles.imdb import lookup_video_from_imdb from scrobbles.imdb import lookup_video_from_imdb
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.scrobblers import ( from scrobbles.scrobblers import (
jellyfin_scrobble_track, jellyfin_scrobble_track,
jellyfin_scrobble_video, jellyfin_scrobble_video,
manual_scrobble_event,
manual_scrobble_video, manual_scrobble_video,
mopidy_scrobble_podcast, mopidy_scrobble_podcast,
mopidy_scrobble_track, mopidy_scrobble_track,
) )
from scrobbles.serializers import ScrobbleSerializer from scrobbles.serializers import ScrobbleSerializer
from scrobbles.thesportsdb import lookup_event_from_thesportsdb
from vrobbler.apps.music.aggregators import ( from vrobbler.apps.music.aggregators import (
scrobble_counts, scrobble_counts,
@ -33,7 +36,6 @@ from vrobbler.apps.music.aggregators import (
top_tracks, top_tracks,
week_of_scrobbles, week_of_scrobbles,
) )
from scrobbles.forms import ImdbScrobbleForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -63,10 +65,13 @@ class RecentScrobbleList(ListView):
) )
data['video_scrobble_list'] = Scrobble.objects.filter( data['video_scrobble_list'] = Scrobble.objects.filter(
video__isnull=False, played_to_completion=True video__isnull=False, played_to_completion=True
).order_by('-timestamp')[:10] ).order_by('-timestamp')[:15]
data['podcast_scrobble_list'] = Scrobble.objects.filter( data['podcast_scrobble_list'] = Scrobble.objects.filter(
podcast_episode__isnull=False, played_to_completion=True podcast_episode__isnull=False, played_to_completion=True
).order_by('-timestamp')[:10] ).order_by('-timestamp')[:15]
data['sport_scrobble_list'] = Scrobble.objects.filter(
sport_event__isnull=False, played_to_completion=True
).order_by('-timestamp')[:15]
data['top_daily_tracks'] = top_tracks() data['top_daily_tracks'] = top_tracks()
data['top_weekly_tracks'] = top_tracks(filter='week') data['top_weekly_tracks'] = top_tracks(filter='week')
data['top_monthly_tracks'] = top_tracks(filter='month') data['top_monthly_tracks'] = top_tracks(filter='month')
@ -77,7 +82,7 @@ class RecentScrobbleList(ListView):
data["weekly_data"] = week_of_scrobbles() data["weekly_data"] = week_of_scrobbles()
data['counts'] = scrobble_counts() data['counts'] = scrobble_counts()
data['imdb_form'] = ImdbScrobbleForm data['imdb_form'] = ScrobbleForm
return data return data
def get_queryset(self): def get_queryset(self):
@ -86,16 +91,28 @@ class RecentScrobbleList(ListView):
).order_by('-timestamp')[:15] ).order_by('-timestamp')[:15]
class ManualImdbScrobbleView(FormView): class ManualScrobbleView(FormView):
form_class = ImdbScrobbleForm form_class = ScrobbleForm
template_name = 'scrobbles/manual_form.html' template_name = 'scrobbles/manual_form.html'
def form_valid(self, form): def form_valid(self, form):
# look for video via IMDB id item_id = form.cleaned_data.get('item_id')
data_dict = lookup_video_from_imdb(form.cleaned_data.get('imdb_id')) data_dict = None
if 'tt' in item_id:
data_dict = lookup_video_from_imdb(
form.cleaned_data.get('item_id')
)
if data_dict:
manual_scrobble_video(data_dict, self.request.user.id)
manual_scrobble_video(data_dict, self.request.user.id) if not data_dict:
logger.debug(f"Looking for sport event with ID {item_id}")
data_dict = lookup_event_from_thesportsdb(
form.cleaned_data.get('item_id')
)
if data_dict:
manual_scrobble_event(data_dict, self.request.user.id)
return HttpResponseRedirect(reverse("home")) return HttpResponseRedirect(reverse("home"))
@ -131,9 +148,7 @@ def jellyfin_websocket(request):
if not scrobble: if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response( return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)
@csrf_exempt @csrf_exempt
@ -154,6 +169,4 @@ def mopidy_websocket(request):
if not scrobble: if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST) return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response( return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)

View File

View File

@ -0,0 +1,37 @@
from django.contrib import admin
from sports.models import League, SportEvent, Team
from scrobbles.admin import ScrobbleInline
@admin.register(League)
class LeagueAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "abbreviation_str")
ordering = ("name",)
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "league")
ordering = ("name",)
@admin.register(SportEvent)
class SportEventAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"event_type",
"start",
"home_team",
"away_team",
"season",
)
list_filter = ("season", "home_team", "away_team")
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class SportsConfig(AppConfig):
name = "sports"

View File

@ -0,0 +1,214 @@
# Generated by Django 4.1.5 on 2023-01-14 21:14
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='League',
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'
),
),
('name', models.CharField(max_length=255)),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
'logo',
models.ImageField(
blank=True, null=True, upload_to='sports/league-logos/'
),
),
(
'abbreviation_str',
models.CharField(blank=True, max_length=10, null=True),
),
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='Team',
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'
),
),
('name', models.CharField(max_length=255)),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
('thesportsdb_id', models.IntegerField(blank=True, null=True)),
(
'league',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='sports.league',
),
),
],
options={
'get_latest_by': 'modified',
'abstract': False,
},
),
migrations.CreateModel(
name='SportEvent',
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'
),
),
(
'uuid',
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
'title',
models.CharField(blank=True, max_length=255, null=True),
),
(
'run_time',
models.CharField(blank=True, max_length=8, null=True),
),
(
'run_time_ticks',
models.PositiveBigIntegerField(blank=True, null=True),
),
(
'event_type',
models.CharField(
choices=[
('UK', 'Unknown'),
('GA', 'Game'),
('MA', 'Match'),
('ME', 'Meet'),
],
default='UK',
max_length=2,
),
),
('start_utc', models.DateTimeField(blank=True, null=True)),
(
'season',
models.CharField(blank=True, max_length=255, null=True),
),
(
'away_team',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='away_event_set',
to='sports.team',
),
),
(
'home_team',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name='home_event_set',
to='sports.team',
),
),
(
'league',
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to='sports.league',
),
),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-14 21:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('sports', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='sportevent',
old_name='start_utc',
new_name='start',
),
]

View File

@ -0,0 +1,117 @@
import logging
from typing import Dict
from uuid import uuid4
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
class League(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
logo = models.ImageField(upload_to="sports/league-logos/", **BNULL)
abbreviation_str = models.CharField(max_length=10, **BNULL)
thesportsdb_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
@property
def abbreviation(self):
return self.abbreviation_str
class Team(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
thesportsdb_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
class SportEvent(ScrobblableMixin):
RESUME_LIMIT = getattr(settings, 'SPORT_RESUME_LIMIT', (12 * 60) * 60)
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
class Type(models.TextChoices):
UNKNOWN = 'UK', _('Unknown')
GAME = 'GA', _('Game')
MATCH = 'MA', _('Match')
MEET = 'ME', _('Meet')
event_type = models.CharField(
max_length=2,
choices=Type.choices,
default=Type.UNKNOWN,
)
league = models.ForeignKey(League, on_delete=models.DO_NOTHING, **BNULL)
start = models.DateTimeField(**BNULL)
home_team = models.ForeignKey(
Team,
on_delete=models.DO_NOTHING,
related_name='home_event_set',
**BNULL,
)
away_team = models.ForeignKey(
Team,
on_delete=models.DO_NOTHING,
related_name='away_event_set',
**BNULL,
)
season = models.CharField(max_length=255, **BNULL)
def __str__(self):
return f"{self.start.date()} - {self.league.abbreviation} - {self.home_team} v {self.away_team}"
def get_absolute_url(self):
return reverse("sports:event_detail", kwargs={'slug': self.uuid})
@classmethod
def find_or_create(cls, data_dict: Dict) -> "Event":
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
the video and, if need, TV Series, creating both if they don't yet
exist.
"""
league_dict = {
"abbreviation_str": data_dict.get("LeagueName", ""),
"thesportsdb_id": data_dict.get("LeagueId", ""),
}
league, _created = League.objects.get_or_create(**league_dict)
home_team_dict = {
"name": data_dict.get("HomeTeamName", ""),
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
"league": league,
}
home_team, _created = Team.objects.get_or_create(**home_team_dict)
away_team_dict = {
"name": data_dict.get("AwayTeamName", ""),
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
"league": league,
}
away_team, _created = Team.objects.get_or_create(**away_team_dict)
event_dict = {
"title": data_dict.get("Name"),
"event_type": data_dict.get("ItemType"),
"home_team": home_team,
"away_team": away_team,
"start": data_dict['Start'],
"league": league,
"run_time_ticks": data_dict.get("RunTimeTicks"),
"run_time": data_dict.get("RunTime", ""),
}
event, _created = cls.objects.get_or_create(**event_dict)
return event

View File

@ -2,6 +2,7 @@ import logging
from typing import Dict from typing import Dict
from uuid import uuid4 from uuid import uuid4
from django.conf import settings
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -32,6 +33,9 @@ class Series(TimeStampedModel):
class Video(ScrobblableMixin): class Video(ScrobblableMixin):
RESUME_LIMIT = getattr(settings, 'VIDEO_RESUME_LIMIT', (12 * 60) * 60)
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
class VideoType(models.TextChoices): class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown') UNKNOWN = 'U', _('Unknown')
TV_EPISODE = 'E', _('TV Episode') TV_EPISODE = 'E', _('TV Episode')
@ -96,7 +100,6 @@ class Video(ScrobblableMixin):
video, created = cls.objects.get_or_create(**video_dict) video, created = cls.objects.get_or_create(**video_dict)
logger.debug(data_dict)
run_time_ticks = data_dict.get("RunTimeTicks", None) run_time_ticks = data_dict.get("RunTimeTicks", None)
if run_time_ticks: if run_time_ticks:
run_time_ticks = run_time_ticks // 10000 run_time_ticks = run_time_ticks // 10000

View File

@ -37,10 +37,6 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
"VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False "VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS", False
) )
PODCAST_COMPLETION_PERCENT = os.getenv(
"VROBBLER_PODCAST_COMPLETION_PERCENT", 25
)
MUSIC_COMPLETION_PERCENT = os.getenv("VROBBLER_MUSIC_COMPLETION_PERCENT", 90)
# Should we cull old in-progress scrobbles that are beyond the wait period for resuming? # Should we cull old in-progress scrobbles that are beyond the wait period for resuming?
DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True) DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
@ -48,16 +44,11 @@ DELETE_STALE_SCROBBLES = os.getenv("VROBBLER_DELETE_STALE_SCROBBLES", True)
# Used to dump data coming from srobbling sources, helpful for building new inputs # Used to dump data coming from srobbling sources, helpful for building new inputs
DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False) DUMP_REQUEST_DATA = os.getenv("VROBBLER_DUMP_REQUEST_DATA", False)
VIDEO_BACKOFF_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 15)
MUSIC_BACKOFF_SECONDS = os.getenv("VROBBLER_VIDEO_BACKOFF_SECONDS", 1)
# If you stop waching or listening to a track, how long should we wait before we
# give up on the old scrobble and start a new one? This could also be considered
# a "continue in progress scrobble" time period. So if you pause the media and
# start again, should it be a new scrobble.
VIDEO_WAIT_PERIOD_DAYS = os.getenv("VROBBLER_VIDEO_WAIT_PERIOD_DAYS", 1)
MUSIC_WAIT_PERIOD_MINUTES = os.getenv("VROBBLER_VIDEO_BACKOFF_MINUTES", 1)
THESPORTSDB_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
THESPORTSDB_BASE_URL = os.getenv(
"VROBBLER_THESPORTSDB_BASE_URL", "https://www.thesportsdb.com/api/v1/json/"
)
TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "") TMDB_API_KEY = os.getenv("VROBBLER_TMDB_API_KEY", "")
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@ -94,6 +85,7 @@ INSTALLED_APPS = [
"videos", "videos",
"music", "music",
"podcasts", "podcasts",
"sports",
"rest_framework", "rest_framework",
"allauth", "allauth",
"allauth.account", "allauth.account",
@ -283,17 +275,17 @@ LOGGING = {
"class": "logging.NullHandler", "class": "logging.NullHandler",
"level": LOG_LEVEL, "level": LOG_LEVEL,
}, },
"file": { 'sql': {
"class": "logging.handlers.RotatingFileHandler", 'class': 'logging.handlers.RotatingFileHandler',
"filename": "".join([LOG_FILE_PATH, "vrobbler.log"]), 'filename': ''.join([LOG_FILE_PATH, 'vrobbler_sql.', LOG_TYPE]),
"formatter": LOG_TYPE, 'formatter': LOG_TYPE,
"level": LOG_LEVEL, 'level': LOG_LEVEL,
}, },
"requests_file": { 'file': {
"class": "logging.handlers.RotatingFileHandler", 'class': 'logging.handlers.RotatingFileHandler',
"filename": "".join([LOG_FILE_PATH, "vrobbler_requests.log"]), 'filename': ''.join([LOG_FILE_PATH, 'vrobbler.', LOG_TYPE]),
"formatter": LOG_TYPE, 'formatter': LOG_TYPE,
"level": LOG_LEVEL, 'level': LOG_LEVEL,
}, },
}, },
"loggers": { "loggers": {
@ -303,13 +295,15 @@ LOGGING = {
"propagate": True, "propagate": True,
}, },
"django.db.backends": {"handlers": ["null"]}, "django.db.backends": {"handlers": ["null"]},
"django.server": {"handlers": ["null"]},
"vrobbler": { "vrobbler": {
"handlers": ["console", "file"], "handlers": ["file"],
"propagate": True, "propagate": True,
}, },
}, },
} }
if DEBUG: LOG_TO_CONSOLE = os.getenv("VROBBLER_LOG_TO_CONSOLE", False)
# We clear out a db with lots of games all the time in dev if LOG_TO_CONSOLE:
DATA_UPLOAD_MAX_NUMBER_FIELDS = 3000 LOGGING['loggers']['django']['handlers'] = ["console"]
LOGGING['loggers']['vrobbler']['handlers'] = ["console"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -8,6 +8,7 @@
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="description" content=""> <meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" type="image/png" href="{% static 'images/favicon.ico' %}"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.js" integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60=" crossorigin="anonymous"></script>
@ -161,12 +162,11 @@
border-color: transparent; border-color: transparent;
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
} }
#scrobble-form { width: 100% }
</style> </style>
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="{% static 'images/apple-touch-icon.png' %}">
<!-- Place favicon.ico in the root directory -->
</head> </head>
<body> <body>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow"> <header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
@ -174,7 +174,11 @@
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<input class="form-control form-control-dark w-100" type="text" placeholder="Search" aria-label="Search">
<form id="scrobble-form" action="{% url 'imdb-manual-scrobble' %}" method="post">
{% csrf_token %}
{{ imdb_form }}
</form>
<div class="navbar-nav"> <div class="navbar-nav">
<div class="nav-item text-nowrap"> <div class="nav-item text-nowrap">
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -199,6 +203,7 @@
{% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %} {% if scrobble.track %}<em>{{scrobble.track.artist}}</em><br/>{% endif %}
{% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %} {% if scrobble.podcast_episode%}<em>{{scrobble.podcast_episode.podcast}}</em><br/>{% endif %}
{% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %} {% if scrobble.video.tv_series %}<em>{{scrobble.video.tv_series }}</em><br/>{% endif %}
{% if scrobble.sport_event %}<em>{{scrobble.sport_event.league.abbreviation}}</em><br/>{% endif %}
<small>{{scrobble.timestamp|naturaltime}}<br/> <small>{{scrobble.timestamp|naturaltime}}<br/>
from {{scrobble.source}}</small> from {{scrobble.source}}</small>
<div class="progress-bar" style="margin-right:5px;"> <div class="progress-bar" style="margin-right:5px;">
@ -243,12 +248,6 @@
</a> </a>
</li> </li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="/manual/imdb/">
<span data-feather="key"></span>
IMDB scrobble
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/admin/"> <a class="nav-link" href="/admin/">
<span data-feather="key"></span> <span data-feather="key"></span>

View File

@ -9,11 +9,6 @@
<div class="btn-toolbar mb-2 mb-md-0"> <div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2"> <div class="btn-group me-2">
<form action="{% url 'imdb-manual-scrobble' %}" method="post">
{% csrf_token %}
{{ imdb_form }}
<button type="submit" class="btn btn-sm btn-outline-secondary">Scrobble</button>
</form>
<button type="button" class="btn btn-sm btn-outline-secondary">Share</button> <button type="button" class="btn btn-sm btn-outline-secondary">Share</button>
<button type="button" class="btn btn-sm btn-outline-secondary">Export</button> <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
</div> </div>
@ -37,6 +32,9 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" aria-selected="true">Weekly Artists</button> <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#artists-week" type="button" role="tab" aria-controls="home" aria-selected="true">Weekly Artists</button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="artist-month-tab" data-bs-toggle="tab" data-bs-target="#artists-month" type="button" role="tab" aria-controls="home" aria-selected="true">Monthly Artists</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week" type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly Tracks</button> <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#tracks-week" type="button" role="tab" aria-controls="profile" aria-selected="false">Weekly Tracks</button>
</li> </li>
@ -88,18 +86,44 @@
</table> </table>
</div> </div>
</div> </div>
<div class="tab-pane fade show " id="artists-month" role="tabpanel" aria-labelledby="artists-month-tab">
<h2>Top artists this month</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Artist</th>
</tr>
</thead>
<tbody>
{% for artist in top_monthly_artists %}
<tr>
<td>{{artist.num_scrobbles}}</td>
<td>{{artist.name}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<ul class="nav nav-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Latest Listened</button> <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#latest-listened" type="button" role="tab" aria-controls="home" aria-selected="true">Tracks</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" type="button" role="tab" aria-controls="profile" aria-selected="false">Latest Watched</button> <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-watched" type="button" role="tab" aria-controls="profile" aria-selected="false">Videos</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" aria-selected="false">Latest Podcasted</button> <button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-podcasted" type="button" role="tab" aria-controls="profile" aria-selected="false">Podcasts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-tab" data-bs-toggle="tab" data-bs-target="#latest-sports" type="button" role="tab" aria-controls="profile" aria-selected="false">Sports</button>
</li> </li>
</ul> </ul>
@ -143,7 +167,7 @@
{% for scrobble in video_scrobble_list %} {% for scrobble in video_scrobble_list %}
<tr> <tr>
<td>{{scrobble.timestamp|naturaltime}}</td> <td>{{scrobble.timestamp|naturaltime}}</td>
<td>{% if scrobble.video.tv_series %}E{{scrobble.video.season_number}}S{{scrobble.video.season_number}} -{% endif %} {{scrobble.video.title}}</td> <td>{% if scrobble.video.tv_series %}S{{scrobble.video.season_number}}E{{scrobble.video.episode_number}} -{% endif %} {{scrobble.video.title}}</td>
<td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td> <td>{% if scrobble.video.tv_series %}{{scrobble.video.tv_series}}{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -152,6 +176,30 @@
</div> </div>
</div> </div>
<div class="tab-pane fade show" id="latest-sports" role="tabpanel" aria-labelledby="latest-sports-tab">
<h2>Latest Sports</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Title</th>
<th scope="col">League</th>
</tr>
</thead>
<tbody>
{% for scrobble in sport_scrobble_list %}
<tr>
<td>{{scrobble.timestamp|naturaltime}}</td>
<td>{{scrobble.sport_event.title}}</td>
<td>{{scrobble.sport_event.league.abbreviation}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade show" id="latest-podcasted" role="tabpanel" aria-labelledby="latest-podcasted-tab"> <div class="tab-pane fade show" id="latest-podcasted" role="tabpanel" aria-labelledby="latest-podcasted-tab">
<h2>Latest Podcasted</h2> <h2>Latest Podcasted</h2>
<div class="table-responsive"> <div class="table-responsive">
@ -175,6 +223,7 @@
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -17,7 +17,7 @@ urlpatterns = [
path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")), path("api/v1/scrobbles/", include(scrobble_urls, namespace="scrobbles")),
path( path(
'manual/imdb/', 'manual/imdb/',
scrobbles_views.ManualImdbScrobbleView.as_view(), scrobbles_views.ManualScrobbleView.as_view(),
name='imdb-manual-scrobble', name='imdb-manual-scrobble',
), ),
path("", include(video_urls, namespace="videos")), path("", include(video_urls, namespace="videos")),