2041 lines
68 KiB
Python
2041 lines
68 KiB
Python
import calendar
|
|
import datetime
|
|
import json
|
|
import logging
|
|
from collections import defaultdict
|
|
from typing import Optional
|
|
from uuid import uuid4
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pendulum
|
|
import pytz
|
|
from beers.models import Beer
|
|
from birds.models import BirdingLocation
|
|
from discgolf.models import DiscGolfCourse
|
|
from boardgames.models import BoardGame
|
|
from books.koreader import process_koreader_sqlite_file
|
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
|
from bricksets.models import BrickSet
|
|
from charts.utils import build_charts
|
|
from dataclass_wizard.errors import ParseError
|
|
from django.conf import settings
|
|
from scrobbles.constants import Visibility
|
|
from scrobbles.sqids import encode_scrobble_share
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.files import File
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django_extensions.db.models import TimeStampedModel
|
|
from foods.models import Food, FoodLogData
|
|
from imagekit.models import ImageSpecField
|
|
from imagekit.processors import ResizeToFit
|
|
from lifeevents.models import LifeEvent
|
|
from locations.models import GeoLocation
|
|
from moods.models import Mood
|
|
from music.models import Artist, Track
|
|
from podcasts.models import PodcastEpisode
|
|
from profiles.utils import (
|
|
end_of_day,
|
|
end_of_month,
|
|
end_of_week,
|
|
fix_profile_historic_timezones,
|
|
start_of_day,
|
|
start_of_month,
|
|
start_of_week,
|
|
)
|
|
from puzzles.models import Puzzle
|
|
from scrobbles import dataclasses as logdata
|
|
from scrobbles.constants import (
|
|
AUTO_FINISH_MEDIA,
|
|
LONG_PLAY_MEDIA,
|
|
MEDIA_END_PADDING_SECONDS,
|
|
)
|
|
from scrobbles.importers.lastfm import LastFM
|
|
from scrobbles.notifications import (
|
|
LastFmImportNtfyNotification,
|
|
ScrobbleNtfyNotification,
|
|
)
|
|
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
|
|
from sports.models import SportEvent
|
|
from taggit.managers import TaggableManager
|
|
from tasks.models import Task
|
|
from trails.models import Trail
|
|
from videogames import retroarch
|
|
from videogames.models import VideoGame
|
|
from videos.models import Series, Video
|
|
from webpages.models import WebPage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
BNULL = {"blank": True, "null": True}
|
|
|
|
POINTS_FOR_MOVEMENT_HISTORY = int(getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3))
|
|
|
|
|
|
class BaseFileImportMixin(TimeStampedModel):
|
|
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
|
uuid = models.UUIDField(editable=False, default=uuid4)
|
|
processing_started = models.DateTimeField(**BNULL)
|
|
processed_finished = models.DateTimeField(**BNULL)
|
|
process_log = models.TextField(**BNULL)
|
|
process_count = models.IntegerField(**BNULL)
|
|
error_log = models.TextField(**BNULL)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def __str__(self):
|
|
return f"{self.import_type} import on {self.human_start}"
|
|
|
|
@property
|
|
def human_start(self):
|
|
start = "Unknown"
|
|
if self.processing_started:
|
|
start = self.processing_started.strftime("%B %d, %Y at %H:%M")
|
|
return start
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "Unknown Import Source"
|
|
|
|
def process(self, force=False):
|
|
logger.warning("Process not implemented")
|
|
|
|
def undo(self, dryrun=False):
|
|
"""Accepts the log from a scrobble import and removes the scrobbles"""
|
|
from scrobbles.models import Scrobble
|
|
|
|
if not self.process_log:
|
|
logger.warning("No lines in process log found to undo")
|
|
return
|
|
|
|
for line in self.process_log.split("\n"):
|
|
scrobble_id = line.split("\t")[0]
|
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
|
if not scrobble:
|
|
logger.warning(f"Could not find scrobble {scrobble_id} to undo")
|
|
continue
|
|
logger.info(f"Removing scrobble {scrobble_id}")
|
|
if not dryrun:
|
|
scrobble.delete()
|
|
self.processed_finished = None
|
|
self.processing_started = None
|
|
self.process_count = None
|
|
self.process_log = ""
|
|
self.save(
|
|
update_fields=[
|
|
"processed_finished",
|
|
"processing_started",
|
|
"process_log",
|
|
"process_count",
|
|
]
|
|
)
|
|
|
|
def scrobbles(self) -> models.QuerySet:
|
|
scrobble_ids = []
|
|
if self.process_log:
|
|
for line in self.process_log.split("\n"):
|
|
sid = line.split("\t")[0]
|
|
if sid:
|
|
scrobble_ids.append(sid)
|
|
return Scrobble.objects.filter(id__in=scrobble_ids)
|
|
|
|
def mark_started(self):
|
|
self.processing_started = timezone.now()
|
|
self.save(update_fields=["processing_started"])
|
|
|
|
def mark_finished(self):
|
|
self.processed_finished = timezone.now()
|
|
self.save(update_fields=["processed_finished"])
|
|
|
|
def record_log(self, scrobbles):
|
|
self.process_log = ""
|
|
if not scrobbles:
|
|
self.process_count = 0
|
|
self.save(update_fields=["process_log", "process_count"])
|
|
return
|
|
|
|
for count, scrobble in enumerate(scrobbles):
|
|
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
|
log_line = f"{scrobble_str}"
|
|
if count > 0:
|
|
log_line = "\n" + log_line
|
|
self.process_log += log_line
|
|
self.process_count = len(scrobbles)
|
|
self.save(update_fields=["process_log", "process_count"])
|
|
|
|
def record_error(self, error_message):
|
|
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
|
if self.error_log:
|
|
self.error_log += "\n" + log_line
|
|
else:
|
|
self.error_log = log_line
|
|
self.save(update_fields=["error_log"])
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
class KoReaderImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "KOReader Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "KOReader"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:koreader-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"koreader-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self) -> str:
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.sqlite_file.url
|
|
else:
|
|
path = self.sqlite_file.path
|
|
return path
|
|
|
|
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
|
|
webdav_etag = models.CharField(max_length=255, **BNULL)
|
|
|
|
def save_sqlite_file_to_self(self, file_path):
|
|
with open(file_path, "rb") as f:
|
|
self.sqlite_file.save(
|
|
f"{self.user_id}-koreader-statistics.sqlite",
|
|
File(f),
|
|
save=True,
|
|
)
|
|
|
|
def file_md5_hash(self) -> str:
|
|
if self.sqlite_file:
|
|
return get_file_md5_hash(self.sqlite_file.path)
|
|
return ""
|
|
|
|
def process(self, force=False):
|
|
|
|
if self.user.id == 1:
|
|
fix_profile_historic_timezones(self.user.profile)
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = process_koreader_sqlite_file(
|
|
self.upload_file_path, self.user.id
|
|
)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class AudioScrobblerTSVImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "AudioScrobbler TSV Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "AudiosScrobbler"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:tsv-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"audioscrobbler-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.tsv_file.url
|
|
else:
|
|
path = self.tsv_file.path
|
|
return path
|
|
|
|
tsv_file = models.FileField(upload_to=get_path, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
from scrobbles.importers.tsv import import_audioscrobbler_tsv_file
|
|
|
|
if self.user.id == 1:
|
|
fix_profile_historic_timezones(self.user.profile)
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = import_audioscrobbler_tsv_file(
|
|
self.upload_file_path, self.user.id
|
|
)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class ScaleCSVImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "Scale CSV Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "Scale"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:scale-csv-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"scale-csv-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.csv_file.url
|
|
else:
|
|
path = self.csv_file.path
|
|
return path
|
|
|
|
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
file_hash = models.CharField(max_length=32, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
from scrobbles.importers.scale import import_scale_csv
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class TrailGPXImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "Trail GPX Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "Trail GPX"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:trail-gpx-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"trail-gpx-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.gpx_file.url
|
|
else:
|
|
path = self.gpx_file.path
|
|
return path
|
|
|
|
gpx_file = models.FileField(upload_to=get_path, **BNULL)
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
from scrobbles.importers.trail_gpx import import_trail_gpx
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = import_trail_gpx(
|
|
self.upload_file_path, self.user.id, self.original_filename
|
|
)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class LastFmImport(BaseFileImportMixin):
|
|
import_in_progress = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
verbose_name = "Last.FM Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "LastFM"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def process(self, import_all=False, time_from=None, time_to=None):
|
|
"""Import scrobbles found on LastFM"""
|
|
|
|
if self.user.id == 1:
|
|
fix_profile_historic_timezones(self.user.profile)
|
|
|
|
if self.processed_finished:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
lastfm = LastFM(self.user)
|
|
last_processed = None
|
|
|
|
if time_from is not None or time_to is not None:
|
|
last_processed = time_from
|
|
else:
|
|
last_import = None
|
|
if not import_all:
|
|
try:
|
|
last_import = LastFmImport.objects.exclude(id=self.id).last()
|
|
except:
|
|
pass
|
|
|
|
if not import_all and not last_import:
|
|
logger.warn(
|
|
"No previous import, to import all Last.fm scrobbles, "
|
|
"pass import_all=True"
|
|
)
|
|
return
|
|
|
|
if last_import:
|
|
last_processed = last_import.processed_finished
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
|
self.record_log(scrobbles)
|
|
if scrobbles:
|
|
LastFmImportNtfyNotification(self, len(scrobbles)).send()
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class RetroarchImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "Retroarch Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "Retroarch"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
lrtl_file = models.FileField(upload_to="scrobbles/lrtl_file/", **BNULL)
|
|
files_hash = models.CharField(max_length=64, **BNULL)
|
|
|
|
def process(self, import_all=False, force=False):
|
|
"""Import scrobbles found on Retroarch"""
|
|
if self.user.id == 1:
|
|
fix_profile_historic_timezones(self.user.profile)
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
if force:
|
|
logger.info(f"You told me to force import from Retroarch")
|
|
|
|
self.mark_started()
|
|
scrobbles = None
|
|
try:
|
|
if self.lrtl_file:
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
zip_path = os.path.join(tmpdir, "archive.zip")
|
|
with open(zip_path, "wb") as f:
|
|
f.write(self.lrtl_file.read())
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
zf.extractall(tmpdir)
|
|
os.unlink(zip_path)
|
|
scrobbles = retroarch.import_retroarch_lrtl_files(
|
|
tmpdir + "/",
|
|
self.user.id,
|
|
)
|
|
finally:
|
|
import shutil
|
|
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
else:
|
|
if not self.user.profile.retroarch_path:
|
|
logger.info(
|
|
"Trying to import Retroarch logs, but user has no retroarch_path configured"
|
|
)
|
|
self.mark_finished()
|
|
return
|
|
|
|
scrobbles = retroarch.import_retroarch_lrtl_files(
|
|
self.user.profile.retroarch_path,
|
|
self.user.id,
|
|
)
|
|
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class BGStatsImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "BG Stats Import"
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "BG Stats"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:bgstats-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"bgstats-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.bgsplay_file.url
|
|
else:
|
|
path = self.bgsplay_file.path
|
|
return path
|
|
|
|
bgsplay_file = models.FileField(upload_to=get_path, **BNULL)
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
"""Import scrobbles from a single BG Stats bgsplay file"""
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
import json
|
|
|
|
from scrobbles.scrobblers import email_scrobble_board_game
|
|
|
|
with open(self.upload_file_path, "r", encoding="utf-8") as f:
|
|
parsed_json = json.load(f)
|
|
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
|
|
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class EBirdCSVImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "eBird CSV Import"
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.DO_NOTHING,
|
|
**BNULL,
|
|
related_name="scrobbles_ebirdcsvimport_set",
|
|
)
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "eBird"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"birding-csv-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.csv_file.url
|
|
else:
|
|
path = self.csv_file.path
|
|
return path
|
|
|
|
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
from birds.importer import import_birding_csv
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = import_birding_csv(
|
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
|
)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
class UDiscCSVImport(BaseFileImportMixin):
|
|
class Meta:
|
|
verbose_name = "uDisc CSV Import"
|
|
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.DO_NOTHING,
|
|
**BNULL,
|
|
related_name="scrobbles_udisccsvimport_set",
|
|
)
|
|
|
|
@property
|
|
def import_type(self) -> str:
|
|
return "uDisc"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})
|
|
|
|
def get_path(instance, filename):
|
|
extension = filename.split(".")[-1]
|
|
uuid = instance.uuid
|
|
return f"udisc-csv-uploads/{uuid}.{extension}"
|
|
|
|
@property
|
|
def upload_file_path(self):
|
|
if getattr(settings, "USE_S3_STORAGE"):
|
|
path = self.csv_file.url
|
|
else:
|
|
path = self.csv_file.path
|
|
return path
|
|
|
|
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
|
original_filename = models.CharField(max_length=255, **BNULL)
|
|
|
|
def process(self, force=False):
|
|
from discgolf.utils import import_udisc_csv
|
|
|
|
if self.processed_finished and not force:
|
|
logger.info(f"{self} already processed on {self.processed_finished}")
|
|
return
|
|
|
|
self.mark_started()
|
|
try:
|
|
scrobbles = import_udisc_csv(
|
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
|
)
|
|
self.record_log(scrobbles)
|
|
except Exception as e:
|
|
self.record_error(f"Import failed: {e}")
|
|
logger.exception(f"Import failed for {self}")
|
|
finally:
|
|
self.mark_finished()
|
|
|
|
|
|
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
|
"Video": ("video",),
|
|
"Track": ("track", "track__artist_fk"),
|
|
"PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
|
|
"SportEvent": ("sport_event",),
|
|
"Book": ("book",),
|
|
"Paper": ("paper",),
|
|
"VideoGame": ("video_game",),
|
|
"BoardGame": ("board_game",),
|
|
"GeoLocation": ("geo_location",),
|
|
"Trail": ("trail",),
|
|
"Beer": ("beer",),
|
|
"Puzzle": ("puzzle",),
|
|
"Food": ("food",),
|
|
"Task": ("task",),
|
|
"WebPage": ("web_page",),
|
|
"LifeEvent": ("life_event",),
|
|
"Mood": ("mood",),
|
|
"BrickSet": ("brick_set",),
|
|
"Channel": ("channel",),
|
|
"BirdingLocation": ("birding_location",),
|
|
"DiscGolfCourse": ("disc_golf_course",),
|
|
}
|
|
|
|
|
|
class ScrobbleQuerySet(models.QuerySet):
|
|
def with_related(self):
|
|
return self.select_related("user").prefetch_related(
|
|
"video",
|
|
"track",
|
|
"track__artist_fk",
|
|
"podcast_episode",
|
|
"podcast_episode__podcast",
|
|
"sport_event",
|
|
"book",
|
|
"paper",
|
|
"video_game",
|
|
"board_game",
|
|
"geo_location",
|
|
"beer",
|
|
"puzzle",
|
|
"food",
|
|
"trail",
|
|
"task",
|
|
"web_page",
|
|
"life_event",
|
|
"mood",
|
|
"brick_set",
|
|
"birding_location",
|
|
"disc_golf_course",
|
|
)
|
|
|
|
def with_related_for_types(self, media_types: list[str]):
|
|
prefetches = []
|
|
for t in media_types:
|
|
if t in TYPE_FK_PREFETCHES:
|
|
prefetches.extend(TYPE_FK_PREFETCHES[t])
|
|
return self.select_related("user").prefetch_related(*prefetches)
|
|
|
|
|
|
class ShareViewLog(TimeStampedModel):
|
|
scrobble = models.ForeignKey(
|
|
"Scrobble", on_delete=models.CASCADE, related_name="share_views"
|
|
)
|
|
ip_address = models.GenericIPAddressField(**BNULL)
|
|
user_agent = models.TextField(**BNULL)
|
|
referrer = models.URLField(max_length=2048, **BNULL)
|
|
|
|
def __str__(self):
|
|
return f"View of {self.scrobble} at {self.created}"
|
|
|
|
|
|
class Scrobble(TimeStampedModel):
|
|
"""A scrobble tracks played media items by a user."""
|
|
|
|
objects = ScrobbleQuerySet.as_manager()
|
|
|
|
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"
|
|
CHANNEL = "Channel", "Channel"
|
|
BIRDING_LOCATION = "BirdingLocation", "Birding location"
|
|
DISC_GOLF = "DiscGolfCourse", "Disc golf"
|
|
|
|
@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)
|
|
channel = models.ForeignKey("videos.Channel", 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)
|
|
birding_location = models.ForeignKey(
|
|
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
|
|
)
|
|
disc_golf_course = models.ForeignKey(
|
|
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
|
|
)
|
|
media_type = models.CharField(
|
|
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
|
|
)
|
|
visibility = models.CharField(
|
|
max_length=10,
|
|
choices=Visibility.choices,
|
|
default=Visibility.PRIVATE,
|
|
db_index=True,
|
|
)
|
|
share_token_version = models.PositiveIntegerField(default=0)
|
|
share_view_count = models.PositiveIntegerField(default=0)
|
|
user = models.ForeignKey(User, blank=True, null=True, on_delete=models.DO_NOTHING)
|
|
|
|
# Time keeping
|
|
timestamp = models.DateTimeField(**BNULL)
|
|
stop_timestamp = models.DateTimeField(**BNULL)
|
|
playback_position_seconds = models.IntegerField(**BNULL)
|
|
|
|
# Status indicators
|
|
is_paused = models.BooleanField(default=False)
|
|
played_to_completion = models.BooleanField(default=False)
|
|
in_progress = models.BooleanField(default=True)
|
|
|
|
# Metadata
|
|
source = models.CharField(max_length=255, **BNULL)
|
|
source_id = models.CharField(max_length=255, **BNULL)
|
|
log = models.JSONField(
|
|
**BNULL,
|
|
default=dict,
|
|
encoder=logdata.ScrobbleLogDataEncoder,
|
|
decoder=logdata.ScrobbleLogDataDecoder,
|
|
)
|
|
timezone = models.CharField(max_length=50, **BNULL)
|
|
tags = TaggableManager(blank=True, verbose_name="Tags")
|
|
|
|
# Fields for keeping track of video game data
|
|
videogame_save_data = models.FileField(
|
|
upload_to="scrobbles/videogame_save_data/", **BNULL
|
|
)
|
|
gpx_file = models.FileField(upload_to="scrobbles/gpx_file/", **BNULL)
|
|
screenshot = models.ImageField(upload_to="scrobbles/videogame_screenshot/", **BNULL)
|
|
screenshot_small = ImageSpecField(
|
|
source="screenshot",
|
|
processors=[ResizeToFit(100, 100)],
|
|
format="JPEG",
|
|
options={"quality": 60},
|
|
)
|
|
screenshot_medium = ImageSpecField(
|
|
source="screenshot",
|
|
processors=[ResizeToFit(300, 300)],
|
|
format="JPEG",
|
|
options={"quality": 75},
|
|
)
|
|
screenshot_large = ImageSpecField(
|
|
source="screenshot",
|
|
processors=[ResizeToFit(800, 800)],
|
|
format="JPEG",
|
|
options={"quality": 85},
|
|
)
|
|
long_play_seconds = models.BigIntegerField(**BNULL)
|
|
long_play_complete = models.BooleanField(**BNULL)
|
|
long_play_last_scrobble = models.ForeignKey(
|
|
"self",
|
|
**BNULL,
|
|
on_delete=models.SET_NULL,
|
|
related_name="next_long_play_scrobbles",
|
|
)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=["timestamp"]),
|
|
models.Index(fields=["media_type"]),
|
|
models.Index(fields=["source_id"]),
|
|
models.Index(
|
|
fields=[
|
|
"user",
|
|
"in_progress",
|
|
"played_to_completion",
|
|
"is_paused",
|
|
]
|
|
),
|
|
models.Index(fields=["user", "-timestamp"]),
|
|
]
|
|
|
|
@classmethod
|
|
def for_year(cls, user, year):
|
|
return cls.objects.filter(timestamp__year=year, user=user).order_by(
|
|
"-timestamp"
|
|
)
|
|
|
|
@classmethod
|
|
def for_month(cls, user, year, month):
|
|
return cls.objects.filter(
|
|
timestamp__year=year, timestamp__month=month, user=user
|
|
).order_by("-timestamp")
|
|
|
|
@classmethod
|
|
def for_day(cls, user, year, month, day):
|
|
return cls.objects.filter(
|
|
timestamp__year=year,
|
|
timestamp__month=month,
|
|
timestamp__day=day,
|
|
user=user,
|
|
).order_by("-timestamp")
|
|
|
|
@classmethod
|
|
def for_week(cls, user, year, week):
|
|
return cls.objects.filter(
|
|
timestamp__year=year, timestamp__week=week, user=user
|
|
).order_by("-timestamp")
|
|
|
|
@classmethod
|
|
def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
|
|
scrobbles_by_type = defaultdict(list)
|
|
|
|
if hasattr(scrobble_qs, "with_related"):
|
|
media_types_present = list(
|
|
scrobble_qs.values_list("media_type", flat=True).distinct()
|
|
)
|
|
scrobbles = scrobble_qs.with_related_for_types(media_types_present)
|
|
else:
|
|
scrobbles = scrobble_qs
|
|
|
|
for scrobble in scrobbles:
|
|
scrobbles_by_type[scrobble.media_type].append(scrobble)
|
|
if not scrobbles_by_type.get(scrobble.media_type + "_count"):
|
|
scrobbles_by_type[scrobble.media_type + "_count"] = 0
|
|
scrobbles_by_type[scrobble.media_type + "_count"] += 1
|
|
if not scrobbles_by_type.get(scrobble.media_type + "_time"):
|
|
scrobbles_by_type[scrobble.media_type + "_time"] = 0
|
|
scrobbles_by_type[scrobble.media_type + "_time"] += int(
|
|
(scrobble.elapsed_time)
|
|
)
|
|
|
|
# Remove any locations without titles
|
|
if "GeoLocation" in scrobbles_by_type.keys():
|
|
for loc_scrobble in list(scrobbles_by_type["GeoLocation"]):
|
|
if not loc_scrobble.media_obj.title:
|
|
scrobbles_by_type["GeoLocation"].remove(loc_scrobble)
|
|
scrobbles_by_type["GeoLocation_count"] -= 1
|
|
|
|
return scrobbles_by_type
|
|
|
|
@classmethod
|
|
def in_progress_for_user(cls, user_id: int) -> models.QuerySet:
|
|
return cls.objects.filter(
|
|
user=user_id,
|
|
in_progress=True,
|
|
played_to_completion=False,
|
|
is_paused=False,
|
|
)
|
|
|
|
@property
|
|
def last_serial_scrobble(self) -> Optional["Scrobble"]:
|
|
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()
|
|
|
|
@property
|
|
def finish_url(self) -> str:
|
|
return reverse("scrobbles:finish", kwargs={"pk": self.pk})
|
|
|
|
def save(self, *args, **kwargs):
|
|
class_name = self.media_obj.__class__.__name__
|
|
|
|
if not self.uuid:
|
|
self.uuid = uuid4()
|
|
|
|
if not self.timezone:
|
|
timezone = settings.TIME_ZONE
|
|
if self.user and self.user.profile:
|
|
timezone = self.user.profile.timezone
|
|
self.timezone = timezone
|
|
|
|
# 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__)
|
|
|
|
if (self.timestamp and self.stop_timestamp) and (
|
|
not self.playback_position_seconds or self.playback_position_seconds <= 0
|
|
):
|
|
self.playback_position_seconds = (
|
|
self.stop_timestamp - self.timestamp
|
|
).seconds
|
|
|
|
if class_name == "Book":
|
|
self.calculate_reading_stats(commit=False)
|
|
|
|
if (
|
|
self.log
|
|
and isinstance(self.log, dict)
|
|
and self.log.get("page_start") is not None
|
|
and self.log.get("page_end") is not None
|
|
and "pages_read" not in self.log
|
|
):
|
|
self.log["pages_read"] = self.log["page_end"] - self.log["page_start"] + 1
|
|
|
|
return super(Scrobble, self).save(*args, **kwargs)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("scrobbles:detail", kwargs={"pk": self.pk})
|
|
|
|
def get_share_url(self):
|
|
if self.visibility == Visibility.PRIVATE:
|
|
return None
|
|
sqid = encode_scrobble_share(self.id, self.share_token_version)
|
|
return reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
|
|
|
|
def regenerate_share_token(self):
|
|
self.share_token_version += 1
|
|
self.save(update_fields=["share_token_version"])
|
|
|
|
@property
|
|
def logdata(self) -> Optional[logdata.BaseLogData]:
|
|
if self.media_obj:
|
|
logdata_cls = self.media_obj.logdata_cls
|
|
else:
|
|
logdata_cls = logdata.BaseLogData
|
|
|
|
log_dict = self.log
|
|
if isinstance(log_dict, logdata.BaseLogData):
|
|
log_dict = log_dict.asdict
|
|
if isinstance(self.log, str):
|
|
# There's nothing stopping django from saving a string in a JSONField :(
|
|
logger.warning(
|
|
"[scrobbles] Received string in JSON data in log",
|
|
extra={"log": self.log},
|
|
)
|
|
log_dict = json.loads(self.log)
|
|
|
|
if not log_dict:
|
|
log_dict = {}
|
|
|
|
# Use LogData's from_log_dict to handle any custom nesting/structure
|
|
logdata_kwargs = logdata_cls.from_log_dict(log_dict)
|
|
|
|
try:
|
|
return logdata_cls(**logdata_kwargs)
|
|
except ParseError as e:
|
|
logger.warning(
|
|
"Could not parse log data",
|
|
extra={
|
|
"log_dict": log_dict,
|
|
"scrobble_id": self.id,
|
|
"error": e,
|
|
},
|
|
)
|
|
return logdata_cls()
|
|
except TypeError as e:
|
|
return logdata_cls()
|
|
|
|
@property
|
|
def notes(self):
|
|
if self.logdata:
|
|
return self.logdata.notes or []
|
|
return []
|
|
|
|
def redirect_url(self, user_id) -> str:
|
|
user = User.objects.filter(id=user_id).first()
|
|
redirect_url = self.media_obj.get_absolute_url()
|
|
|
|
if (
|
|
self.media_type == self.MediaType.WEBPAGE
|
|
and user
|
|
and user.profile.redirect_to_webpage
|
|
):
|
|
logger.info(f"Redirecting to {self.media_obj.url}")
|
|
redirect_url = self.media_obj.url
|
|
|
|
if self.media_type == self.MediaType.VIDEO and self.media_obj.youtube_id:
|
|
redirect_url = self.media_obj.youtube_link
|
|
|
|
return redirect_url
|
|
|
|
@property
|
|
def tzinfo(self):
|
|
return ZoneInfo(self.timezone)
|
|
|
|
@property
|
|
def local_timestamp(self):
|
|
return timezone.localtime(self.timestamp, timezone=self.tzinfo)
|
|
|
|
@property
|
|
def local_stop_timestamp(self):
|
|
if self.stop_timestamp:
|
|
return timezone.localtime(self.stop_timestamp, timezone=self.tzinfo)
|
|
|
|
@property
|
|
def scrobble_media_key(self) -> str:
|
|
return media_class_to_foreign_key(self.media_type) + "_id"
|
|
|
|
@property
|
|
def status(self) -> str:
|
|
if self.is_paused:
|
|
return "paused"
|
|
if self.played_to_completion:
|
|
return "finished"
|
|
if self.in_progress:
|
|
return "in-progress"
|
|
return "zombie"
|
|
|
|
@property
|
|
def is_stale(self) -> bool:
|
|
"""Mark scrobble as stale if it's been more than an hour since it was updated
|
|
|
|
Effectively, this allows 'resuming' a video scrobble within an hour of starting it.
|
|
"""
|
|
is_stale = False
|
|
now = timezone.now()
|
|
seconds_since_last_update = (now - self.modified).total_seconds()
|
|
if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
|
|
is_stale = True
|
|
return is_stale
|
|
|
|
@property
|
|
def previous(self) -> "Scrobble":
|
|
return (
|
|
self.media_obj.scrobble_set.order_by("-timestamp")
|
|
.filter(timestamp__lt=self.timestamp)
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def next(self) -> "Scrobble":
|
|
return (
|
|
self.media_obj.scrobble_set.order_by("timestamp")
|
|
.filter(timestamp__gt=self.timestamp)
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def previous_by_media(self) -> "Scrobble":
|
|
return (
|
|
Scrobble.objects.filter(
|
|
media_type=self.media_type,
|
|
user=self.user,
|
|
timestamp__lt=self.timestamp,
|
|
)
|
|
.order_by("-timestamp")
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def next_by_media(self) -> "Scrobble":
|
|
return (
|
|
Scrobble.objects.filter(
|
|
media_type=self.media_type,
|
|
user=self.user,
|
|
timestamp__gt=self.timestamp,
|
|
)
|
|
.order_by("-timestamp")
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def previous_by_user(self) -> "Scrobble":
|
|
return (
|
|
Scrobble.objects.order_by("-timestamp")
|
|
.filter(timestamp__lt=self.timestamp)
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def next_by_user(self) -> "Scrobble":
|
|
return (
|
|
Scrobble.objects.order_by("-timestamp")
|
|
.filter(timestamp__gt=self.timestamp)
|
|
.first()
|
|
)
|
|
|
|
@property
|
|
def session_pages_read(self) -> Optional[int]:
|
|
pages_read = 0
|
|
if self.log and isinstance(self.log, dict):
|
|
pages_read = self.log.get("pages_read", 0)
|
|
return pages_read
|
|
|
|
@property
|
|
def is_long_play(self) -> bool:
|
|
return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
|
|
|
|
@property
|
|
def elapsed_time(self) -> int | None:
|
|
if self.played_to_completion:
|
|
if self.playback_position_seconds:
|
|
return self.playback_position_seconds
|
|
if self.media_obj and self.media_obj.run_time_seconds:
|
|
return self.media_obj.run_time_seconds
|
|
|
|
return (timezone.now() - self.timestamp).seconds
|
|
|
|
@property
|
|
def percent_played(self) -> int:
|
|
if not self.media_obj:
|
|
return 0
|
|
|
|
if self.media_obj and not self.media_obj.run_time_seconds:
|
|
return 100
|
|
|
|
if not self.playback_position_seconds and self.played_to_completion:
|
|
return 100
|
|
|
|
playback_seconds = self.playback_position_seconds
|
|
if not playback_seconds:
|
|
playback_seconds = self.elapsed_time
|
|
|
|
run_time_secs = self.media_obj.run_time_seconds
|
|
percent = int((playback_seconds / run_time_secs) * 100)
|
|
|
|
if self.is_long_play:
|
|
long_play_secs = 0
|
|
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:
|
|
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
|
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
|
|
|
return percent
|
|
|
|
@property
|
|
def probably_still_in_progress(self) -> bool:
|
|
"""Add our start time to our media run time to get when we expect to
|
|
|
|
Audio tracks should be given a second or two of grace, videos should
|
|
be given closer to 30 minutes, because the odds of watching it back to
|
|
back are very slim.
|
|
|
|
"""
|
|
is_in_progress = False
|
|
padding_seconds = MEDIA_END_PADDING_SECONDS.get(self.media_type)
|
|
if not padding_seconds:
|
|
return is_in_progress
|
|
|
|
if not self.media_obj:
|
|
logger.info(
|
|
"[scrobbling] scrobble has no media obj",
|
|
extra={
|
|
"media_id": self.media_obj,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"probably_still_in_progress": is_in_progress,
|
|
},
|
|
)
|
|
return is_in_progress
|
|
|
|
if not self.media_obj.run_time_seconds:
|
|
logger.info(
|
|
"[scrobbling] media has no run time seconds, cannot calculate end",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"probably_still_in_progress": is_in_progress,
|
|
},
|
|
)
|
|
return is_in_progress
|
|
|
|
expected_end = self.timestamp + datetime.timedelta(
|
|
seconds=self.media_obj.run_time_seconds
|
|
)
|
|
expected_end_padded = expected_end + datetime.timedelta(seconds=padding_seconds)
|
|
# Take our start time, add our media length and an extra 30 min (1800s) is it still in the future? keep going
|
|
is_in_progress = expected_end_padded > pendulum.now()
|
|
logger.info(
|
|
"[scrobbling] checking if we're probably still playing",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"probably_still_in_progress": is_in_progress,
|
|
},
|
|
)
|
|
return is_in_progress
|
|
|
|
@property
|
|
def can_be_updated(self) -> bool:
|
|
if (
|
|
self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
|
|
and self.source != "readcomicsonline.ru"
|
|
):
|
|
logger.info(
|
|
"[scrobbling] cannot be updated, long play media",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
},
|
|
)
|
|
return False
|
|
|
|
if self.percent_played >= 100 and not self.probably_still_in_progress:
|
|
logger.info(
|
|
"[scrobbling] cannot be updated, existing scrobble is 100% played",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
},
|
|
)
|
|
return False
|
|
|
|
if self.is_stale:
|
|
logger.info(
|
|
"[scrobbling] cannot be udpated, stale",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
},
|
|
)
|
|
return False
|
|
logger.info(
|
|
"[scrobbling] can be updated",
|
|
extra={
|
|
"media_id": self.media_obj.id,
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
},
|
|
)
|
|
return True
|
|
|
|
@classmethod
|
|
def by_date(cls, media_type: str = "Track"):
|
|
cls.objects.filter(media_type=media_type).values("timestamp__date").annotate(
|
|
count=models.Count("id")
|
|
).values("timestamp__date", "count").order_by(
|
|
"-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
|
|
if self.channel:
|
|
media_obj = self.channel
|
|
if self.birding_location:
|
|
media_obj = self.birding_location
|
|
if self.disc_golf_course:
|
|
media_obj = self.disc_golf_course
|
|
return media_obj
|
|
|
|
def __str__(self):
|
|
return f"Scrobble of {self.media_obj} ({self.timestamp})"
|
|
|
|
def calc_reading_duration(self) -> int:
|
|
duration = 0
|
|
if self.logdata.page_data:
|
|
for k, v in self.logdata.page_data.items():
|
|
duration += v.get("duration")
|
|
return duration
|
|
|
|
def calc_pages_read(self) -> int:
|
|
pages_read = 0
|
|
if self.logdata.page_data:
|
|
pages = [int(k) for k in self.logdata.page_data.keys()]
|
|
pages.sort()
|
|
if len(pages) == 1:
|
|
pages_read = 1
|
|
elif len(pages) >= 2:
|
|
pages_read += pages[-1] - pages[0]
|
|
else:
|
|
pages_read = pages[-1] - pages[0]
|
|
return pages_read
|
|
|
|
@property
|
|
def last_page_read(self) -> int:
|
|
last_page = 0
|
|
if self.logdata.page_data:
|
|
pages = [int(k) for k in self.logdata.page_data.keys()]
|
|
pages.sort()
|
|
last_page = pages[-1]
|
|
return last_page
|
|
|
|
@property
|
|
def get_media_source_url(self) -> str:
|
|
url = ""
|
|
if self.media_type == "Website":
|
|
url = self.media_obj.url
|
|
if self.media_type == "Task":
|
|
url = self.media_obj.source_url_for_user(self.user)
|
|
return url
|
|
|
|
@classmethod
|
|
def create_or_update(
|
|
cls, media, user_id: int, scrobble_data: dict, **kwargs
|
|
) -> "Scrobble":
|
|
key = media_class_to_foreign_key(media.__class__.__name__)
|
|
media_query = models.Q(**{key: media})
|
|
scrobble_data[key + "_id"] = media.id
|
|
skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
|
|
read_log_page = kwargs.get("read_log_page", None)
|
|
|
|
source_id = scrobble_data.get("source_id")
|
|
|
|
# If source_id is provided (e.g., MediaSourceId from Jellyfin), check for existing scrobble first
|
|
# This prevents duplicates when webhooks arrive faster than scrobble creation
|
|
# if source_id:
|
|
# existing_by_source_id = cls.objects.filter(
|
|
# media_query,
|
|
# user_id=user_id,
|
|
# source_id=source_id,
|
|
# ).first()
|
|
# if existing_by_source_id:
|
|
# logger.info(
|
|
# "[create_or_update] found existing scrobble by source_id, updating",
|
|
# extra={
|
|
# "scrobble_id": existing_by_source_id.id,
|
|
# "source_id": source_id,
|
|
# },
|
|
# )
|
|
# scrobble_data["playback_status"] = scrobble_data.pop(
|
|
# "status", None
|
|
# )
|
|
# return existing_by_source_id.update(scrobble_data)
|
|
|
|
# Find our last scrobble of this media item (track, video, etc)
|
|
scrobble = (
|
|
cls.objects.filter(
|
|
media_query,
|
|
user_id=user_id,
|
|
)
|
|
.order_by("-timestamp")
|
|
.first()
|
|
)
|
|
source = scrobble_data.get("source", "Vrobbler")
|
|
mtype = media.__class__.__name__
|
|
|
|
# GeoLocations are a special case scrobble
|
|
if mtype == cls.MediaType.GEO_LOCATION:
|
|
logger.warning(
|
|
f"[create_or_update] geoloc requires create_or_update_location"
|
|
)
|
|
scrobble = cls.create_or_update_location(media, scrobble_data, user_id)
|
|
return scrobble
|
|
|
|
if not skip_in_progress_check or read_log_page:
|
|
logger.info(
|
|
f"[create_or_update] check for existing scrobble to update ",
|
|
extra={
|
|
"scrobble_id": scrobble.id if scrobble else None,
|
|
"media_type": mtype,
|
|
"media_id": media.id,
|
|
"scrobble_data": scrobble_data,
|
|
},
|
|
)
|
|
scrobble_data["playback_status"] = scrobble_data.pop("status", None)
|
|
# If it's marked as stopped, send it through our update mechanism, which will complete it
|
|
if scrobble and (
|
|
scrobble.can_be_updated
|
|
or (read_log_page and scrobble.can_be_updated)
|
|
or scrobble_data["playback_status"] == "stopped"
|
|
):
|
|
if read_log_page:
|
|
page_data = scrobble.log.get("page_data", {})
|
|
if page_data:
|
|
for page_num, page in page_data.items():
|
|
if not page.get("end_ts", None):
|
|
page["end_ts"] = int(timezone.now().timestamp())
|
|
page["duration"] = page["end_ts"] - page.get("start_ts")
|
|
|
|
page_data[read_log_page] = BookPageLogData(
|
|
page_number=read_log_page,
|
|
start_ts=int(timezone.now().timestamp()),
|
|
)
|
|
scrobble.log["page_data"] = page_data
|
|
scrobble.save(update_fields=["log"])
|
|
elif "log" in scrobble_data.keys() and scrobble.log:
|
|
scrobble_data["log"] = scrobble.log | scrobble_data["log"]
|
|
return scrobble.update(scrobble_data)
|
|
|
|
# Discard status before creating
|
|
scrobble_data.pop("playback_status")
|
|
|
|
if read_log_page:
|
|
scrobble_data["log"] = BookLogData(
|
|
page_data={
|
|
read_log_page: BookPageLogData(
|
|
page_number=read_log_page,
|
|
start_ts=int(timezone.now().timestamp()),
|
|
)
|
|
}
|
|
).asdict
|
|
|
|
logger.info(
|
|
f"[scrobbling] creating new scrobble",
|
|
extra={
|
|
"scrobble_id": scrobble.id if scrobble else None,
|
|
"media_type": mtype,
|
|
"media_id": media.id,
|
|
"source": source,
|
|
},
|
|
)
|
|
if mtype == cls.MediaType.FOOD and not scrobble_data.get("log", {}).get(
|
|
"calories", None
|
|
):
|
|
if media.calories:
|
|
scrobble_data["log"] = FoodLogData(calories=media.calories).asdict
|
|
|
|
if mtype not in LONG_PLAY_MEDIA.values():
|
|
scrobble_data.pop("long_play_complete", None)
|
|
|
|
scrobble = cls.create(scrobble_data)
|
|
|
|
if mtype in LONG_PLAY_MEDIA.values():
|
|
last_finished = (
|
|
cls.objects.filter(
|
|
models.Q(**{key: media}),
|
|
user_id=user_id,
|
|
timestamp__lt=scrobble.timestamp,
|
|
)
|
|
.order_by("-timestamp")
|
|
.first()
|
|
)
|
|
if last_finished:
|
|
scrobble.long_play_last_scrobble = last_finished
|
|
scrobble.save(update_fields=["long_play_last_scrobble"])
|
|
|
|
return scrobble
|
|
|
|
@classmethod
|
|
def create_or_update_location(
|
|
cls, location: GeoLocation, scrobble_data: dict, user_id: int
|
|
) -> "Scrobble":
|
|
"""Location is special type, where the current scrobble for a user is always the
|
|
current active scrobble, and we only finish it a move on if we get a new location
|
|
that is far enough (and far enough over the last three past scrobbles) to have
|
|
actually moved.
|
|
"""
|
|
key = media_class_to_foreign_key(location.__class__.__name__)
|
|
scrobble_data[key + "_id"] = location.id
|
|
|
|
scrobble = (
|
|
cls.objects.filter(
|
|
media_type=cls.MediaType.GEO_LOCATION,
|
|
user_id=user_id,
|
|
timestamp__lte=scrobble_data.get("timestamp"),
|
|
)
|
|
.order_by("-timestamp")
|
|
.first()
|
|
)
|
|
|
|
logger.info(
|
|
f"[scrobbling] fetching last location scrobble",
|
|
extra={
|
|
"scrobble_id": scrobble.id if scrobble else None,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"media_id": location.id,
|
|
"scrobble_data": scrobble_data,
|
|
},
|
|
)
|
|
|
|
if not scrobble:
|
|
logger.info(
|
|
f"[scrobbling] finished - no existing location scrobbles found",
|
|
extra={
|
|
"media_id": location.id,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
},
|
|
)
|
|
return cls.create(scrobble_data)
|
|
|
|
if scrobble.media_obj == location:
|
|
logger.info(
|
|
f"[scrobbling] finished - same location - not moved",
|
|
extra={
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"media_id": location.id,
|
|
"scrobble_id": scrobble.id,
|
|
"scrobble_media_id": scrobble.media_obj.id,
|
|
},
|
|
)
|
|
return scrobble
|
|
|
|
has_moved = location.has_moved(scrobble.media_obj)
|
|
logger.info(
|
|
f"[scrobbling] checking - has last location has moved?",
|
|
extra={
|
|
"scrobble_id": scrobble.id,
|
|
"scrobble_media_id": scrobble.media_obj.id,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"media_id": location.id,
|
|
"has_moved": has_moved,
|
|
},
|
|
)
|
|
if not has_moved:
|
|
logger.info(
|
|
f"[scrobbling] finished - not from old location - not moved",
|
|
extra={
|
|
"scrobble_id": scrobble.id,
|
|
"media_id": location.id,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"old_media__id": scrobble.media_obj.id,
|
|
},
|
|
)
|
|
return scrobble
|
|
|
|
if existing_locations := location.in_proximity(named=True):
|
|
existing_location = existing_locations.first()
|
|
ts = int(pendulum.now().timestamp())
|
|
scrobble.log[ts] = f"Location {location.id} too close to this scrobble"
|
|
scrobble.save(update_fields=["log"])
|
|
logger.info(
|
|
f"[scrobbling] finished - found existing named location",
|
|
extra={
|
|
"media_id": location.id,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"old_media_id": existing_location.id,
|
|
},
|
|
)
|
|
return scrobble
|
|
|
|
scrobble.stop(force_finish=True)
|
|
scrobble = cls.create(scrobble_data)
|
|
logger.info(
|
|
f"[scrobbling] finished - created for location",
|
|
extra={
|
|
"scrobble_id": scrobble.id,
|
|
"media_id": location.id,
|
|
"scrobble_data": scrobble_data,
|
|
"media_type": cls.MediaType.GEO_LOCATION,
|
|
"source": scrobble_data.get("source"),
|
|
},
|
|
)
|
|
return scrobble
|
|
|
|
def update(self, scrobble_data: dict) -> "Scrobble":
|
|
# Status is a field we get from Mopidy, which refuses to poll us
|
|
playback_status = scrobble_data.pop("playback_status", None)
|
|
|
|
logger.info(
|
|
"[update] called",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
"scrobble_data": scrobble_data,
|
|
"media_type": self.media_type,
|
|
"playback_status": playback_status,
|
|
},
|
|
)
|
|
if self.beyond_completion_percent:
|
|
playback_status = "stopped"
|
|
|
|
if playback_status == "stopped":
|
|
self.stop()
|
|
if playback_status == "paused":
|
|
self.pause()
|
|
if playback_status == "resumed":
|
|
self.resume()
|
|
|
|
if playback_status != "resumed":
|
|
scrobble_data["stop_timestamp"] = (
|
|
scrobble_data.pop("timestamp", None) or timezone.now()
|
|
)
|
|
|
|
# timestamp should be more-or-less immutable
|
|
scrobble_data.pop("timestamp", None)
|
|
|
|
update_fields = []
|
|
for key, value in scrobble_data.items():
|
|
setattr(self, key, value)
|
|
update_fields.append(key)
|
|
self.save(update_fields=update_fields)
|
|
|
|
return self
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
scrobble_data: dict,
|
|
) -> "Scrobble":
|
|
if "visibility" not in scrobble_data:
|
|
user = scrobble_data.get("user")
|
|
media_type = scrobble_data.get("media_type")
|
|
override = None
|
|
if user and media_type:
|
|
try:
|
|
profile = user.profile
|
|
overrides = profile.media_type_visibility or {}
|
|
override = overrides.get(media_type)
|
|
except user.__class__.profile.RelatedObjectDoesNotExist:
|
|
pass
|
|
scrobble_data["visibility"] = override or Visibility.PRIVATE
|
|
scrobble = cls.objects.create(**scrobble_data)
|
|
if not (
|
|
scrobble.media_type == cls.MediaType.GEO_LOCATION
|
|
and scrobble.media_obj
|
|
and not scrobble.media_obj.title
|
|
):
|
|
ScrobbleNtfyNotification(scrobble).send()
|
|
return scrobble
|
|
|
|
def stop(self, timestamp=None, force_finish=False) -> None:
|
|
self.stop_timestamp = timestamp or timezone.now()
|
|
self.played_to_completion = True
|
|
self.in_progress = False
|
|
|
|
if not self.playback_position_seconds:
|
|
self.playback_position_seconds = int(
|
|
(self.stop_timestamp - self.timestamp).total_seconds()
|
|
)
|
|
|
|
self.save(
|
|
update_fields=[
|
|
"in_progress",
|
|
"played_to_completion",
|
|
"stop_timestamp",
|
|
"playback_position_seconds",
|
|
]
|
|
)
|
|
|
|
self._add_labels_as_tags()
|
|
|
|
class_name = self.media_obj.__class__.__name__
|
|
if class_name in LONG_PLAY_MEDIA.values():
|
|
self.finish_long_play()
|
|
|
|
if class_name == "Book":
|
|
self.calculate_reading_stats()
|
|
|
|
logger.info(
|
|
f"[scrobbling] stopped",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
"media_id": self.media_obj.id,
|
|
"media_type": self.media_type,
|
|
"source": self.source,
|
|
},
|
|
)
|
|
|
|
def _add_labels_as_tags(self) -> None:
|
|
if self.logdata and hasattr(self.logdata, "labels") and self.logdata.labels:
|
|
for label in self.logdata.labels:
|
|
self.tags.add(label)
|
|
|
|
def pause(self) -> None:
|
|
if self.is_paused:
|
|
logger.warning(f"{self.id} - already paused - {self.source}")
|
|
return
|
|
self.is_paused = True
|
|
self.save(update_fields=["is_paused"])
|
|
logger.info(
|
|
f"[scrobbling] paused",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"source": self.source,
|
|
},
|
|
)
|
|
|
|
def resume(self) -> None:
|
|
if self.is_paused or not self.in_progress:
|
|
self.is_paused = False
|
|
self.in_progress = True
|
|
self.save(update_fields=["is_paused", "in_progress"])
|
|
logger.info(
|
|
f"[scrobbling] resumed",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"source": self.source,
|
|
},
|
|
)
|
|
|
|
def cancel(self) -> None:
|
|
self.delete()
|
|
|
|
def update_ticks(self, data) -> None:
|
|
self.playback_position_seconds = data.get("playback_position_seconds")
|
|
self.save(update_fields=["playback_position_seconds"])
|
|
|
|
def finish_long_play(self):
|
|
seconds_elapsed = (timezone.now() - self.timestamp).seconds
|
|
past_seconds = 0
|
|
|
|
# Set our playback seconds, and calc long play seconds
|
|
self.playback_position_seconds = seconds_elapsed
|
|
if self.long_play_last_scrobble:
|
|
past_seconds = self.long_play_last_scrobble.long_play_seconds or 0
|
|
|
|
self.long_play_seconds = past_seconds + seconds_elapsed
|
|
|
|
# Long play scrobbles are always finished when we say they are
|
|
self.played_to_completion = True
|
|
|
|
self.save(
|
|
update_fields=[
|
|
"playback_position_seconds",
|
|
"played_to_completion",
|
|
"long_play_seconds",
|
|
]
|
|
)
|
|
logger.info(
|
|
f"[scrobbling] finishing long play",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
},
|
|
)
|
|
|
|
@property
|
|
def beyond_completion_percent(self) -> bool:
|
|
"""Returns true if our media is beyond our completion percent, unless
|
|
our type is geolocation in which case we always return false
|
|
"""
|
|
beyond_completion = self.percent_played >= self.media_obj.COMPLETION_PERCENT
|
|
|
|
if self.media_type == "GeoLocation":
|
|
logger.info(
|
|
f"[scrobbling] locations are ONLY completed when new one is created",
|
|
extra={
|
|
"scrobble_id": self.id,
|
|
"media_type": self.media_type,
|
|
"beyond_completion": beyond_completion,
|
|
},
|
|
)
|
|
beyond_completion = False
|
|
|
|
return beyond_completion
|
|
|
|
@property
|
|
def is_over_time(self) -> bool:
|
|
"""Has the scrobble overrun it's time?"""
|
|
elapsed_scrobble_seconds = (timezone.now() - self.timestamp).seconds
|
|
|
|
if elapsed_scrobble_seconds > self.media_obj.run_time_seconds:
|
|
return True
|
|
return False
|
|
|
|
def auto_finish(self) -> bool:
|
|
"""Check if media type should auto finish.
|
|
|
|
Return True if scrobble is finished, False if not.
|
|
"""
|
|
if self.is_over_time and self.media_type in AUTO_FINISH_MEDIA.values():
|
|
self.stop(force_finish=True)
|
|
return True
|
|
return False
|
|
|
|
def calculate_reading_stats(self, commit=True):
|
|
page_data = self.logdata.page_data
|
|
|
|
if page_data:
|
|
if isinstance(page_data, dict):
|
|
valid_pages = sorted(int(k) for k in page_data.keys())
|
|
if valid_pages:
|
|
self.log["page_start"] = min(valid_pages)
|
|
self.log["page_end"] = max(valid_pages)
|
|
self.log["pages_read"] = len(set(valid_pages))
|
|
elif isinstance(page_data, list):
|
|
valid_pages = []
|
|
for page in page_data:
|
|
try:
|
|
valid_pages.append(int(page["page_number"]))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
|
|
if valid_pages:
|
|
self.log["page_start"] = min(valid_pages)
|
|
self.log["page_end"] = max(valid_pages)
|
|
self.log["pages_read"] = len(set(valid_pages))
|
|
else:
|
|
page_start = self.log.get("page_start")
|
|
page_end = self.log.get("page_end")
|
|
if page_start is not None and page_end is not None:
|
|
self.log["pages_read"] = page_end - page_start + 1
|
|
|
|
if commit and "pages_read" in self.log:
|
|
self.save(update_fields=["log"])
|
|
|
|
|
|
class FavoriteMedia(TimeStampedModel):
|
|
|
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
|
|
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
|
|
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
|
|
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
|
|
podcast_episode = models.ForeignKey(
|
|
PodcastEpisode, on_delete=models.CASCADE, **BNULL
|
|
)
|
|
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
|
|
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
|
|
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
|
|
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
|
|
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
|
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
|
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
|
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
|
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
|
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
|
|
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
|
|
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
|
|
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
|
|
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
|
|
birding_location = models.ForeignKey(
|
|
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
|
)
|
|
disc_golf_course = models.ForeignKey(
|
|
DiscGolfCourse, on_delete=models.CASCADE, **BNULL
|
|
)
|
|
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
|
|
sent_to_mopidy = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
ordering = ["-created"]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} favorites {self.media_obj}"
|
|
|
|
@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
|
|
if self.channel:
|
|
media_obj = self.channel
|
|
if self.birding_location:
|
|
media_obj = self.birding_location
|
|
if self.disc_golf_course:
|
|
media_obj = self.disc_golf_course
|
|
return media_obj
|
|
|
|
@classmethod
|
|
def toggle(cls, media_obj, user):
|
|
media_type = media_obj.__class__.__name__
|
|
if media_type not in Scrobble.MediaType.list():
|
|
raise ValueError(f"Unknown media type: {media_type}")
|
|
|
|
fk_map = {
|
|
"Video": "video",
|
|
"Channel": "channel",
|
|
"Track": "track",
|
|
"PodcastEpisode": "podcast_episode",
|
|
"SportEvent": "sport_event",
|
|
"Book": "book",
|
|
"Paper": "paper",
|
|
"VideoGame": "video_game",
|
|
"BoardGame": "board_game",
|
|
"GeoLocation": "geo_location",
|
|
"Beer": "beer",
|
|
"Puzzle": "puzzle",
|
|
"Food": "food",
|
|
"Trail": "trail",
|
|
"Task": "task",
|
|
"WebPage": "web_page",
|
|
"LifeEvent": "life_event",
|
|
"Mood": "mood",
|
|
"BrickSet": "brick_set",
|
|
"BirdingLocation": "birding_location",
|
|
"DiscGolfCourse": "disc_golf_course",
|
|
}
|
|
|
|
fk = fk_map.get(media_type)
|
|
if not fk:
|
|
raise ValueError(f"No FK mapping for media type: {media_type}")
|
|
|
|
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
|
|
if existing:
|
|
existing.delete()
|
|
return None
|
|
|
|
return cls.objects.create(
|
|
user=user,
|
|
media_type=media_type,
|
|
**{fk: media_obj},
|
|
)
|