models.py 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447
  1. import calendar
  2. import datetime
  3. import json
  4. import logging
  5. from collections import defaultdict
  6. from typing import Optional
  7. from uuid import uuid4
  8. from zoneinfo import ZoneInfo
  9. import pendulum
  10. import pytz
  11. from beers.models import Beer
  12. from boardgames.models import BoardGame
  13. from books.koreader import process_koreader_sqlite_file
  14. from books.models import Book, Paper
  15. from bricksets.models import BrickSet
  16. from dataclass_wizard.errors import ParseError
  17. from django.conf import settings
  18. from django.contrib.auth import get_user_model
  19. from django.core.files import File
  20. from django.db import models
  21. from django.urls import reverse
  22. from django.utils import timezone
  23. from django_extensions.db.models import TimeStampedModel
  24. from foods.models import Food
  25. from imagekit.models import ImageSpecField
  26. from imagekit.processors import ResizeToFit
  27. from lifeevents.models import LifeEvent
  28. from locations.models import GeoLocation
  29. from moods.models import Mood
  30. from music.models import Artist, Track
  31. from podcasts.models import PodcastEpisode
  32. from profiles.utils import (
  33. end_of_day,
  34. end_of_month,
  35. end_of_week,
  36. fix_profile_historic_timezones,
  37. start_of_day,
  38. start_of_month,
  39. start_of_week,
  40. )
  41. from puzzles.models import Puzzle
  42. from scrobbles import dataclasses as logdata
  43. from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
  44. from scrobbles.importers.lastfm import LastFM
  45. from scrobbles.notifications import ScrobbleNtfyNotification
  46. from scrobbles.stats import build_charts
  47. from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
  48. from sports.models import SportEvent
  49. from tasks.models import Task
  50. from trails.models import Trail
  51. from videogames import retroarch
  52. from videogames.models import VideoGame
  53. from videos.models import Series, Video
  54. from webpages.models import WebPage
  55. logger = logging.getLogger(__name__)
  56. User = get_user_model()
  57. BNULL = {"blank": True, "null": True}
  58. POINTS_FOR_MOVEMENT_HISTORY = int(
  59. getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3)
  60. )
  61. class BaseFileImportMixin(TimeStampedModel):
  62. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  63. uuid = models.UUIDField(editable=False, default=uuid4)
  64. processing_started = models.DateTimeField(**BNULL)
  65. processed_finished = models.DateTimeField(**BNULL)
  66. process_log = models.TextField(**BNULL)
  67. process_count = models.IntegerField(**BNULL)
  68. class Meta:
  69. abstract = True
  70. def __str__(self):
  71. return f"{self.import_type} import on {self.human_start}"
  72. @property
  73. def human_start(self):
  74. start = "Unknown"
  75. if self.processing_started:
  76. start = self.processing_started.strftime("%B %d, %Y at %H:%M")
  77. return start
  78. @property
  79. def import_type(self) -> str:
  80. return "Unknown Import Source"
  81. def process(self, force=False):
  82. logger.warning("Process not implemented")
  83. def undo(self, dryrun=False):
  84. """Accepts the log from a scrobble import and removes the scrobbles"""
  85. from scrobbles.models import Scrobble
  86. if not self.process_log:
  87. logger.warning("No lines in process log found to undo")
  88. return
  89. for line in self.process_log.split("\n"):
  90. scrobble_id = line.split("\t")[0]
  91. scrobble = Scrobble.objects.filter(id=scrobble_id).first()
  92. if not scrobble:
  93. logger.warning(
  94. f"Could not find scrobble {scrobble_id} to undo"
  95. )
  96. continue
  97. logger.info(f"Removing scrobble {scrobble_id}")
  98. if not dryrun:
  99. scrobble.delete()
  100. self.processed_finished = None
  101. self.processing_started = None
  102. self.process_count = None
  103. self.process_log = ""
  104. self.save(
  105. update_fields=[
  106. "processed_finished",
  107. "processing_started",
  108. "process_log",
  109. "process_count",
  110. ]
  111. )
  112. def scrobbles(self) -> models.QuerySet:
  113. scrobble_ids = []
  114. if self.process_log:
  115. for line in self.process_log.split("\n"):
  116. sid = line.split("\t")[0]
  117. if sid:
  118. scrobble_ids.append(sid)
  119. return Scrobble.objects.filter(id__in=scrobble_ids)
  120. def mark_started(self):
  121. self.processing_started = timezone.now()
  122. self.save(update_fields=["processing_started"])
  123. def mark_finished(self):
  124. self.processed_finished = timezone.now()
  125. self.save(update_fields=["processed_finished"])
  126. def record_log(self, scrobbles):
  127. self.process_log = ""
  128. if not scrobbles:
  129. self.process_count = 0
  130. self.save(update_fields=["process_log", "process_count"])
  131. return
  132. for count, scrobble in enumerate(scrobbles):
  133. scrobble_str = (
  134. f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
  135. )
  136. log_line = f"{scrobble_str}"
  137. if count > 0:
  138. log_line = "\n" + log_line
  139. self.process_log += log_line
  140. self.process_count = len(scrobbles)
  141. self.save(update_fields=["process_log", "process_count"])
  142. @property
  143. def upload_file_path(self):
  144. raise NotImplementedError
  145. class KoReaderImport(BaseFileImportMixin):
  146. class Meta:
  147. verbose_name = "KOReader Import"
  148. @property
  149. def import_type(self) -> str:
  150. return "KOReader"
  151. def get_absolute_url(self):
  152. return reverse(
  153. "scrobbles:koreader-import-detail", kwargs={"slug": self.uuid}
  154. )
  155. def get_path(instance, filename):
  156. extension = filename.split(".")[-1]
  157. uuid = instance.uuid
  158. return f"koreader-uploads/{uuid}.{extension}"
  159. @property
  160. def upload_file_path(self) -> str:
  161. if getattr(settings, "USE_S3_STORAGE"):
  162. path = self.sqlite_file.url
  163. else:
  164. path = self.sqlite_file.path
  165. return path
  166. sqlite_file = models.FileField(upload_to=get_path, **BNULL)
  167. def save_sqlite_file_to_self(self, file_path):
  168. with open(file_path, "rb") as f:
  169. self.sqlite_file.save(
  170. f"{self.user_id}-koreader-statistics.sqlite",
  171. File(f),
  172. save=True,
  173. )
  174. def file_md5_hash(self) -> str:
  175. if self.sqlite_file:
  176. return get_file_md5_hash(self.sqlite_file.path)
  177. return ""
  178. def process(self, force=False):
  179. if self.user.id == 1:
  180. fix_profile_historic_timezones(self.user.profile)
  181. if self.processed_finished and not force:
  182. logger.info(
  183. f"{self} already processed on {self.processed_finished}"
  184. )
  185. return
  186. self.mark_started()
  187. scrobbles = process_koreader_sqlite_file(
  188. self.upload_file_path, self.user.id
  189. )
  190. self.record_log(scrobbles)
  191. self.mark_finished()
  192. class AudioScrobblerTSVImport(BaseFileImportMixin):
  193. class Meta:
  194. verbose_name = "AudioScrobbler TSV Import"
  195. @property
  196. def import_type(self) -> str:
  197. return "AudiosScrobbler"
  198. def get_absolute_url(self):
  199. return reverse(
  200. "scrobbles:tsv-import-detail", kwargs={"slug": self.uuid}
  201. )
  202. def get_path(instance, filename):
  203. extension = filename.split(".")[-1]
  204. uuid = instance.uuid
  205. return f"audioscrobbler-uploads/{uuid}.{extension}"
  206. @property
  207. def upload_file_path(self):
  208. if getattr(settings, "USE_S3_STORAGE"):
  209. path = self.tsv_file.url
  210. else:
  211. path = self.tsv_file.path
  212. return path
  213. tsv_file = models.FileField(upload_to=get_path, **BNULL)
  214. def process(self, force=False):
  215. from scrobbles.importers.tsv import import_audioscrobbler_tsv_file
  216. if self.user.id == 1:
  217. fix_profile_historic_timezones(self.user.profile)
  218. if self.processed_finished and not force:
  219. logger.info(
  220. f"{self} already processed on {self.processed_finished}"
  221. )
  222. return
  223. self.mark_started()
  224. scrobbles = import_audioscrobbler_tsv_file(
  225. self.upload_file_path, self.user.id
  226. )
  227. self.record_log(scrobbles)
  228. self.mark_finished()
  229. class LastFmImport(BaseFileImportMixin):
  230. class Meta:
  231. verbose_name = "Last.FM Import"
  232. @property
  233. def import_type(self) -> str:
  234. return "LastFM"
  235. def get_absolute_url(self):
  236. return reverse(
  237. "scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid}
  238. )
  239. def process(self, import_all=False):
  240. """Import scrobbles found on LastFM"""
  241. if self.user.id == 1:
  242. fix_profile_historic_timezones(self.user.profile)
  243. if self.processed_finished:
  244. logger.info(
  245. f"{self} already processed on {self.processed_finished}"
  246. )
  247. return
  248. last_import = None
  249. if not import_all:
  250. try:
  251. last_import = LastFmImport.objects.exclude(id=self.id).last()
  252. except:
  253. pass
  254. if not import_all and not last_import:
  255. logger.warn(
  256. "No previous import, to import all Last.fm scrobbles, pass import_all=True"
  257. )
  258. return
  259. lastfm = LastFM(self.user)
  260. last_processed = None
  261. if last_import:
  262. last_processed = last_import.processed_finished
  263. self.mark_started()
  264. scrobbles = lastfm.import_from_lastfm(last_processed)
  265. self.record_log(scrobbles)
  266. self.mark_finished()
  267. class RetroarchImport(BaseFileImportMixin):
  268. class Meta:
  269. verbose_name = "Retroarch Import"
  270. @property
  271. def import_type(self) -> str:
  272. return "Retroarch"
  273. def get_absolute_url(self):
  274. return reverse(
  275. "scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid}
  276. )
  277. def process(self, import_all=False, force=False):
  278. """Import scrobbles found on Retroarch"""
  279. if self.user.id == 1:
  280. fix_profile_historic_timezones(self.user.profile)
  281. if self.processed_finished and not force:
  282. logger.info(
  283. f"{self} already processed on {self.processed_finished}"
  284. )
  285. return
  286. if force:
  287. logger.info(f"You told me to force import from Retroarch")
  288. if not self.user.profile.retroarch_path:
  289. logger.info(
  290. "Tying to import Retroarch logs, but user has no retroarch_path configured"
  291. )
  292. self.mark_started()
  293. scrobbles = retroarch.import_retroarch_lrtl_files(
  294. self.user.profile.retroarch_path,
  295. self.user.id,
  296. )
  297. self.record_log(scrobbles)
  298. self.mark_finished()
  299. class ChartRecord(TimeStampedModel):
  300. """Sort of like a materialized view for what we could dynamically generate,
  301. but would kill the DB as it gets larger. Collects time-based records
  302. generated by a cron-like archival job
  303. 1972 by Josh Rouse - #3 in 2023, January
  304. """
  305. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  306. rank = models.IntegerField(db_index=True)
  307. count = models.IntegerField(default=0)
  308. year = models.IntegerField(**BNULL)
  309. month = models.IntegerField(**BNULL)
  310. week = models.IntegerField(**BNULL)
  311. day = models.IntegerField(**BNULL)
  312. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  313. series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  314. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
  315. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  316. period_start = models.DateTimeField(**BNULL)
  317. period_end = models.DateTimeField(**BNULL)
  318. def save(self, *args, **kwargs):
  319. profile = self.user.profile
  320. if self.week:
  321. # set start and end to start and end of week
  322. period = datetime.date.fromisocalendar(self.year, self.week, 1)
  323. self.period_start = start_of_week(period, profile)
  324. self.period_start = end_of_week(period, profile)
  325. if self.day:
  326. period = datetime.datetime(self.year, self.month, self.day)
  327. self.period_start = start_of_day(period, profile)
  328. self.period_end = end_of_day(period, profile)
  329. if self.month and not self.day:
  330. period = datetime.datetime(self.year, self.month, 1)
  331. self.period_start = start_of_month(period, profile)
  332. self.period_end = end_of_month(period, profile)
  333. super(ChartRecord, self).save(*args, **kwargs)
  334. @property
  335. def media_obj(self):
  336. media_obj = None
  337. if self.video:
  338. media_obj = self.video
  339. if self.track:
  340. media_obj = self.track
  341. if self.artist:
  342. media_obj = self.artist
  343. return media_obj
  344. @property
  345. def month_str(self) -> str:
  346. month_str = ""
  347. if self.month:
  348. month_str = calendar.month_name[self.month]
  349. return month_str
  350. @property
  351. def day_str(self) -> str:
  352. day_str = ""
  353. if self.day:
  354. day_str = str(self.day)
  355. return day_str
  356. @property
  357. def week_str(self) -> str:
  358. week_str = ""
  359. if self.week:
  360. week_str = str(self.week)
  361. return "Week " + week_str
  362. @property
  363. def period(self) -> str:
  364. period = str(self.year)
  365. if self.month:
  366. period = " ".join([self.month_str, period])
  367. if self.week:
  368. period = " ".join([self.week_str, period])
  369. if self.day:
  370. period = " ".join([self.day_str, period])
  371. return period
  372. @property
  373. def period_type(self) -> str:
  374. period = "year"
  375. if self.month:
  376. period = "month"
  377. if self.week:
  378. period = "week"
  379. if self.day:
  380. period = "day"
  381. return period
  382. def __str__(self):
  383. title = f"#{self.rank} in {self.period}"
  384. if self.day or self.week:
  385. title = f"#{self.rank} on {self.period}"
  386. return title
  387. def link(self):
  388. get_params = f"?date={self.year}"
  389. if self.week:
  390. get_params = get_params = get_params + f"-W{self.week}"
  391. if self.month:
  392. get_params = get_params = get_params + f"-{self.month}"
  393. if self.day:
  394. get_params = get_params = get_params + f"-{self.day}"
  395. if self.artist:
  396. get_params = get_params + "&media=Artist"
  397. return reverse("scrobbles:charts-home") + get_params
  398. @classmethod
  399. def build(cls, user, **kwargs):
  400. build_charts(user=user, **kwargs)
  401. @classmethod
  402. def for_year(cls, user, year):
  403. return cls.objects.filter(year=year, user=user)
  404. @classmethod
  405. def for_month(cls, user, year, month):
  406. return cls.objects.filter(year=year, month=month, user=user)
  407. @classmethod
  408. def for_day(cls, user, year, day, month):
  409. return cls.objects.filter(year=year, month=month, day=day, user=user)
  410. @classmethod
  411. def for_week(cls, user, year, week):
  412. return cls.objects.filter(year=year, week=week, user=user)
  413. class Scrobble(TimeStampedModel):
  414. """A scrobble tracks played media items by a user."""
  415. class MediaType(models.TextChoices):
  416. """Enum mapping a media model type to a string"""
  417. VIDEO = "Video", "Video"
  418. TRACK = "Track", "Track"
  419. PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
  420. SPORT_EVENT = "SportEvent", "Sport event"
  421. BOOK = "Book", "Book"
  422. PAPER = "Paper", "Paper"
  423. VIDEO_GAME = "VideoGame", "Video game"
  424. BOARD_GAME = "BoardGame", "Board game"
  425. GEO_LOCATION = "GeoLocation", "GeoLocation"
  426. TRAIL = "Trail", "Trail"
  427. BEER = "Beer", "Beer"
  428. PUZZLE = "Puzzle", "Puzzle"
  429. FOOD = "Food", "Food"
  430. TASK = "Task", "Task"
  431. WEBPAGE = "WebPage", "Web Page"
  432. LIFE_EVENT = "LifeEvent", "Life event"
  433. MOOD = "Mood", "Mood"
  434. BRICKSET = "BrickSet", "Brick set"
  435. @classmethod
  436. def list(cls):
  437. return list(map(lambda c: c.value, cls))
  438. uuid = models.UUIDField(editable=False, **BNULL)
  439. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  440. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  441. podcast_episode = models.ForeignKey(
  442. PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
  443. )
  444. sport_event = models.ForeignKey(
  445. SportEvent, on_delete=models.DO_NOTHING, **BNULL
  446. )
  447. book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
  448. paper = models.ForeignKey(Paper, on_delete=models.DO_NOTHING, **BNULL)
  449. video_game = models.ForeignKey(
  450. VideoGame, on_delete=models.DO_NOTHING, **BNULL
  451. )
  452. board_game = models.ForeignKey(
  453. BoardGame, on_delete=models.DO_NOTHING, **BNULL
  454. )
  455. geo_location = models.ForeignKey(
  456. GeoLocation, on_delete=models.DO_NOTHING, **BNULL
  457. )
  458. beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
  459. puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
  460. food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
  461. trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
  462. task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL)
  463. web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
  464. life_event = models.ForeignKey(
  465. LifeEvent, on_delete=models.DO_NOTHING, **BNULL
  466. )
  467. mood = models.ForeignKey(Mood, on_delete=models.DO_NOTHING, **BNULL)
  468. brick_set = models.ForeignKey(
  469. BrickSet, on_delete=models.DO_NOTHING, **BNULL
  470. )
  471. media_type = models.CharField(
  472. max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
  473. )
  474. user = models.ForeignKey(
  475. User, blank=True, null=True, on_delete=models.DO_NOTHING
  476. )
  477. # Time keeping
  478. timestamp = models.DateTimeField(**BNULL)
  479. stop_timestamp = models.DateTimeField(**BNULL)
  480. playback_position_seconds = models.IntegerField(**BNULL)
  481. # Status indicators
  482. is_paused = models.BooleanField(default=False)
  483. played_to_completion = models.BooleanField(default=False)
  484. in_progress = models.BooleanField(default=True)
  485. # Metadata
  486. source = models.CharField(max_length=255, **BNULL)
  487. log = models.JSONField(
  488. **BNULL,
  489. default=dict,
  490. encoder=logdata.ScrobbleLogDataEncoder,
  491. decoder=logdata.ScrobbleLogDataDecoder,
  492. )
  493. timezone = models.CharField(max_length=50, **BNULL)
  494. # Fields for keeping track of video game data
  495. videogame_save_data = models.FileField(
  496. upload_to="scrobbles/videogame_save_data/", **BNULL
  497. )
  498. gpx_file = models.FileField(upload_to="scrobbles/gpx_file/", **BNULL)
  499. screenshot = models.ImageField(
  500. upload_to="scrobbles/videogame_screenshot/", **BNULL
  501. )
  502. screenshot_small = ImageSpecField(
  503. source="screenshot",
  504. processors=[ResizeToFit(100, 100)],
  505. format="JPEG",
  506. options={"quality": 60},
  507. )
  508. screenshot_medium = ImageSpecField(
  509. source="screenshot",
  510. processors=[ResizeToFit(300, 300)],
  511. format="JPEG",
  512. options={"quality": 75},
  513. )
  514. long_play_seconds = models.BigIntegerField(**BNULL)
  515. long_play_complete = models.BooleanField(**BNULL)
  516. @classmethod
  517. def for_year(cls, user, year):
  518. return cls.objects.filter(timestamp__year=year, user=user).order_by(
  519. "-timestamp"
  520. )
  521. @classmethod
  522. def for_month(cls, user, year, month):
  523. return cls.objects.filter(
  524. timestamp__year=year, timestamp__month=month, user=user
  525. ).order_by("-timestamp")
  526. @classmethod
  527. def for_day(cls, user, year, month, day):
  528. return cls.objects.filter(
  529. timestamp__year=year,
  530. timestamp__month=month,
  531. timestamp__day=day,
  532. user=user,
  533. ).order_by("-timestamp")
  534. @classmethod
  535. def for_week(cls, user, year, week):
  536. return cls.objects.filter(
  537. timestamp__year=year, timestamp__week=week, user=user
  538. ).order_by("-timestamp")
  539. @classmethod
  540. def as_dict_by_type(cls, scrobble_qs: models.QuerySet) -> dict:
  541. scrobbles_by_type = defaultdict(list)
  542. for scrobble in scrobble_qs:
  543. scrobbles_by_type[scrobble.media_type].append(scrobble)
  544. if not scrobbles_by_type.get(scrobble.media_type + "_count"):
  545. scrobbles_by_type[scrobble.media_type + "_count"] = 0
  546. scrobbles_by_type[scrobble.media_type + "_count"] += 1
  547. if not scrobbles_by_type.get(scrobble.media_type + "_time"):
  548. scrobbles_by_type[scrobble.media_type + "_time"] = 0
  549. scrobbles_by_type[scrobble.media_type + "_time"] += int(
  550. (scrobble.elapsed_time)
  551. )
  552. return scrobbles_by_type
  553. @classmethod
  554. def in_progress_for_user(cls, user_id: int) -> models.QuerySet:
  555. return cls.objects.filter(
  556. user=user_id,
  557. in_progress=True,
  558. played_to_completion=False,
  559. is_paused=False,
  560. )
  561. @property
  562. def last_serial_scrobble(self) -> Optional["Scrobble"]:
  563. from scrobbles.models import Scrobble
  564. if self.logdata and self.logdata.serial_scrobble_id:
  565. return Scrobble.objects.filter(
  566. id=self.logdata.serial_scrobble_id
  567. ).first()
  568. @property
  569. def finish_url(self) -> str:
  570. return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
  571. def save(self, *args, **kwargs):
  572. if not self.uuid:
  573. self.uuid = uuid4()
  574. if not self.timezone:
  575. timezone = settings.TIME_ZONE
  576. if self.user and self.user.profile:
  577. timezone = self.user.profile.timezone
  578. self.timezone = timezone
  579. # Microseconds mess up Django's filtering, and we don't need be that specific
  580. if self.timestamp:
  581. self.timestamp = self.timestamp.replace(microsecond=0)
  582. if self.media_obj:
  583. self.media_type = self.MediaType(self.media_obj.__class__.__name__)
  584. return super(Scrobble, self).save(*args, **kwargs)
  585. def push_to_archivebox(self):
  586. pushable_media = hasattr(
  587. self.media_obj, "push_to_archivebox"
  588. ) and callable(self.media_obj.push_to_archivebox)
  589. if pushable_media and self.user.profile.archivebox_url:
  590. try:
  591. self.media_obj.push_to_archivebox(
  592. url=self.user.profile.archivebox_url,
  593. username=self.user.profile.archivebox_username,
  594. password=self.user.profile.archivebox_password,
  595. )
  596. except Exception:
  597. logger.info(
  598. "Failed to push URL to archivebox",
  599. extra={
  600. "archivebox_url": self.user.profile.archivebox_url,
  601. "archivebox_username": self.user.profile.archivebox_username,
  602. },
  603. )
  604. @property
  605. def logdata(self) -> Optional[logdata.BaseLogData]:
  606. if self.media_obj:
  607. logdata_cls = self.media_obj.logdata_cls
  608. else:
  609. logdata_cls = logdata.BaseLogData
  610. log_dict = self.log
  611. if isinstance(self.log, str):
  612. # There's nothing stopping django from saving a string in a JSONField :(
  613. logger.warning(
  614. "[scrobbles] Received string in JSON data in log",
  615. extra={"log": self.log},
  616. )
  617. log_dict = json.loads(self.log)
  618. if not log_dict:
  619. log_dict = {}
  620. try:
  621. return logdata_cls.from_dict(log_dict)
  622. except ParseError:
  623. logger.warning(
  624. "Could not parse log data",
  625. extra={"log_dict": log_dict, "scrobble_id": self.id},
  626. )
  627. return logdata_cls()
  628. def redirect_url(self, user_id) -> str:
  629. user = User.objects.filter(id=user_id).first()
  630. redirect_url = self.media_obj.get_absolute_url()
  631. if (
  632. self.media_type == self.MediaType.WEBPAGE
  633. and user
  634. and user.profile.redirect_to_webpage
  635. ):
  636. logger.info(f"Redirecting to {self.media_obj.url}")
  637. redirect_url = self.media_obj.get_read_url()
  638. if (
  639. self.media_type == self.MediaType.VIDEO
  640. and self.media_obj.youtube_id
  641. ):
  642. redirect_url = self.media_obj.youtube_link
  643. return redirect_url
  644. @property
  645. def tzinfo(self):
  646. return ZoneInfo(self.timezone)
  647. @property
  648. def local_timestamp(self):
  649. return timezone.localtime(self.timestamp, timezone=self.tzinfo)
  650. @property
  651. def local_stop_timestamp(self):
  652. if self.stop_timestamp:
  653. return timezone.localtime(
  654. self.stop_timestamp, timezone=self.tzinfo
  655. )
  656. @property
  657. def scrobble_media_key(self) -> str:
  658. return media_class_to_foreign_key(self.media_type) + "_id"
  659. @property
  660. def status(self) -> str:
  661. if self.is_paused:
  662. return "paused"
  663. if self.played_to_completion:
  664. return "finished"
  665. if self.in_progress:
  666. return "in-progress"
  667. return "zombie"
  668. @property
  669. def is_stale(self) -> bool:
  670. """Mark scrobble as stale if it's been more than an hour since it was updated
  671. Effectively, this allows 'resuming' a video scrobble within an hour of starting it.
  672. """
  673. is_stale = False
  674. now = timezone.now()
  675. seconds_since_last_update = (now - self.modified).seconds
  676. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  677. is_stale = True
  678. return is_stale
  679. @property
  680. def previous(self) -> "Scrobble":
  681. return (
  682. self.media_obj.scrobble_set.order_by("-timestamp")
  683. .filter(timestamp__lt=self.timestamp)
  684. .first()
  685. )
  686. @property
  687. def next(self) -> "Scrobble":
  688. return (
  689. self.media_obj.scrobble_set.order_by("timestamp")
  690. .filter(timestamp__gt=self.timestamp)
  691. .first()
  692. )
  693. @property
  694. def previous_by_media(self) -> "Scrobble":
  695. return (
  696. Scrobble.objects.filter(
  697. media_type=self.media_type,
  698. user=self.user,
  699. timestamp__lt=self.timestamp,
  700. )
  701. .order_by("-timestamp")
  702. .first()
  703. )
  704. @property
  705. def next_by_media(self) -> "Scrobble":
  706. return (
  707. Scrobble.objects.filter(
  708. media_type=self.media_type,
  709. user=self.user,
  710. timestamp__gt=self.timestamp,
  711. )
  712. .order_by("-timestamp")
  713. .first()
  714. )
  715. @property
  716. def previous_by_user(self) -> "Scrobble":
  717. return (
  718. Scrobble.objects.order_by("-timestamp")
  719. .filter(timestamp__lt=self.timestamp)
  720. .first()
  721. )
  722. @property
  723. def next_by_user(self) -> "Scrobble":
  724. return (
  725. Scrobble.objects.order_by("-timestamp")
  726. .filter(timestamp__gt=self.timestamp)
  727. .first()
  728. )
  729. @property
  730. def session_pages_read(self) -> Optional[int]:
  731. pages_read = 0
  732. if self.log and isinstance(self.log, dict):
  733. pages_read = self.log.get("pages_read", 0)
  734. return pages_read
  735. @property
  736. def is_long_play(self) -> bool:
  737. return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
  738. @property
  739. def elapsed_time(self) -> int | None:
  740. if self.played_to_completion:
  741. if self.playback_position_seconds:
  742. return self.playback_position_seconds
  743. if self.media_obj.run_time_seconds:
  744. return self.media_obj.run_time_seconds
  745. return (timezone.now() - self.timestamp).seconds
  746. @property
  747. def percent_played(self) -> int:
  748. if not self.media_obj:
  749. return 0
  750. if self.media_obj and not self.media_obj.run_time_seconds:
  751. return 100
  752. if not self.playback_position_seconds and self.played_to_completion:
  753. return 100
  754. playback_seconds = self.playback_position_seconds
  755. if not playback_seconds:
  756. playback_seconds = self.elapsed_time
  757. run_time_secs = self.media_obj.run_time_seconds
  758. percent = int((playback_seconds / run_time_secs) * 100)
  759. if self.is_long_play:
  760. long_play_secs = 0
  761. if self.previous and not self.previous.long_play_complete:
  762. long_play_secs = self.previous.long_play_seconds or 0
  763. percent = int(
  764. ((playback_seconds + long_play_secs) / run_time_secs) * 100
  765. )
  766. return percent
  767. @property
  768. def probably_still_in_progress(self) -> bool:
  769. """Add our start time to our media run time to get when we expect to
  770. Audio tracks should be given a second or two of grace, videos should
  771. be given closer to 30 minutes, because the odds of watching it back to
  772. back are very slim.
  773. """
  774. is_in_progress = False
  775. padding_seconds = MEDIA_END_PADDING_SECONDS.get(self.media_type)
  776. if not padding_seconds:
  777. return is_in_progress
  778. if not self.media_obj:
  779. logger.info(
  780. "[scrobbling] scrobble has no media obj",
  781. extra={
  782. "media_id": self.media_obj,
  783. "scrobble_id": self.id,
  784. "media_type": self.media_type,
  785. "probably_still_in_progress": is_in_progress,
  786. },
  787. )
  788. return is_in_progress
  789. if not self.media_obj.run_time_seconds:
  790. logger.info(
  791. "[scrobbling] media has no run time seconds, cannot calculate end",
  792. extra={
  793. "media_id": self.media_obj.id,
  794. "scrobble_id": self.id,
  795. "media_type": self.media_type,
  796. "probably_still_in_progress": is_in_progress,
  797. },
  798. )
  799. return is_in_progress
  800. expected_end = self.timestamp + datetime.timedelta(
  801. seconds=self.media_obj.run_time_seconds
  802. )
  803. expected_end_padded = expected_end + datetime.timedelta(
  804. seconds=padding_seconds
  805. )
  806. # Take our start time, add our media length and an extra 30 min (1800s) is it still in the future? keep going
  807. is_in_progress = expected_end_padded > pendulum.now()
  808. logger.info(
  809. "[scrobbling] checking if we're probably still playing",
  810. extra={
  811. "media_id": self.media_obj.id,
  812. "scrobble_id": self.id,
  813. "media_type": self.media_type,
  814. "probably_still_in_progress": is_in_progress,
  815. },
  816. )
  817. return is_in_progress
  818. @property
  819. def can_be_updated(self) -> bool:
  820. if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
  821. logger.info(
  822. "[scrobbling] cannot be updated, long play media",
  823. extra={
  824. "media_id": self.media_obj.id,
  825. "scrobble_id": self.id,
  826. "media_type": self.media_type,
  827. },
  828. )
  829. return False
  830. if self.percent_played >= 100 and not self.probably_still_in_progress:
  831. logger.info(
  832. "[scrobbling] cannot be updated, existing scrobble is 100% played",
  833. extra={
  834. "media_id": self.media_obj.id,
  835. "scrobble_id": self.id,
  836. "media_type": self.media_type,
  837. },
  838. )
  839. return False
  840. if self.is_stale:
  841. logger.info(
  842. "[scrobbling] cannot be udpated, stale",
  843. extra={
  844. "media_id": self.media_obj.id,
  845. "scrobble_id": self.id,
  846. "media_type": self.media_type,
  847. },
  848. )
  849. return False
  850. logger.info(
  851. "[scrobbling] can be updated",
  852. extra={
  853. "media_id": self.media_obj.id,
  854. "scrobble_id": self.id,
  855. "media_type": self.media_type,
  856. },
  857. )
  858. return True
  859. @classmethod
  860. def by_date(cls, media_type: str = "Track"):
  861. cls.objects.filter(media_type=media_type).values(
  862. "timestamp__date"
  863. ).annotate(count=models.Count("id")).values(
  864. "timestamp__date", "count"
  865. ).order_by(
  866. "-count",
  867. )
  868. @property
  869. def media_obj(self):
  870. media_obj = None
  871. if self.video:
  872. media_obj = self.video
  873. if self.track:
  874. media_obj = self.track
  875. if self.podcast_episode:
  876. media_obj = self.podcast_episode
  877. if self.sport_event:
  878. media_obj = self.sport_event
  879. if self.book:
  880. media_obj = self.book
  881. if self.video_game:
  882. media_obj = self.video_game
  883. if self.board_game:
  884. media_obj = self.board_game
  885. if self.geo_location:
  886. media_obj = self.geo_location
  887. if self.web_page:
  888. media_obj = self.web_page
  889. if self.life_event:
  890. media_obj = self.life_event
  891. if self.mood:
  892. media_obj = self.mood
  893. if self.brick_set:
  894. media_obj = self.brick_set
  895. if self.trail:
  896. media_obj = self.trail
  897. if self.beer:
  898. media_obj = self.beer
  899. if self.puzzle:
  900. media_obj = self.puzzle
  901. if self.task:
  902. media_obj = self.task
  903. return media_obj
  904. def __str__(self):
  905. timestamp = self.timestamp.strftime("%Y-%m-%d")
  906. return f"Scrobble of {self.media_obj} ({timestamp})"
  907. def calc_reading_duration(self) -> int:
  908. duration = 0
  909. if self.logdata.page_data:
  910. for k, v in self.logdata.page_data.items():
  911. duration += v.get("duration")
  912. return duration
  913. def calc_pages_read(self) -> int:
  914. pages_read = 0
  915. if self.logdata.page_data:
  916. pages = [int(k) for k in self.logdata.page_data.keys()]
  917. pages.sort()
  918. if len(pages) == 1:
  919. pages_read = 1
  920. elif len(pages) >= 2:
  921. pages_read += pages[-1] - pages[0]
  922. else:
  923. pages_read = pages[-1] - pages[0]
  924. return pages_read
  925. @property
  926. def last_page_read(self) -> int:
  927. last_page = 0
  928. if self.logdata.page_data:
  929. pages = [int(k) for k in self.logdata.page_data.keys()]
  930. pages.sort()
  931. last_page = pages[-1]
  932. return last_page
  933. @property
  934. def get_media_source_url(self) -> str:
  935. url = ""
  936. if self.media_type == "Website":
  937. url = self.media_obj.url
  938. if self.media_type == "Task":
  939. url = self.media_obj.source_url_for_user(self.user)
  940. return url
  941. @classmethod
  942. def create_or_update(
  943. cls, media, user_id: int, scrobble_data: dict, **kwargs
  944. ) -> "Scrobble":
  945. key = media_class_to_foreign_key(media.__class__.__name__)
  946. media_query = models.Q(**{key: media})
  947. scrobble_data[key + "_id"] = media.id
  948. skip_in_progress_check = kwargs.get("skip_in_progress_check", False)
  949. # Find our last scrobble of this media item (track, video, etc)
  950. scrobble = (
  951. cls.objects.filter(
  952. media_query,
  953. user_id=user_id,
  954. )
  955. .order_by("-timestamp")
  956. .first()
  957. )
  958. source = scrobble_data.get("source", "Vrobbler")
  959. mtype = media.__class__.__name__
  960. # GeoLocations are a special case scrobble
  961. if mtype == cls.MediaType.GEO_LOCATION:
  962. logger.warning(
  963. f"[create_or_update] geoloc requires create_or_update_location"
  964. )
  965. scrobble = cls.create_or_update_location(
  966. media, scrobble_data, user_id
  967. )
  968. return scrobble
  969. if not skip_in_progress_check:
  970. logger.info(
  971. f"[create_or_update] check for existing scrobble to update ",
  972. extra={
  973. "scrobble_id": scrobble.id if scrobble else None,
  974. "media_type": mtype,
  975. "media_id": media.id,
  976. "scrobble_data": scrobble_data,
  977. },
  978. )
  979. scrobble_data["playback_status"] = scrobble_data.pop(
  980. "status", None
  981. )
  982. # If it's marked as stopped, send it through our update mechanism, which will complete it
  983. if scrobble and (
  984. scrobble.can_be_updated
  985. or scrobble_data["playback_status"] == "stopped"
  986. ):
  987. if "log" in scrobble_data.keys() and scrobble.log:
  988. scrobble_data["log"] = scrobble.log | scrobble_data["log"]
  989. return scrobble.update(scrobble_data)
  990. # Discard status before creating
  991. scrobble_data.pop("playback_status")
  992. logger.info(
  993. f"[scrobbling] creating new scrobble",
  994. extra={
  995. "scrobble_id": scrobble.id if scrobble else None,
  996. "media_type": mtype,
  997. "media_id": media.id,
  998. "source": source,
  999. },
  1000. )
  1001. scrobble = cls.create(scrobble_data)
  1002. return scrobble
  1003. @classmethod
  1004. def create_or_update_location(
  1005. cls, location: GeoLocation, scrobble_data: dict, user_id: int
  1006. ) -> "Scrobble":
  1007. """Location is special type, where the current scrobble for a user is always the
  1008. current active scrobble, and we only finish it a move on if we get a new location
  1009. that is far enough (and far enough over the last three past scrobbles) to have
  1010. actually moved.
  1011. """
  1012. key = media_class_to_foreign_key(location.__class__.__name__)
  1013. scrobble_data[key + "_id"] = location.id
  1014. scrobble = (
  1015. cls.objects.filter(
  1016. media_type=cls.MediaType.GEO_LOCATION,
  1017. user_id=user_id,
  1018. timestamp__lte=scrobble_data.get("timestamp"),
  1019. )
  1020. .order_by("-timestamp")
  1021. .first()
  1022. )
  1023. logger.info(
  1024. f"[scrobbling] fetching last location scrobble",
  1025. extra={
  1026. "scrobble_id": scrobble.id if scrobble else None,
  1027. "media_type": cls.MediaType.GEO_LOCATION,
  1028. "media_id": location.id,
  1029. "scrobble_data": scrobble_data,
  1030. },
  1031. )
  1032. if not scrobble:
  1033. logger.info(
  1034. f"[scrobbling] finished - no existing location scrobbles found",
  1035. extra={
  1036. "media_id": location.id,
  1037. "media_type": cls.MediaType.GEO_LOCATION,
  1038. },
  1039. )
  1040. return cls.create(scrobble_data)
  1041. if scrobble.media_obj == location:
  1042. logger.info(
  1043. f"[scrobbling] finished - same location - not moved",
  1044. extra={
  1045. "media_type": cls.MediaType.GEO_LOCATION,
  1046. "media_id": location.id,
  1047. "scrobble_id": scrobble.id,
  1048. "scrobble_media_id": scrobble.media_obj.id,
  1049. },
  1050. )
  1051. return scrobble
  1052. has_moved = location.has_moved(scrobble.media_obj)
  1053. logger.info(
  1054. f"[scrobbling] checking - has last location has moved?",
  1055. extra={
  1056. "scrobble_id": scrobble.id,
  1057. "scrobble_media_id": scrobble.media_obj.id,
  1058. "media_type": cls.MediaType.GEO_LOCATION,
  1059. "media_id": location.id,
  1060. "has_moved": has_moved,
  1061. },
  1062. )
  1063. if not has_moved:
  1064. logger.info(
  1065. f"[scrobbling] finished - not from old location - not moved",
  1066. extra={
  1067. "scrobble_id": scrobble.id,
  1068. "media_id": location.id,
  1069. "media_type": cls.MediaType.GEO_LOCATION,
  1070. "old_media__id": scrobble.media_obj.id,
  1071. },
  1072. )
  1073. return scrobble
  1074. if existing_locations := location.in_proximity(named=True):
  1075. existing_location = existing_locations.first()
  1076. ts = int(pendulum.now().timestamp())
  1077. scrobble.log[
  1078. ts
  1079. ] = f"Location {location.id} too close to this scrobble"
  1080. scrobble.save(update_fields=["log"])
  1081. logger.info(
  1082. f"[scrobbling] finished - found existing named location",
  1083. extra={
  1084. "media_id": location.id,
  1085. "media_type": cls.MediaType.GEO_LOCATION,
  1086. "old_media_id": existing_location.id,
  1087. },
  1088. )
  1089. return scrobble
  1090. scrobble.stop(force_finish=True)
  1091. scrobble = cls.create(scrobble_data)
  1092. logger.info(
  1093. f"[scrobbling] finished - created for location",
  1094. extra={
  1095. "scrobble_id": scrobble.id,
  1096. "media_id": location.id,
  1097. "scrobble_data": scrobble_data,
  1098. "media_type": cls.MediaType.GEO_LOCATION,
  1099. "source": scrobble_data.get("source"),
  1100. },
  1101. )
  1102. return scrobble
  1103. def update(self, scrobble_data: dict) -> "Scrobble":
  1104. # Status is a field we get from Mopidy, which refuses to poll us
  1105. playback_status = scrobble_data.pop("playback_status", None)
  1106. logger.info(
  1107. "[update] called",
  1108. extra={
  1109. "scrobble_id": self.id,
  1110. "scrobble_data": scrobble_data,
  1111. "media_type": self.media_type,
  1112. "playback_status": playback_status,
  1113. },
  1114. )
  1115. if self.beyond_completion_percent:
  1116. playback_status = "stopped"
  1117. if playback_status == "stopped":
  1118. self.stop()
  1119. if playback_status == "paused":
  1120. self.pause()
  1121. if playback_status == "resumed":
  1122. self.resume()
  1123. if playback_status != "resumed":
  1124. scrobble_data["stop_timestamp"] = (
  1125. scrobble_data.pop("timestamp", None) or timezone.now()
  1126. )
  1127. # timestamp should be more-or-less immutable
  1128. scrobble_data.pop("timestamp", None)
  1129. update_fields = []
  1130. for key, value in scrobble_data.items():
  1131. setattr(self, key, value)
  1132. update_fields.append(key)
  1133. self.save(update_fields=update_fields)
  1134. return self
  1135. @classmethod
  1136. def create(
  1137. cls,
  1138. scrobble_data: dict,
  1139. ) -> "Scrobble":
  1140. scrobble = cls.objects.create(**scrobble_data)
  1141. ScrobbleNtfyNotification(scrobble).send()
  1142. return scrobble
  1143. def stop(self, timestamp=None, force_finish=False) -> None:
  1144. self.stop_timestamp = timestamp or timezone.now()
  1145. self.played_to_completion = True
  1146. self.in_progress = False
  1147. if not self.playback_position_seconds:
  1148. self.playback_position_seconds = int(
  1149. (self.stop_timestamp - self.timestamp).total_seconds()
  1150. )
  1151. self.save(
  1152. update_fields=[
  1153. "in_progress",
  1154. "played_to_completion",
  1155. "stop_timestamp",
  1156. "playback_position_seconds",
  1157. ]
  1158. )
  1159. class_name = self.media_obj.__class__.__name__
  1160. if class_name in LONG_PLAY_MEDIA.values():
  1161. self.finish_long_play()
  1162. logger.info(
  1163. f"[scrobbling] stopped",
  1164. extra={
  1165. "scrobble_id": self.id,
  1166. "media_id": self.media_obj.id,
  1167. "media_type": self.media_type,
  1168. "source": self.source,
  1169. },
  1170. )
  1171. def pause(self) -> None:
  1172. if self.is_paused:
  1173. logger.warning(f"{self.id} - already paused - {self.source}")
  1174. return
  1175. self.is_paused = True
  1176. self.save(update_fields=["is_paused"])
  1177. logger.info(
  1178. f"[scrobbling] paused",
  1179. extra={
  1180. "scrobble_id": self.id,
  1181. "media_type": self.media_type,
  1182. "source": self.source,
  1183. },
  1184. )
  1185. def resume(self) -> None:
  1186. if self.is_paused or not self.in_progress:
  1187. self.is_paused = False
  1188. self.in_progress = True
  1189. self.save(update_fields=["is_paused", "in_progress"])
  1190. logger.info(
  1191. f"[scrobbling] resumed",
  1192. extra={
  1193. "scrobble_id": self.id,
  1194. "media_type": self.media_type,
  1195. "source": self.source,
  1196. },
  1197. )
  1198. def cancel(self) -> None:
  1199. self.delete()
  1200. def update_ticks(self, data) -> None:
  1201. self.playback_position_seconds = data.get("playback_position_seconds")
  1202. self.save(update_fields=["playback_position_seconds"])
  1203. def finish_long_play(self):
  1204. seconds_elapsed = (timezone.now() - self.timestamp).seconds
  1205. past_seconds = 0
  1206. # Set our playback seconds, and calc long play seconds
  1207. self.playback_position_seconds = seconds_elapsed
  1208. if self.previous:
  1209. past_seconds = self.previous.long_play_seconds or 0
  1210. self.long_play_seconds = past_seconds + seconds_elapsed
  1211. # Long play scrobbles are always finished when we say they are
  1212. self.played_to_completion = True
  1213. self.save(
  1214. update_fields=[
  1215. "playback_position_seconds",
  1216. "played_to_completion",
  1217. "long_play_seconds",
  1218. ]
  1219. )
  1220. logger.info(
  1221. f"[scrobbling] finishing long play",
  1222. extra={
  1223. "scrobble_id": self.id,
  1224. },
  1225. )
  1226. @property
  1227. def beyond_completion_percent(self) -> bool:
  1228. """Returns true if our media is beyond our completion percent, unless
  1229. our type is geolocation in which case we always return false
  1230. """
  1231. beyond_completion = (
  1232. self.percent_played >= self.media_obj.COMPLETION_PERCENT
  1233. )
  1234. if self.media_type == "GeoLocation":
  1235. logger.info(
  1236. f"[scrobbling] locations are ONLY completed when new one is created",
  1237. extra={
  1238. "scrobble_id": self.id,
  1239. "media_type": self.media_type,
  1240. "beyond_completion": beyond_completion,
  1241. },
  1242. )
  1243. beyond_completion = False
  1244. return beyond_completion