models.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import calendar
  2. import logging
  3. from uuid import uuid4
  4. from django.contrib.auth import get_user_model
  5. from django.db import models
  6. from django.utils import timezone
  7. from django_extensions.db.models import TimeStampedModel
  8. from music.models import Artist, Track
  9. from podcasts.models import Episode
  10. from scrobbles.utils import check_scrobble_for_finish
  11. from sports.models import SportEvent
  12. from videos.models import Series, Video
  13. from vrobbler.apps.profiles.utils import now_user_timezone
  14. logger = logging.getLogger(__name__)
  15. User = get_user_model()
  16. BNULL = {"blank": True, "null": True}
  17. class AudioScrobblerTSVImport(TimeStampedModel):
  18. def get_path(instance, filename):
  19. extension = filename.split('.')[-1]
  20. uuid = instance.uuid
  21. return f'audioscrobbler-uploads/{uuid}.{extension}'
  22. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  23. uuid = models.UUIDField(editable=False, default=uuid4)
  24. tsv_file = models.FileField(upload_to=get_path, **BNULL)
  25. processed_on = models.DateTimeField(**BNULL)
  26. process_log = models.TextField(**BNULL)
  27. process_count = models.IntegerField(**BNULL)
  28. def __str__(self):
  29. if self.tsv_file:
  30. return f"Audioscrobbler TSV upload: {self.tsv_file.path}"
  31. return f"Audioscrobbler TSV upload {self.id}"
  32. def save(self, **kwargs):
  33. """On save, attempt to import the TSV file"""
  34. super().save(**kwargs)
  35. self.process()
  36. return
  37. def process(self, force=False):
  38. from scrobbles.tsv import process_audioscrobbler_tsv_file
  39. if self.processed_on and not force:
  40. logger.info(f"{self} already processed on {self.processed_on}")
  41. return
  42. tz = None
  43. if self.user:
  44. tz = self.user.profile.tzinfo
  45. scrobbles = process_audioscrobbler_tsv_file(
  46. self.tsv_file.path, user_tz=tz
  47. )
  48. if scrobbles:
  49. self.process_log = f"Created {len(scrobbles)} scrobbles"
  50. for scrobble in scrobbles:
  51. scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.track.title}"
  52. self.process_log += f"\n{scrobble_str}"
  53. self.process_count = len(scrobbles)
  54. else:
  55. self.process_log = f"Created no new scrobbles"
  56. self.process_count = 0
  57. self.processed_on = timezone.now()
  58. self.save(
  59. update_fields=['processed_on', 'process_count', 'process_log']
  60. )
  61. def undo(self, dryrun=True):
  62. from scrobbles.tsv import undo_audioscrobbler_tsv_import
  63. undo_audioscrobbler_tsv_import(self.process_log, dryrun)
  64. class ChartRecord(TimeStampedModel):
  65. """Sort of like a materialized view for what we could dynamically generate,
  66. but would kill the DB as it gets larger. Collects time-based records
  67. generated by a cron-like archival job
  68. 1972 by Josh Rouse - #3 in 2023, January
  69. """
  70. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  71. rank = models.IntegerField()
  72. year = models.IntegerField(default=timezone.now().year)
  73. month = models.IntegerField(**BNULL)
  74. week = models.IntegerField(**BNULL)
  75. day = models.IntegerField(**BNULL)
  76. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  77. series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  78. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
  79. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  80. @property
  81. def media_obj(self):
  82. media_obj = None
  83. if self.video:
  84. media_obj = self.video
  85. if self.track:
  86. media_obj = self.track
  87. return media_obj
  88. @property
  89. def month_str(self) -> str:
  90. month_str = ""
  91. if self.month:
  92. month_str = calendar.month_name[self.month]
  93. return month_str
  94. @property
  95. def day_str(self) -> str:
  96. day_str = ""
  97. if self.day:
  98. day_str = str(self.day)
  99. return day_str
  100. @property
  101. def week_str(self) -> str:
  102. week_str = ""
  103. if self.week:
  104. week_str = str(self.week)
  105. return "Week " + week_str
  106. @property
  107. def period(self) -> str:
  108. period = str(self.year)
  109. if self.month:
  110. period = " ".join([self.month_str, period])
  111. if self.week:
  112. period = " ".join([self.week_str, period])
  113. if self.day:
  114. period = " ".join([self.day_str, period])
  115. return period
  116. @property
  117. def period_type(self) -> str:
  118. period = 'year'
  119. if self.month:
  120. period = 'month'
  121. if self.week:
  122. period = 'week'
  123. if self.day:
  124. period = 'day'
  125. return period
  126. def __str__(self):
  127. return f"#{self.rank} in {self.period} - {self.media_obj}"
  128. class Scrobble(TimeStampedModel):
  129. """A scrobble tracks played media items by a user."""
  130. uuid = models.UUIDField(editable=False, **BNULL)
  131. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  132. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  133. podcast_episode = models.ForeignKey(
  134. Episode, on_delete=models.DO_NOTHING, **BNULL
  135. )
  136. sport_event = models.ForeignKey(
  137. SportEvent, on_delete=models.DO_NOTHING, **BNULL
  138. )
  139. user = models.ForeignKey(
  140. User, blank=True, null=True, on_delete=models.DO_NOTHING
  141. )
  142. timestamp = models.DateTimeField(**BNULL)
  143. playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
  144. playback_position = models.CharField(max_length=8, **BNULL)
  145. is_paused = models.BooleanField(default=False)
  146. played_to_completion = models.BooleanField(default=False)
  147. source = models.CharField(max_length=255, **BNULL)
  148. source_id = models.TextField(**BNULL)
  149. in_progress = models.BooleanField(default=True)
  150. scrobble_log = models.TextField(**BNULL)
  151. def save(self, *args, **kwargs):
  152. if not self.uuid:
  153. self.uuid = uuid4()
  154. return super(Scrobble, self).save(*args, **kwargs)
  155. @property
  156. def status(self) -> str:
  157. if self.is_paused:
  158. return 'paused'
  159. if self.played_to_completion:
  160. return 'finished'
  161. if self.in_progress:
  162. return 'in-progress'
  163. return 'zombie'
  164. @property
  165. def is_stale(self) -> bool:
  166. """Mark scrobble as stale if it's been more than an hour since it was updated"""
  167. is_stale = False
  168. now = timezone.now()
  169. seconds_since_last_update = (now - self.modified).seconds
  170. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  171. is_stale = True
  172. return is_stale
  173. @property
  174. def percent_played(self) -> int:
  175. if not self.media_obj.run_time_ticks:
  176. return 100
  177. if not self.playback_position_ticks and self.played_to_completion:
  178. return 100
  179. playback_ticks = self.playback_position_ticks
  180. if not playback_ticks:
  181. playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
  182. percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
  183. if percent > 100:
  184. percent = 100
  185. return percent
  186. @property
  187. def can_be_updated(self) -> bool:
  188. updatable = True
  189. if self.percent_played > 100:
  190. logger.info(f"No - 100% played - {self.id} - {self.source}")
  191. updatable = False
  192. if self.is_stale:
  193. logger.info(f"No - stale - {self.id} - {self.source}")
  194. updatable = False
  195. return updatable
  196. @property
  197. def media_obj(self):
  198. media_obj = None
  199. if self.video:
  200. media_obj = self.video
  201. if self.track:
  202. media_obj = self.track
  203. if self.podcast_episode:
  204. media_obj = self.podcast_episode
  205. if self.sport_event:
  206. media_obj = self.sport_event
  207. return media_obj
  208. def __str__(self):
  209. timestamp = self.timestamp.strftime('%Y-%m-%d')
  210. return f"Scrobble of {self.media_obj} ({timestamp})"
  211. @classmethod
  212. def create_or_update(
  213. cls, media, user_id: int, scrobble_data: dict
  214. ) -> "Scrobble":
  215. if media.__class__.__name__ == 'Track':
  216. media_query = models.Q(track=media)
  217. scrobble_data['track_id'] = media.id
  218. if media.__class__.__name__ == 'Video':
  219. media_query = models.Q(video=media)
  220. scrobble_data['video_id'] = media.id
  221. if media.__class__.__name__ == 'Episode':
  222. media_query = models.Q(podcast_episode=media)
  223. scrobble_data['podcast_episode_id'] = media.id
  224. if media.__class__.__name__ == 'SportEvent':
  225. media_query = models.Q(sport_event=media)
  226. scrobble_data['sport_event_id'] = media.id
  227. scrobble = (
  228. cls.objects.filter(
  229. media_query,
  230. user_id=user_id,
  231. )
  232. .order_by('-modified')
  233. .first()
  234. )
  235. if scrobble and scrobble.can_be_updated:
  236. logger.info(
  237. f"Updating {scrobble.id}",
  238. {"scrobble_data": scrobble_data, "media": media},
  239. )
  240. return scrobble.update(scrobble_data)
  241. source = scrobble_data['source']
  242. logger.info(
  243. f"Creating for {media.id} - {source}",
  244. {"scrobble_data": scrobble_data, "media": media},
  245. )
  246. # If creating a new scrobble, we don't need status
  247. scrobble_data.pop('mopidy_status', None)
  248. scrobble_data.pop('jellyfin_status', None)
  249. return cls.create(scrobble_data)
  250. def update(self, scrobble_data: dict) -> "Scrobble":
  251. # Status is a field we get from Mopidy, which refuses to poll us
  252. scrobble_status = scrobble_data.pop('mopidy_status', None)
  253. if not scrobble_status:
  254. scrobble_status = scrobble_data.pop('jellyfin_status', None)
  255. if self.percent_played < 100:
  256. # Only worry about ticks if we haven't gotten to the end
  257. self.update_ticks(scrobble_data)
  258. # On stop, stop progress and send it to the check for completion
  259. if scrobble_status == "stopped":
  260. self.stop()
  261. # On pause, set is_paused and stop scrobbling
  262. if scrobble_status == "paused":
  263. self.pause()
  264. if scrobble_status == "resumed":
  265. self.resume()
  266. for key, value in scrobble_data.items():
  267. setattr(self, key, value)
  268. self.save()
  269. return self
  270. @classmethod
  271. def create(
  272. cls,
  273. scrobble_data: dict,
  274. ) -> "Scrobble":
  275. scrobble_data['scrobble_log'] = ""
  276. scrobble = cls.objects.create(
  277. **scrobble_data,
  278. )
  279. return scrobble
  280. def stop(self, force_finish=False) -> None:
  281. if not self.in_progress:
  282. return
  283. self.in_progress = False
  284. self.save(update_fields=['in_progress'])
  285. logger.info(f"{self.id} - {self.source}")
  286. check_scrobble_for_finish(self, force_finish)
  287. def pause(self) -> None:
  288. if self.is_paused:
  289. logger.warning(f"{self.id} - already paused - {self.source}")
  290. return
  291. self.is_paused = True
  292. self.save(update_fields=["is_paused"])
  293. logger.info(f"{self.id} - pausing - {self.source}")
  294. check_scrobble_for_finish(self)
  295. def resume(self) -> None:
  296. if self.is_paused or not self.in_progress:
  297. self.is_paused = False
  298. self.in_progress = True
  299. logger.info(f"{self.id} - resuming - {self.source}")
  300. return self.save(update_fields=["is_paused", "in_progress"])
  301. def cancel(self) -> None:
  302. check_scrobble_for_finish(self, force_finish=True)
  303. self.delete()
  304. def update_ticks(self, data) -> None:
  305. self.playback_position_ticks = data.get("playback_position_ticks")
  306. self.playback_position = data.get("playback_position")
  307. logger.info(
  308. f"{self.id} - {self.playback_position_ticks} - {self.source}"
  309. )
  310. self.save(
  311. update_fields=['playback_position_ticks', 'playback_position']
  312. )