models.py 12 KB

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