Compare commits

..

17 Commits
0.5.1 ... 0.6.2

Author SHA1 Message Date
907ef802bc Bump version to 0.6.2
Releases

* Fix for double scrobbling
* Fix error messages
* Fix bug in IMDB lookup when runtime is missing
* Fix filter order with annotations for top tracks and artists
* Fix error in completion percentage issues
2023-01-17 00:05:54 -05:00
d700b581a1 Fix filter order with annotations
We were getting all artists of all time, not just for the time period
2023-01-17 00:02:20 -05:00
7605c672f6 Fix str rep for scrobbles 2023-01-16 23:54:19 -05:00
8d1df806d7 Fix IMDB fetch when runtimes are not present 2023-01-16 23:46:04 -05:00
0f562b7c58 Fix resume bug, stop trying to avoid resuming
Turns out trying to not resume in-progress Scrobbles is super painful.
Maybe I'll come up with a better idea later, but for now, I'd rather
just resurrect old paused scrobbles of past tracks, rather than
completely mess up all other aspects of scrobbling.
2023-01-16 23:44:49 -05:00
fe53b68714 Fix silly error message 2023-01-16 20:40:02 -05:00
7e2915850f Fix double scrobbling for real 2023-01-16 20:39:27 -05:00
90687a6b43 Fix complicated completion percentage 2023-01-16 19:36:10 -05:00
07cfb03eb6 Fix really irritating double scrobble bug 2023-01-16 19:34:40 -05:00
58be8d26a0 Bump version to 0.6.1 2023-01-16 17:53:14 -05:00
0fede269b1 Fix checking for completion percentages 2023-01-16 17:52:07 -05:00
6cdcf4ff6f Fix Mopidy resuming messing things up 2023-01-16 16:12:01 -05:00
0634b94368 Fix how we calcualte resuming a scrobble 2023-01-16 13:43:14 -05:00
0ab7c563cf Fix favicon static files and logging 2023-01-15 19:06:54 -05:00
363a132df2 Bump version to 0.6.0 2023-01-15 02:42:14 -05:00
c484905d11 Add manual scrobbling by TheSportsDB ID 2023-01-15 02:41:36 -05:00
0378dfe6eb Add drone badge 2023-01-15 01:45:35 -05:00
19 changed files with 304 additions and 187 deletions

View File

@ -1,6 +1,8 @@
Vrobbler 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.
At the most basic level, you should be able to run `pip install vrobbler` to the latest version from pypi.org. At the most basic level, you should be able to run `pip install vrobbler` to the latest version from pypi.org.

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.1" version = "0.6.2"
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

@ -71,8 +71,8 @@ def top_tracks(filter: str = "today", limit: int = 15) -> List["Track"]:
time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR) time_filter = Q(scrobble__timestamp__gte=STARTING_DAY_OF_CURRENT_YEAR)
return ( return (
Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True)) Track.objects.filter(time_filter)
.filter(time_filter) .annotate(num_scrobbles=Count("scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit] .order_by("-num_scrobbles")[:limit]
) )
@ -93,10 +93,8 @@ def top_artists(filter: str = "today", limit: int = 15) -> List["Artist"]:
) )
return ( return (
Artist.objects.annotate( Artist.objects.filter(time_filter)
num_scrobbles=Count("track__scrobble", distinct=True) .annotate(num_scrobbles=Count("track__scrobble", distinct=True))
)
.filter(time_filter)
.order_by("-num_scrobbles")[:limit] .order_by("-num_scrobbles")[:limit]
) )

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,8 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin): class Track(ScrobblableMixin):
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'

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,8 @@ class Podcast(TimeStampedModel):
class Episode(ScrobblableMixin): class Episode(ScrobblableMixin):
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

@ -1,3 +1,2 @@
#!/usr/bin/env python3
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"] JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"] JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]

View File

