diff --git a/poetry.lock b/poetry.lock index 5c30b64..bc8843a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -667,6 +667,19 @@ category = "dev" optional = false 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]] name = "musicbrainzngs" version = "0.7.1" @@ -861,6 +874,18 @@ category = "dev" optional = false 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]] name = "pytest" version = "7.2.0" @@ -1095,7 +1120,7 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.28.1" +version = "2.28.2" description = "Python HTTP for Humans." category = "main" optional = false @@ -1103,7 +1128,7 @@ python-versions = ">=3.7, <4" [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset-normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<1.27" @@ -1451,7 +1476,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "0d571d0abd62d2c4614bc29fb0475bd0bad0b0ef99b789c8c9d46fde87515a89" +content-hash = "39b4ea9b1b67a317d159760bff3def91ead580997052759a3f64521044e0b5ae" [metadata.files] amqp = [ @@ -1946,6 +1971,10 @@ mccabe = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {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 = [ {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, {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"}, ] 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_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"}, {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.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 = [ {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, {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"}, ] requests = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, ] requests-oauthlib = [ {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, diff --git a/pyproject.toml b/pyproject.toml index 49e5120..dc73fd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ django-simple-history = "^3.1.1" whitenoise = "^6.3.0" musicbrainzngs = "^0.7.1" cinemagoer = "^2022.12.27" +pysportsdb = "^0.1.0" [tool.poetry.dev-dependencies] Werkzeug = "2.0.3" diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index f7aeccc..6d74364 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -148,6 +148,25 @@ class Scrobble(TimeStampedModel): scrobble, backoff, wait_period, scrobble_data ) + @classmethod + def create_or_update_for_sport_event( + cls, event: "SportEvent", user_id: int, jellyfin_data: dict + ) -> "Scrobble": + jellyfin_data['sport_event_id'] = event.id + scrobble = ( + cls.objects.filter(sport_event=event, user_id=user_id) + .order_by('-modified') + .first() + ) + + # Backoff is how long until we consider this a new scrobble + backoff = timezone.now() + timedelta(minutes=VIDEO_BACKOFF) + wait_period = timezone.now() + timedelta(days=VIDEO_WAIT_PERIOD) + + return cls.update_or_create( + scrobble, backoff, wait_period, jellyfin_data + ) + @classmethod def update_or_create( cls, diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index b5f7aca..b7d2830 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -9,6 +9,7 @@ from podcasts.models import Episode from scrobbles.models import Scrobble from scrobbles.utils import convert_to_seconds, parse_mopidy_uri from videos.models import Video +from sports.models import SportEvent logger = logging.getLogger(__name__) @@ -104,18 +105,23 @@ def create_jellyfin_scrobble_dict(data_dict: dict, user_id: int) -> dict: if data_dict.get("PlayedToCompletion"): jellyfin_status = "stopped" - playback_position_ticks = data_dict.get("PlaybackPositionTicks") // 10000 - if playback_position_ticks <= 0: - playback_position_ticks = None + playback_position_ticks = None + if data_dict.get("PlaybackPositionTicks"): + 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 { "user_id": user_id, "timestamp": parse(data_dict.get("UtcTimestamp")), "playback_position_ticks": playback_position_ticks, - "playback_position": convert_to_seconds( - data_dict.get("PlaybackPosition") - ), + "playback_position": playback_position, "source": "Jellyfin", "source_id": data_dict.get('MediaSourceId'), "jellyfin_status": jellyfin_status, @@ -193,3 +199,18 @@ def manual_scrobble_video(data_dict: dict, user_id: Optional[int]): scrobble_dict = create_jellyfin_scrobble_dict(data_dict, user_id) 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 + ) diff --git a/vrobbler/apps/scrobbles/thesportsdb.py b/vrobbler/apps/scrobbles/thesportsdb.py new file mode 100644 index 0000000..568d06f --- /dev/null +++ b/vrobbler/apps/scrobbles/thesportsdb.py @@ -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 diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index c5e594b..fa0ca33 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -16,16 +16,19 @@ from scrobbles.constants import ( JELLYFIN_AUDIO_ITEM_TYPES, JELLYFIN_VIDEO_ITEM_TYPES, ) +from scrobbles.forms import ScrobbleForm from scrobbles.imdb import lookup_video_from_imdb from scrobbles.models import Scrobble from scrobbles.scrobblers import ( jellyfin_scrobble_track, jellyfin_scrobble_video, + manual_scrobble_event, manual_scrobble_video, mopidy_scrobble_podcast, mopidy_scrobble_track, ) from scrobbles.serializers import ScrobbleSerializer +from scrobbles.thesportsdb import lookup_event_from_thesportsdb from vrobbler.apps.music.aggregators import ( scrobble_counts, @@ -33,7 +36,6 @@ from vrobbler.apps.music.aggregators import ( top_tracks, week_of_scrobbles, ) -from scrobbles.forms import ScrobbleForm logger = logging.getLogger(__name__) @@ -95,7 +97,7 @@ class ManualScrobbleView(FormView): def form_valid(self, form): - item_id = form.cleaned_data.get('itme_id') + item_id = form.cleaned_data.get('item_id') data_dict = None if 'tt' in item_id: data_dict = lookup_video_from_imdb( @@ -104,12 +106,13 @@ class ManualScrobbleView(FormView): if data_dict: manual_scrobble_video(data_dict, self.request.user.id) - # if not data_dict: - # data_dict = lookup_event_from_thesportsdb( - # form.cleaned_data.get('item_id') - # ) - # if data_dict: - # manual_scrobble_event(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")) diff --git a/vrobbler/apps/sports/models.py b/vrobbler/apps/sports/models.py index 9467d7c..ac448e4 100644 --- a/vrobbler/apps/sports/models.py +++ b/vrobbler/apps/sports/models.py @@ -78,27 +78,35 @@ class SportEvent(ScrobblableMixin): exist. """ - league_dict = {"name": data_dict.get("LeagueName", "")} + 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 = { - "event_type": SportEvent.Type.GAME, + "title": data_dict.get("Name"), + "event_type": data_dict.get("ItemType"), "home_team": home_team, "away_team": away_team, - "start_utc": data_dict['SportEventStart'], + "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) diff --git a/vrobbler/apps/videos/models.py b/vrobbler/apps/videos/models.py index f6436ed..b0da4e5 100644 --- a/vrobbler/apps/videos/models.py +++ b/vrobbler/apps/videos/models.py @@ -96,7 +96,6 @@ class Video(ScrobblableMixin): video, created = cls.objects.get_or_create(**video_dict) - logger.debug(data_dict) run_time_ticks = data_dict.get("RunTimeTicks", None) if run_time_ticks: run_time_ticks = run_time_ticks // 10000