models.py 39 KB

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