@ -19,7 +19,11 @@ def lookup_video_from_imdb(imdb_id: str) -> dict:
lookup_id = imdb_id.strip('tt') lookup_id = imdb_id.strip('tt')
media = imdb_client.get_movie(lookup_id) media = imdb_client.get_movie(lookup_id)
run_time_seconds = int(media.get('runtimes')[0]) * 60 run_time_seconds = 60 * 60
runtimes = media.get("runtimes")
if runtimes:
run_time_seconds = int(runtimes)[0] * 60
# Ticks otherwise known as miliseconds # Ticks otherwise known as miliseconds
run_time_ticks = run_time_seconds * 1000 * 1000 run_time_ticks = run_time_seconds * 1000 * 1000

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
@ -16,10 +14,6 @@ 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):
@ -46,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
@ -82,34 +70,54 @@ class Scrobble(TimeStampedModel):
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}" timestamp = self.timestamp.strftime('%Y-%m-%d')
return f"Scrobble of {self.media_obj} ({timestamp})"
@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
scrobble = ( scrobble = (
cls.objects.filter(video=video, user_id=user_id) cls.objects.filter(
video=video,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
if scrobble:
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,
played_to_completion=False,
)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
@ -118,96 +126,109 @@ class Scrobble(TimeStampedModel):
f"Found existing scrobble for track {track}, updating", f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data}, {"scrobble_data": scrobble_data},
) )
return cls.update(scrobble, scrobble_data)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF) logger.debug(
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD) f"No existing scrobble for track {track}, creating",
{"scrobble_data": scrobble_data},
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
) )
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status')
return cls.create(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,
played_to_completion=False,
)
.order_by('-modified') .order_by('-modified')
.first() .first()
) )
if scrobble:
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(
) -> Optional["Scrobble"]: sport_event=event,
user_id=user_id,
played_to_completion=False,
)
.order_by('-modified')
.first()
)
if scrobble:
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:
logger.warning(
f"No status update found in message, not scrobbling"
)
return
if scrobble: logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
logger.debug( scrobble.update_ticks(scrobble_data)
f"Scrobbling to {scrobble} with status {scrobble_status}"
)
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
if scrobble_status == "paused":
scrobble.pause()
if scrobble_status == "resumed":
scrobble.resume()
# On pause, set is_paused and stop scrobbling for key, value in scrobble_data.items():
if scrobble_status == "paused": setattr(scrobble, key, value)
return scrobble.pause() scrobble.save()
check_scrobble_for_finish(scrobble)
if scrobble_status == "resumed": return scrobble
return scrobble.resume()
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
# 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
check_scrobble_for_finish(scrobble)
else:
logger.debug(
f"Creating new scrobble with status {scrobble_status}"
)
# If we default this to "" we can probably remove this
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
@classmethod
def create(
cls,
scrobble_data: dict,
) -> "Scrobble":
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
return scrobble return scrobble
def stop(self) -> None: def stop(self) -> None:

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

