models.py 32 KB

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