Compare commits

...

14 Commits
0.6.0 ... 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
16 changed files with 153 additions and 173 deletions

View File

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

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)
return (
Track.objects.annotate(num_scrobbles=Count("scrobble", distinct=True))
.filter(time_filter)
Track.objects.filter(time_filter)
.annotate(num_scrobbles=Count("scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit]
)
@ -93,10 +93,8 @@ def top_artists(filter: str = "today", limit: int = 15) -> List["Artist"]:
)
return (
Artist.objects.annotate(
num_scrobbles=Count("track__scrobble", distinct=True)
)
.filter(time_filter)
Artist.objects.filter(time_filter)
.annotate(num_scrobbles=Count("track__scrobble", distinct=True))
.order_by("-num_scrobbles")[:limit]
)

View File

@ -4,6 +4,7 @@ from uuid import uuid4
import musicbrainzngs
from django.apps.config import cached_property
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -93,6 +94,8 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
class Opinion(models.IntegerChoices):
DOWN = -1, 'Thumbs down'
NEUTRAL = 0, 'No opinion'

View File

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

View File

@ -1,3 +1,2 @@
#!/usr/bin/env python3
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
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')
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
run_time_ticks = run_time_seconds * 1000 * 1000

View File

@ -1,8 +1,6 @@
import logging
from datetime import timedelta
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
@ -16,10 +14,6 @@ from sports.models import SportEvent
logger = logging.getLogger(__name__)
User = get_user_model()
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):
@ -46,26 +40,20 @@ class Scrobble(TimeStampedModel):
@property
def percent_played(self) -> int:
playback_ticks = None
percent_played = 100
if not self.media_obj.run_time_ticks:
logger.warning(
f"{self} has no run_time_ticks value, cannot show percent played"
)
return percent_played
return 100
playback_ticks = self.playback_position_ticks
if not playback_ticks:
logger.info(
"No playback_position_ticks, estimating based on creation time"
)
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 = 100
percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
return percent
@property
@ -82,34 +70,54 @@ class Scrobble(TimeStampedModel):
return media_obj
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
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":
jellyfin_data['video_id'] = video.id
scrobble_data['video_id'] = video.id
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')
.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
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
logger.debug(
f"No existing scrobble for video {video}, 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 create_or_update_for_track(
cls, track: "Track", user_id: int, scrobble_data: dict
) -> "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 = (
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')
.first()
)
@ -118,115 +126,109 @@ class Scrobble(TimeStampedModel):
f"Found existing scrobble for track {track}, updating",
{"scrobble_data": scrobble_data},
)
return cls.update(scrobble, scrobble_data)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
return cls.update_or_create(
scrobble, backoff, wait_period, scrobble_data
logger.debug(
f"No existing scrobble for track {track}, creating",
{"scrobble_data": scrobble_data},
)
# If creating a new scrobble, we don't need status
scrobble_data.pop('mopidy_status')
return cls.create(scrobble_data)
@classmethod
def create_or_update_for_podcast_episode(
cls, episode: "Episode", user_id: int, scrobble_data: dict
) -> "Scrobble":
scrobble_data['podcast_episode_id'] = episode.id
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')
.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(
f"Found existing scrobble for podcast {episode}, updating",
f"No existing scrobble for podcast epsiode {episode}, creating",
{"scrobble_data": scrobble_data},
)
backoff = timezone.now() + timedelta(seconds=TRACK_BACKOFF)
wait_period = timezone.now() + timedelta(minutes=TRACK_WAIT_PERIOD)
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
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":
jellyfin_data['sport_event_id'] = event.id
scrobble_data['sport_event_id'] = event.id
scrobble = (
cls.objects.filter(sport_event=event, user_id=user_id)
cls.objects.filter(
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)
# 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
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_or_create(
cls,
scrobble: Optional["Scrobble"],
backoff,
wait_period,
scrobble_data: dict,
) -> Optional["Scrobble"]:
def update(cls, scrobble: "Scrobble", scrobble_data: dict) -> "Scrobble":
# Status is a field we get from Mopidy, which refuses to poll us
scrobble_status = scrobble_data.pop('mopidy_status', None)
if not scrobble_status:
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}"
)
scrobble.update_ticks(scrobble_data)
logger.debug(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
if scrobble_status == "stopped":
return scrobble.stop()
# On stop, stop progress and send it to the check for completion
if scrobble_status == "stopped":
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
if scrobble_status == "paused":
return scrobble.pause()
if scrobble_status == "resumed":
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,
)
for key, value in scrobble_data.items():
setattr(scrobble, key, value)
scrobble.save()
check_scrobble_for_finish(scrobble)
return scrobble
@classmethod
def create(
cls,
scrobble_data: dict,
) -> "Scrobble":
scrobble_data['scrobble_log'] = ""
scrobble = cls.objects.create(
**scrobble_data,
)
return scrobble
def stop(self) -> None:

