models.py 14 KB

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