@ -1,5 +1,4 @@
import logging import logging
from typing import Any, Optional
from urllib.parse import unquote from urllib.parse import unquote
from dateutil.parser import ParserError, parse from dateutil.parser import ParserError, parse
@ -68,14 +67,11 @@ def parse_mopidy_uri(uri: str) -> dict:
def check_scrobble_for_finish(scrobble: "Scrobble") -> None: def check_scrobble_for_finish(scrobble: "Scrobble") -> None:
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 95) completion_percent = scrobble.media_obj.COMPLETION_PERCENT
if scrobble.video:
completion_percent = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
if scrobble.podcast_episode:
completion_percent = getattr(
settings, "PODCAST_COMPLETION_PERCENT", 25
)
if scrobble.percent_played >= completion_percent: if scrobble.percent_played >= completion_percent:
logger.debug(f"Completion percent {completion_percent} met, finishing")
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

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,22 +36,9 @@ from vrobbler.apps.music.aggregators import (
top_tracks, top_tracks,
week_of_scrobbles, week_of_scrobbles,
) )
from scrobbles.forms import ScrobbleForm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRUTHY_VALUES = [
'true',
'1',
't',
'y',
'yes',
'yeah',
'yup',
'certainly',
'uh-huh',
]
class RecentScrobbleList(ListView): class RecentScrobbleList(ListView):
model = Scrobble model = Scrobble
@ -95,7 +85,7 @@ class ManualScrobbleView(FormView):
def form_valid(self, form): def form_valid(self, form):
item_id = form.cleaned_data.get('itme_id') item_id = form.cleaned_data.get('item_id')
data_dict = None data_dict = None
if 'tt' in item_id: if 'tt' in item_id:
data_dict = lookup_video_from_imdb( data_dict = lookup_video_from_imdb(
@ -104,12 +94,13 @@ class ManualScrobbleView(FormView):
if data_dict: 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: if not data_dict:
# data_dict = lookup_event_from_thesportsdb( logger.debug(f"Looking for sport event with ID {item_id}")
# form.cleaned_data.get('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) if data_dict:
manual_scrobble_event(data_dict, self.request.user.id)
return HttpResponseRedirect(reverse("home")) return HttpResponseRedirect(reverse("home"))
@ -145,9 +136,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
@ -168,6 +157,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

@ -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 _
@ -38,6 +39,8 @@ class Team(TimeStampedModel):
class SportEvent(ScrobblableMixin): class SportEvent(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
class Type(models.TextChoices): class Type(models.TextChoices):
UNKNOWN = 'UK', _('Unknown') UNKNOWN = 'UK', _('Unknown')
GAME = 'GA', _('Game') GAME = 'GA', _('Game')
@ -78,27 +81,35 @@ class SportEvent(ScrobblableMixin):
exist. 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) league, _created = League.objects.get_or_create(**league_dict)
home_team_dict = { home_team_dict = {
"name": data_dict.get("HomeTeamName", ""), "name": data_dict.get("HomeTeamName", ""),
"thesportsdb_id": data_dict.get("HomeTeamId", ""),
"league": league, "league": league,
} }
home_team, _created = Team.objects.get_or_create(**home_team_dict) home_team, _created = Team.objects.get_or_create(**home_team_dict)
away_team_dict = { away_team_dict = {
"name": data_dict.get("AwayTeamName", ""), "name": data_dict.get("AwayTeamName", ""),
"thesportsdb_id": data_dict.get("AwayTeamId", ""),
"league": league, "league": league,
} }
away_team, _created = Team.objects.get_or_create(**away_team_dict) away_team, _created = Team.objects.get_or_create(**away_team_dict)
event_dict = { event_dict = {
"event_type": SportEvent.Type.GAME, "title": data_dict.get("Name"),
"event_type": data_dict.get("ItemType"),
"home_team": home_team, "home_team": home_team,
"away_team": away_team, "away_team": away_team,
"start_utc": data_dict['SportEventStart'], "start": data_dict['Start'],
"league": league, "league": league,
"run_time_ticks": data_dict.get("RunTimeTicks"),
"run_time": data_dict.get("RunTime", ""),
} }
event, _created = cls.objects.get_or_create(**event_dict) event, _created = cls.objects.get_or_create(**event_dict)

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,8 @@ class Series(TimeStampedModel):
class Video(ScrobblableMixin): class Video(ScrobblableMixin):
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 +99,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,15 +44,6 @@ 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_API_KEY = os.getenv("VROBBLER_THESPORTSDB_API_KEY", "2")
THESPORTSDB_BASE_URL = os.getenv( THESPORTSDB_BASE_URL = os.getenv(
@ -288,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": {
@ -310,12 +297,13 @@ LOGGING = {
"django.db.backends": {"handlers": ["null"]}, "django.db.backends": {"handlers": ["null"]},
"django.server": {"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>
@ -165,8 +166,7 @@
</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">