|
|
@@ -17,6 +17,7 @@ from bricksets.models import BrickSet
|
|
|
from dataclass_wizard.errors import ParseError
|
|
|
from django.conf import settings
|
|
|
from django.contrib.auth import get_user_model
|
|
|
+from django.utils.functional import cached_property
|
|
|
from django.core.files import File
|
|
|
from django.db import models
|
|
|
from django.urls import reverse
|
|
|
@@ -41,9 +42,11 @@ from profiles.utils import (
|
|
|
)
|
|
|
from puzzles.models import Puzzle
|
|
|
from scrobbles import dataclasses as logdata
|
|
|
+from scrobbles import constants
|
|
|
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
|
|
|
from scrobbles.importers.lastfm import LastFM
|
|
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
|
|
+from scrobbles.mixins import ScrobblableItem
|
|
|
from scrobbles.stats import build_charts
|
|
|
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
|
|
from sports.models import SportEvent
|
|
|
@@ -503,68 +506,18 @@ class ChartRecord(TimeStampedModel):
|
|
|
|
|
|
class Scrobble(TimeStampedModel):
|
|
|
"""A scrobble tracks played media items by a user."""
|
|
|
-
|
|
|
- class MediaType(models.TextChoices):
|
|
|
- """Enum mapping a media model type to a string"""
|
|
|
-
|
|
|
- VIDEO = "Video", "Video"
|
|
|
- TRACK = "Track", "Track"
|
|
|
- PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
|
|
|
- SPORT_EVENT = "SportEvent", "Sport event"
|
|
|
- BOOK = "Book", "Book"
|
|
|
- PAPER = "Paper", "Paper"
|
|
|
- VIDEO_GAME = "VideoGame", "Video game"
|
|
|
- BOARD_GAME = "BoardGame", "Board game"
|
|
|
- GEO_LOCATION = "GeoLocation", "GeoLocation"
|
|
|
- TRAIL = "Trail", "Trail"
|
|
|
- BEER = "Beer", "Beer"
|
|
|
- PUZZLE = "Puzzle", "Puzzle"
|
|
|
- FOOD = "Food", "Food"
|
|
|
- TASK = "Task", "Task"
|
|
|
- WEBPAGE = "WebPage", "Web Page"
|
|
|
- LIFE_EVENT = "LifeEvent", "Life event"
|
|
|
- MOOD = "Mood", "Mood"
|
|
|
- BRICKSET = "BrickSet", "Brick set"
|
|
|
-
|
|
|
- @classmethod
|
|
|
- def list(cls):
|
|
|
- return list(map(lambda c: c.value, cls))
|
|
|
-
|
|
|
uuid = models.UUIDField(editable=False, **BNULL)
|
|
|
- video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- podcast_episode = models.ForeignKey(
|
|
|
- PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- sport_event = models.ForeignKey(
|
|
|
- SportEvent, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- video_game = models.ForeignKey(
|
|
|
- VideoGame, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- board_game = models.ForeignKey(
|
|
|
- BoardGame, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- geo_location = models.ForeignKey(
|
|
|
- GeoLocation, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- life_event = models.ForeignKey(
|
|
|
- LifeEvent, on_delete=models.DO_NOTHING, **BNULL
|
|
|
- )
|
|
|
- mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
- brick_set = models.ForeignKey(
|
|
|
- BrickSet, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ item = models.ForeignKey(
|
|
|
+ ScrobblableItem,
|
|
|
+ null=True,
|
|
|
+ on_delete=models.CASCADE,
|
|
|
+ related_name="scrobbles",
|
|
|
)
|
|
|
media_type = models.CharField(
|
|
|
- max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
|
|
|
+ max_length=14,
|
|
|
+ choices=constants.MediaType.choices,
|
|
|
+ db_index=True,
|
|
|
+ editable=False,
|
|
|
)
|
|
|
user = models.ForeignKey(
|
|
|
User, blank=True, null=True, on_delete=models.DO_NOTHING
|
|
|
@@ -610,9 +563,60 @@ class Scrobble(TimeStampedModel):
|
|
|
format="JPEG",
|
|
|
options={"quality": 75},
|
|
|
)
|
|
|
+
|
|
|
+ @cached_property
|
|
|
+ def media_obj(self):
|
|
|
+ """
|
|
|
+ Return the concrete media instance (Book, Video, Track, etc.).
|
|
|
+ Cached for the lifetime of this model instance.
|
|
|
+ """
|
|
|
+ if not self.item:
|
|
|
+ return None
|
|
|
+ return self.item.get_concrete_instance()
|
|
|
+
|
|
|
+ # Deprecated
|
|
|
+ video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ podcast_episode = models.ForeignKey(
|
|
|
+ PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ sport_event = models.ForeignKey(
|
|
|
+ SportEvent, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ video_game = models.ForeignKey(
|
|
|
+ VideoGame, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ board_game = models.ForeignKey(
|
|
|
+ BoardGame, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ geo_location = models.ForeignKey(
|
|
|
+ GeoLocation, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ life_event = models.ForeignKey(
|
|
|
+ LifeEvent, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
+ mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
|
|
|
+ brick_set = models.ForeignKey(
|
|
|
+ BrickSet, on_delete=models.DO_NOTHING, **BNULL
|
|
|
+ )
|
|
|
long_play_seconds = models.BigIntegerField(**BNULL)
|
|
|
long_play_complete = models.BooleanField(**BNULL)
|
|
|
|
|
|
+ class Meta:
|
|
|
+ ordering = ["-timestamp"]
|
|
|
+ indexes = [
|
|
|
+ models.Index(fields=["timestamp"]),
|
|
|
+ models.Index(fields=["media_type"]),
|
|
|
+ ]
|
|
|
+
|
|
|
@classmethod
|
|
|
def for_year(cls, user, year):
|
|
|
return cls.objects.filter(timestamp__year=year, user=user).order_by(
|
|
|
@@ -674,13 +678,16 @@ class Scrobble(TimeStampedModel):
|
|
|
)
|
|
|
|
|
|
@property
|
|
|
- def last_serial_scrobble(self) -> Optional["Scrobble"]:
|
|
|
+ def last_serial_scrobble(self) -> "Scrobble | None":
|
|
|
from scrobbles.models import Scrobble
|
|
|
|
|
|
- if self.logdata and self.logdata.serial_scrobble_id:
|
|
|
- return Scrobble.objects.filter(
|
|
|
- id=self.logdata.serial_scrobble_id
|
|
|
- ).first()
|
|
|
+ try:
|
|
|
+ if self.logdata and self.logdata.serial_scrobble_id:
|
|
|
+ return Scrobble.objects.filter(
|
|
|
+ id=self.logdata.serial_scrobble_id
|
|
|
+ ).first()
|
|
|
+ except AttributeError:
|
|
|
+ return
|
|
|
|
|
|
@property
|
|
|
def finish_url(self) -> str:
|
|
|
@@ -690,6 +697,9 @@ class Scrobble(TimeStampedModel):
|
|
|
if not self.uuid:
|
|
|
self.uuid = uuid4()
|
|
|
|
|
|
+ if self.item:
|
|
|
+ self.media_type = self.item.MEDIA_TYPE
|
|
|
+
|
|
|
if not self.timezone:
|
|
|
timezone = settings.TIME_ZONE
|
|
|
if self.user and self.user.profile:
|
|
|
@@ -699,10 +709,8 @@ class Scrobble(TimeStampedModel):
|
|
|
# Microseconds mess up Django's filtering, and we don't need be that specific
|
|
|
if self.timestamp:
|
|
|
self.timestamp = self.timestamp.replace(microsecond=0)
|
|
|
- if self.media_obj:
|
|
|
- self.media_type = self.MediaType(self.media_obj.__class__.__name__)
|
|
|
|
|
|
- return super(Scrobble, self).save(*args, **kwargs)
|
|
|
+ return super().save(*args, **kwargs)
|
|
|
|
|
|
def get_absolute_url(self):
|
|
|
if not self.uuid:
|
|
|
@@ -770,7 +778,7 @@ class Scrobble(TimeStampedModel):
|
|
|
redirect_url = self.media_obj.get_absolute_url()
|
|
|
|
|
|
if (
|
|
|
- self.media_type == self.MediaType.WEBPAGE
|
|
|
+ self.media_type == constants.MediaType.WEBPAGE
|
|
|
and user
|
|
|
and user.profile.redirect_to_webpage
|
|
|
):
|
|
|
@@ -778,7 +786,7 @@ class Scrobble(TimeStampedModel):
|
|
|
redirect_url = self.media_obj.url
|
|
|
|
|
|
if (
|
|
|
- self.media_type == self.MediaType.VIDEO
|
|
|
+ self.media_type == constants.MediaType.VIDEO
|
|
|
and self.media_obj.youtube_id
|
|
|
):
|
|
|
redirect_url = self.media_obj.youtube_link
|
|
|
@@ -1043,45 +1051,6 @@ class Scrobble(TimeStampedModel):
|
|
|
"-count",
|
|
|
)
|
|
|
|
|
|
- @property
|
|
|
- def media_obj(self):
|
|
|
- media_obj = None
|
|
|
- if self.video:
|
|
|
- media_obj = self.video
|
|
|
- if self.track:
|
|
|
- media_obj = self.track
|
|
|
- if self.podcast_episode:
|
|
|
- media_obj = self.podcast_episode
|
|
|
- if self.sport_event:
|
|
|
- media_obj = self.sport_event
|
|
|
- if self.book:
|
|
|
- media_obj = self.book
|
|
|
- if self.video_game:
|
|
|
- media_obj = self.video_game
|
|
|
- if self.board_game:
|
|
|
- media_obj = self.board_game
|
|
|
- if self.geo_location:
|
|
|
- media_obj = self.geo_location
|
|
|
- if self.web_page:
|
|
|
- media_obj = self.web_page
|
|
|
- if self.life_event:
|
|
|
- media_obj = self.life_event
|
|
|
- if self.mood:
|
|
|
- media_obj = self.mood
|
|
|
- if self.brick_set:
|
|
|
- media_obj = self.brick_set
|
|
|
- if self.trail:
|
|
|
- media_obj = self.trail
|
|
|
- if self.beer:
|
|
|
- media_obj = self.beer
|
|
|
- if self.puzzle:
|
|
|
- media_obj = self.puzzle
|
|
|
- if self.task:
|
|
|
- media_obj = self.task
|
|
|
- if self.food:
|
|
|
- media_obj = self.food
|
|
|
- return media_obj
|
|
|
-
|
|
|
def __str__(self):
|
|
|
return f"Scrobble of {self.media_obj} ({self.timestamp})"
|
|
|
|
|
|
@@ -1147,7 +1116,7 @@ class Scrobble(TimeStampedModel):
|
|
|
mtype = media.__class__.__name__
|
|
|
|
|
|
# GeoLocations are a special case scrobble
|
|
|
- if mtype == cls.MediaType.GEO_LOCATION:
|
|
|
+ if mtype == constants.MediaType.GEO_LOCATION:
|
|
|
logger.warning(
|
|
|
f"[create_or_update] geoloc requires create_or_update_location"
|
|
|
)
|
|
|
@@ -1210,7 +1179,7 @@ class Scrobble(TimeStampedModel):
|
|
|
"source": source,
|
|
|
},
|
|
|
)
|
|
|
- if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get("calories", None):
|
|
|
+ if mtype == constants.MediaType.FOOD and not scrobble_data.get("log", {}).get("calories", None):
|
|
|
if media.calories:
|
|
|
scrobble_data["log"] = FoodLogData(calories=media.calories)
|
|
|
|
|
|
@@ -1231,7 +1200,7 @@ class Scrobble(TimeStampedModel):
|
|
|
|
|
|
scrobble = (
|
|
|
cls.objects.filter(
|
|
|
- media_type=cls.MediaType.GEO_LOCATION,
|
|
|
+ media_type=constants.MediaType.GEO_LOCATION,
|
|
|
user_id=user_id,
|
|
|
timestamp__lte=scrobble_data.get("timestamp"),
|
|
|
)
|
|
|
@@ -1243,7 +1212,7 @@ class Scrobble(TimeStampedModel):
|
|
|
f"[scrobbling] fetching last location scrobble",
|
|
|
extra={
|
|
|
"scrobble_id": scrobble.id if scrobble else None,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"media_id": location.id,
|
|
|
"scrobble_data": scrobble_data,
|
|
|
},
|
|
|
@@ -1254,7 +1223,7 @@ class Scrobble(TimeStampedModel):
|
|
|
f"[scrobbling] finished - no existing location scrobbles found",
|
|
|
extra={
|
|
|
"media_id": location.id,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
},
|
|
|
)
|
|
|
return cls.create(scrobble_data)
|
|
|
@@ -1263,7 +1232,7 @@ class Scrobble(TimeStampedModel):
|
|
|
logger.info(
|
|
|
f"[scrobbling] finished - same location - not moved",
|
|
|
extra={
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"media_id": location.id,
|
|
|
"scrobble_id": scrobble.id,
|
|
|
"scrobble_media_id": scrobble.media_obj.id,
|
|
|
@@ -1277,7 +1246,7 @@ class Scrobble(TimeStampedModel):
|
|
|
extra={
|
|
|
"scrobble_id": scrobble.id,
|
|
|
"scrobble_media_id": scrobble.media_obj.id,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"media_id": location.id,
|
|
|
"has_moved": has_moved,
|
|
|
},
|
|
|
@@ -1288,7 +1257,7 @@ class Scrobble(TimeStampedModel):
|
|
|
extra={
|
|
|
"scrobble_id": scrobble.id,
|
|
|
"media_id": location.id,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"old_media__id": scrobble.media_obj.id,
|
|
|
},
|
|
|
)
|
|
|
@@ -1305,7 +1274,7 @@ class Scrobble(TimeStampedModel):
|
|
|
f"[scrobbling] finished - found existing named location",
|
|
|
extra={
|
|
|
"media_id": location.id,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"old_media_id": existing_location.id,
|
|
|
},
|
|
|
)
|
|
|
@@ -1319,7 +1288,7 @@ class Scrobble(TimeStampedModel):
|
|
|
"scrobble_id": scrobble.id,
|
|
|
"media_id": location.id,
|
|
|
"scrobble_data": scrobble_data,
|
|
|
- "media_type": cls.MediaType.GEO_LOCATION,
|
|
|
+ "media_type": constants.MediaType.GEO_LOCATION,
|
|
|
"source": scrobble_data.get("source"),
|
|
|
},
|
|
|
)
|