Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58be8d26a0 | |||
| 0fede269b1 | |||
| 6cdcf4ff6f | |||
| 0634b94368 | |||
| 0ab7c563cf |
@ -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>"]
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
)
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
BIN
vrobbler/static/images/apple-touch-icon.png
Normal file
BIN
vrobbler/static/images/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
vrobbler/static/images/favicon.ico
Normal file
BIN
vrobbler/static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user