Compare commits

...

5 Commits
0.6.0 ... 0.6.1

13 changed files with 197 additions and 143 deletions

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "0.6.0" version = "0.6.1"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

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'

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

@ -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
@ -84,149 +72,210 @@ class Scrobble(TimeStampedModel):
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
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: if scrobble and scrobble.resumable(
scrobble_data['playback_position_ticks']
):
logger.debug( logger.debug(
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)
.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 create_or_update_for_sport_event( def create_or_update_for_sport_event(
cls, event: "SportEvent", user_id: int, jellyfin_data: dict cls, event: "SportEvent", user_id: int, scrobble_data: dict
) -> "Scrobble": ) -> "Scrobble":
jellyfin_data['sport_event_id'] = event.id scrobble_data['sport_event_id'] = event.id
scrobble = ( scrobble = (
cls.objects.filter(sport_event=event, user_id=user_id) cls.objects.filter(sport_event=event, 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 sport event {event}, 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 sport event {event}, 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 update_or_create( def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
scrobble_data: dict,
) -> Optional["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
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:
@ -238,7 +287,7 @@ class Scrobble(TimeStampedModel):
check_scrobble_for_finish(self) check_scrobble_for_finish(self)
def pause(self) -> None: 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
@ -246,10 +295,15 @@ class Scrobble(TimeStampedModel):
check_scrobble_for_finish(self) check_scrobble_for_finish(self)
def resume(self) -> None: 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(
update_fields=[
"is_paused",
"in_progress",
]
)
def update_ticks(self, data) -> None: def update_ticks(self, data) -> None:
self.playback_position_ticks = data.get("playback_position_ticks") self.playback_position_ticks = data.get("playback_position_ticks")

View File

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

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

View File

@ -148,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
@ -171,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

@ -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,9 @@ class Team(TimeStampedModel):
class SportEvent(ScrobblableMixin): 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): class Type(models.TextChoices):
UNKNOWN = 'UK', _('Unknown') UNKNOWN = 'UK', _('Unknown')
GAME = 'GA', _('Game') GAME = 'GA', _('Game')

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

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