Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 907ef802bc | |||
| d700b581a1 | |||
| 7605c672f6 | |||
| 8d1df806d7 | |||
| 0f562b7c58 | |||
| fe53b68714 | |||
| 7e2915850f | |||
| 90687a6b43 | |||
| 07cfb03eb6 |
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.6.1"
|
version = "0.6.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -94,7 +94,6 @@ 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)
|
COMPLETION_PERCENT = getattr(settings, 'MUSIC_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
class Opinion(models.IntegerChoices):
|
class Opinion(models.IntegerChoices):
|
||||||
|
|||||||
@ -35,7 +35,6 @@ 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)
|
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)
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -70,61 +70,8 @@ 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})"
|
||||||
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(
|
||||||
@ -133,13 +80,15 @@ class Scrobble(TimeStampedModel):
|
|||||||
scrobble_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 and scrobble.resumable(
|
if scrobble:
|
||||||
scrobble_data['playback_position_ticks']
|
|
||||||
):
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Found existing scrobble for video {video}, updating",
|
f"Found existing scrobble for video {video}, updating",
|
||||||
{"scrobble_data": scrobble_data},
|
{"scrobble_data": scrobble_data},
|
||||||
@ -164,13 +113,15 @@ class Scrobble(TimeStampedModel):
|
|||||||
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()
|
||||||
)
|
)
|
||||||
if scrobble and scrobble.resumable(
|
if scrobble:
|
||||||
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},
|
||||||
@ -192,13 +143,15 @@ class Scrobble(TimeStampedModel):
|
|||||||
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 and scrobble.resumable(
|
if scrobble:
|
||||||
scrobble_data['playback_position_ticks']
|
|
||||||
):
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Found existing scrobble for podcast {episode}, updating",
|
f"Found existing scrobble for podcast {episode}, updating",
|
||||||
{"scrobble_data": scrobble_data},
|
{"scrobble_data": scrobble_data},
|
||||||
@ -219,13 +172,15 @@ class Scrobble(TimeStampedModel):
|
|||||||
) -> "Scrobble":
|
) -> "Scrobble":
|
||||||
scrobble_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,
|
||||||
|
played_to_completion=False,
|
||||||
|
)
|
||||||
.order_by('-modified')
|
.order_by('-modified')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if scrobble and scrobble.resumable(
|
if scrobble:
|
||||||
scrobble_data['playback_position_ticks']
|
|
||||||
):
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Found existing scrobble for sport event {event}, updating",
|
f"Found existing scrobble for sport event {event}, updating",
|
||||||
{"scrobble_data": scrobble_data},
|
{"scrobble_data": scrobble_data},
|
||||||
@ -246,8 +201,6 @@ class Scrobble(TimeStampedModel):
|
|||||||
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:
|
|
||||||
scrobble_status = 'resumed'
|
|
||||||
|
|
||||||
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
|
logger.debug(f"Scrobbling to {scrobble} with status {scrobble_status}")
|
||||||
scrobble.update_ticks(scrobble_data)
|
scrobble.update_ticks(scrobble_data)
|
||||||
@ -287,7 +240,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 and not self.played_to_completion:
|
if self.is_paused:
|
||||||
logger.warning("Scrobble already paused")
|
logger.warning("Scrobble already paused")
|
||||||
return
|
return
|
||||||
self.is_paused = True
|
self.is_paused = True
|
||||||
@ -295,15 +248,10 @@ 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.played_to_completion:
|
if self.is_paused or not self.in_progress:
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
self.in_progress = True
|
self.in_progress = True
|
||||||
return self.save(
|
return self.save(update_fields=["is_paused", "in_progress"])
|
||||||
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")
|
||||||
|
|||||||
@ -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,17 +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(
|
logger.debug(f"Completion percent {completion_percent} met, finishing")
|
||||||
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
|
||||||
|
|||||||
@ -39,18 +39,6 @@ from vrobbler.apps.music.aggregators import (
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -39,7 +39,6 @@ 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)
|
COMPLETION_PERCENT = getattr(settings, 'SPORT_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
class Type(models.TextChoices):
|
class Type(models.TextChoices):
|
||||||
|
|||||||
@ -33,7 +33,6 @@ 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)
|
COMPLETION_PERCENT = getattr(settings, 'VIDEO_COMPLETION_PERCENT', 90)
|
||||||
|
|
||||||
class VideoType(models.TextChoices):
|
class VideoType(models.TextChoices):
|
||||||
|
|||||||
Reference in New Issue
Block a user