View File

@ -86,15 +86,12 @@ def mopidy_scrobble_track(
"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

View File

@ -1,5 +1,4 @@
import logging
from typing import Any, Optional
from urllib.parse import unquote
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:
completion_percent = getattr(settings, "MUSIC_COMPLETION_PERCENT", 95)
if scrobble.video:
completion_percent = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
if scrobble.podcast_episode:
completion_percent = getattr(
settings, "PODCAST_COMPLETION_PERCENT", 25
)
completion_percent = scrobble.media_obj.COMPLETION_PERCENT
if scrobble.percent_played >= completion_percent:
logger.debug(f"Completion percent {completion_percent} met, finishing")
scrobble.in_progress = False
scrobble.is_paused = False
scrobble.played_to_completion = True

View File

@ -39,18 +39,6 @@ from vrobbler.apps.music.aggregators import (
logger = logging.getLogger(__name__)
TRUTHY_VALUES = [
'true',
'1',
't',
'y',
'yes',
'yeah',
'yup',
'certainly',
'uh-huh',
]
class RecentScrobbleList(ListView):
model = Scrobble
@ -148,9 +136,7 @@ def jellyfin_websocket(request):
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)
@csrf_exempt
@ -171,6 +157,4 @@ def mopidy_websocket(request):
if not scrobble:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{'scrobble_id': scrobble.id}, status=status.HTTP_201_CREATED
)
return Response({'scrobble_id': scrobble.id}, status=status.HTTP_200_OK)

View File

@ -2,6 +2,7 @@ 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 _
@ -38,6 +39,8 @@ class Team(TimeStampedModel):
class SportEvent(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
class Type(models.TextChoices):
UNKNOWN = 'UK', _('Unknown')
GAME = 'GA', _('Game')

View File

@ -2,6 +2,7 @@ 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 _
@ -32,6 +33,8 @@ class Series(TimeStampedModel):
class Video(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
class VideoType(models.TextChoices):
UNKNOWN = 'U', _('Unknown')
TV_EPISODE = 'E', _('TV Episode')

View File

@ -37,10 +37,6 @@ KEEP_DETAILED_SCROBBLE_LOGS = os.getenv(
"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?
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
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(
@ -288,17 +275,17 @@ LOGGING = {
"class": "logging.NullHandler",
"level": LOG_LEVEL,
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "".join([LOG_FILE_PATH, "vrobbler.log"]),
"formatter": LOG_TYPE,
"level": LOG_LEVEL,
'sql': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': ''.join([LOG_FILE_PATH, 'vrobbler_sql.', LOG_TYPE]),
'formatter': LOG_TYPE,
'level': LOG_LEVEL,
},
"requests_file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "".join([LOG_FILE_PATH, "vrobbler_requests.log"]),
"formatter": LOG_TYPE,
"level": LOG_LEVEL,
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': ''.join([LOG_FILE_PATH, 'vrobbler.', LOG_TYPE]),
'formatter': LOG_TYPE,
'level': LOG_LEVEL,
},
},
"loggers": {
@ -310,12 +297,13 @@ LOGGING = {
"django.db.backends": {"handlers": ["null"]},
"django.server": {"handlers": ["null"]},
"vrobbler": {
"handlers": ["console", "file"],
"handlers": ["file"],
"propagate": True,
},
},
}
if DEBUG:
# We clear out a db with lots of games all the time in dev
DATA_UPLOAD_MAX_NUMBER_FIELDS = 3000
LOG_TO_CONSOLE = os.getenv("VROBBLER_LOG_TO_CONSOLE", False)
if LOG_TO_CONSOLE:
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 name="description" content="">
<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">
<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>
@ -165,8 +166,7 @@
</style>
{% block head_extra %}{% endblock %}
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<!-- Place favicon.ico in the root directory -->
<link rel="apple-touch-icon" href="{% static 'images/apple-touch-icon.png' %}">
</head>
<body>
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">