models.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import calendar
  2. import logging
  3. from uuid import uuid4
  4. from books.models import Book
  5. from django.contrib.auth import get_user_model
  6. from django.db import models
  7. from django.urls import reverse
  8. from django.utils import timezone
  9. from django_extensions.db.models import TimeStampedModel
  10. from music.models import Artist, Track
  11. from podcasts.models import Episode
  12. from scrobbles.lastfm import LastFM
  13. from scrobbles.utils import check_scrobble_for_finish
  14. from sports.models import SportEvent
  15. from videos.models import Series, Video
  16. from vrobbler.apps.scrobbles.stats import build_charts
  17. logger = logging.getLogger(__name__)
  18. User = get_user_model()
  19. BNULL = {"blank": True, "null": True}
  20. class BaseFileImportMixin(TimeStampedModel):
  21. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  22. uuid = models.UUIDField(editable=False, default=uuid4)
  23. processing_started = models.DateTimeField(**BNULL)
  24. processed_finished = models.DateTimeField(**BNULL)
  25. process_log = models.TextField(**BNULL)
  26. process_count = models.IntegerField(**BNULL)
  27. class Meta:
  28. abstract = True
  29. def __str__(self):
  30. return f"Scrobble import {self.id}"
  31. @property
  32. def human_start(self):
  33. start = "Unknown"
  34. if self.processing_started:
  35. start = self.processing_started.strftime('%B %d, %Y at %H:%M')
  36. return start
  37. @property
  38. def import_type(self) -> str:
  39. class_name = self.__class__.__name__
  40. if class_name == 'AudioscrobblerTSVImport':
  41. return "Audioscrobbler"
  42. if class_name == 'KoReaderImport':
  43. return "KoReader"
  44. if self.__class__.__name__ == 'LastFMImport':
  45. return "LastFM"
  46. return "Generic"
  47. def process(self, force=False):
  48. logger.warning("Process not implemented")
  49. def undo(self, dryrun=False):
  50. """Accepts the log from a scrobble import and removes the scrobbles"""
  51. from scrobbles.models import Scrobble
  52. if not self.process_log:
  53. logger.warning("No lines in process log found to undo")
  54. return
  55. for line in self.process_log.split('\n'):
  56. scrobble_id = line.split("\t")[0]
  57. scrobble = Scrobble.objects.filter(id=scrobble_id).first()
  58. if not scrobble:
  59. logger.warning(
  60. f"Could not find scrobble {scrobble_id} to undo"
  61. )
  62. continue
  63. logger.info(f"Removing scrobble {scrobble_id}")
  64. if not dryrun:
  65. scrobble.delete()
  66. self.processed_finished = None
  67. self.processing_started = None
  68. self.process_count = None
  69. self.process_log = ""
  70. self.save(
  71. update_fields=[
  72. "processed_finished",
  73. "processing_started",
  74. "process_log",
  75. "process_count",
  76. ]
  77. )
  78. def mark_started(self):
  79. self.processing_started = timezone.now()
  80. self.save(update_fields=["processing_started"])
  81. def mark_finished(self):
  82. self.processed_finished = timezone.now()
  83. self.save(update_fields=['processed_finished'])
  84. def record_log(self, scrobbles):
  85. self.process_log = ""
  86. if not scrobbles:
  87. self.process_count = 0
  88. self.save(update_fields=["process_log", "process_count"])
  89. return
  90. for count, scrobble in enumerate(scrobbles):
  91. scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
  92. log_line = f"{scrobble_str}"
  93. if count > 0:
  94. log_line = "\n" + log_line
  95. self.process_log += log_line
  96. self.process_count = len(scrobbles)
  97. self.save(update_fields=["process_log", "process_count"])
  98. class KoReaderImport(BaseFileImportMixin):
  99. class Meta:
  100. verbose_name = "KOReader Import"
  101. def __str__(self):
  102. return f"KoReader import on {self.human_start}"
  103. def get_absolute_url(self):
  104. return reverse(
  105. 'scrobbles:koreader-import-detail', kwargs={'slug': self.uuid}
  106. )
  107. def get_path(instance, filename):
  108. extension = filename.split('.')[-1]
  109. uuid = instance.uuid
  110. return f'koreader-uploads/{uuid}.{extension}'
  111. sqlite_file = models.FileField(upload_to=get_path, **BNULL)
  112. def process(self, force=False):
  113. from scrobbles.koreader import process_koreader_sqlite_file
  114. if self.processed_finished and not force:
  115. logger.info(
  116. f"{self} already processed on {self.processed_finished}"
  117. )
  118. return
  119. self.mark_started()
  120. scrobbles = process_koreader_sqlite_file(
  121. self.sqlite_file.path, self.user.id
  122. )
  123. self.record_log(scrobbles)
  124. self.mark_finished()
  125. class AudioScrobblerTSVImport(BaseFileImportMixin):
  126. class Meta:
  127. verbose_name = "AudioScrobbler TSV Import"
  128. def __str__(self):
  129. return f"Audioscrobbler import on {self.human_start}"
  130. def get_absolute_url(self):
  131. return reverse(
  132. 'scrobbles:tsv-import-detail', kwargs={'slug': self.uuid}
  133. )
  134. def get_path(instance, filename):
  135. extension = filename.split('.')[-1]
  136. uuid = instance.uuid
  137. return f'audioscrobbler-uploads/{uuid}.{extension}'
  138. tsv_file = models.FileField(upload_to=get_path, **BNULL)
  139. def process(self, force=False):
  140. from scrobbles.tsv import process_audioscrobbler_tsv_file
  141. if self.processed_finished and not force:
  142. logger.info(
  143. f"{self} already processed on {self.processed_finished}"
  144. )
  145. return
  146. self.mark_started()
  147. tz = None
  148. if self.user:
  149. tz = self.user.profile.tzinfo
  150. scrobbles = process_audioscrobbler_tsv_file(
  151. self.tsv_file.path, self.user.id, user_tz=tz
  152. )
  153. self.record_log(scrobbles)
  154. self.mark_finished()
  155. class LastFmImport(BaseFileImportMixin):
  156. class Meta:
  157. verbose_name = "Last.FM Import"
  158. def __str__(self):
  159. return f"LastFM import on {self.human_start}"
  160. def get_absolute_url(self):
  161. return reverse(
  162. 'scrobbles:lastfm-import-detail', kwargs={'slug': self.uuid}
  163. )
  164. def process(self, import_all=False):
  165. """Import scrobbles found on LastFM"""
  166. if self.processed_finished:
  167. logger.info(
  168. f"{self} already processed on {self.processed_finished}"
  169. )
  170. return
  171. last_import = None
  172. if not import_all:
  173. try:
  174. last_import = LastFmImport.objects.exclude(id=self.id).last()
  175. except:
  176. pass
  177. if not import_all and not last_import:
  178. logger.warn(
  179. "No previous import, to import all Last.fm scrobbles, pass import_all=True"
  180. )
  181. return
  182. lastfm = LastFM(self.user)
  183. last_processed = None
  184. if last_import:
  185. last_processed = last_import.processed_finished
  186. self.mark_started()
  187. scrobbles = lastfm.import_from_lastfm(last_processed)
  188. self.record_log(scrobbles)
  189. self.mark_finished()
  190. class ChartRecord(TimeStampedModel):
  191. """Sort of like a materialized view for what we could dynamically generate,
  192. but would kill the DB as it gets larger. Collects time-based records
  193. generated by a cron-like archival job
  194. 1972 by Josh Rouse - #3 in 2023, January
  195. """
  196. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  197. rank = models.IntegerField()
  198. count = models.IntegerField(default=0)
  199. year = models.IntegerField(default=timezone.now().year)
  200. month = models.IntegerField(**BNULL)
  201. week = models.IntegerField(**BNULL)
  202. day = models.IntegerField(**BNULL)
  203. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  204. series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  205. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
  206. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  207. @property
  208. def media_obj(self):
  209. media_obj = None
  210. if self.video:
  211. media_obj = self.video
  212. if self.track:
  213. media_obj = self.track
  214. if self.artist:
  215. media_obj = self.artist
  216. return media_obj
  217. @property
  218. def month_str(self) -> str:
  219. month_str = ""
  220. if self.month:
  221. month_str = calendar.month_name[self.month]
  222. return month_str
  223. @property
  224. def day_str(self) -> str:
  225. day_str = ""
  226. if self.day:
  227. day_str = str(self.day)
  228. return day_str
  229. @property
  230. def week_str(self) -> str:
  231. week_str = ""
  232. if self.week:
  233. week_str = str(self.week)
  234. return "Week " + week_str
  235. @property
  236. def period(self) -> str:
  237. period = str(self.year)
  238. if self.month:
  239. period = " ".join([self.month_str, period])
  240. if self.week:
  241. period = " ".join([self.week_str, period])
  242. if self.day:
  243. period = " ".join([self.day_str, period])
  244. return period
  245. @property
  246. def period_type(self) -> str:
  247. period = 'year'
  248. if self.month:
  249. period = 'month'
  250. if self.week:
  251. period = 'week'
  252. if self.day:
  253. period = 'day'
  254. return period
  255. def __str__(self):
  256. title = f"#{self.rank} in {self.period}"
  257. if self.day or self.week:
  258. title = f"#{self.rank} on {self.period}"
  259. return title
  260. def link(self):
  261. get_params = f"?date={self.year}"
  262. if self.week:
  263. get_params = get_params = get_params + f"-W{self.week}"
  264. if self.month:
  265. get_params = get_params = get_params + f"-{self.month}"
  266. if self.day:
  267. get_params = get_params = get_params + f"-{self.day}"
  268. if self.artist:
  269. get_params = get_params + "&media=Artist"
  270. return reverse('scrobbles:charts-home') + get_params
  271. @classmethod
  272. def build(cls, user, **kwargs):
  273. build_charts(user=user, **kwargs)
  274. @classmethod
  275. def for_year(cls, user, year):
  276. return cls.objects.filter(year=year, user=user)
  277. @classmethod
  278. def for_month(cls, user, year, month):
  279. return cls.objects.filter(year=year, month=month, user=user)
  280. @classmethod
  281. def for_day(cls, user, year, day, month):
  282. return cls.objects.filter(year=year, month=month, day=day, user=user)
  283. @classmethod
  284. def for_week(cls, user, year, week):
  285. return cls.objects.filter(year=year, week=week, user=user)
  286. class Scrobble(TimeStampedModel):
  287. """A scrobble tracks played media items by a user."""
  288. uuid = models.UUIDField(editable=False, **BNULL)
  289. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  290. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  291. podcast_episode = models.ForeignKey(
  292. Episode, on_delete=models.DO_NOTHING, **BNULL
  293. )
  294. sport_event = models.ForeignKey(
  295. SportEvent, on_delete=models.DO_NOTHING, **BNULL
  296. )
  297. book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
  298. user = models.ForeignKey(
  299. User, blank=True, null=True, on_delete=models.DO_NOTHING
  300. )
  301. # Time keeping
  302. timestamp = models.DateTimeField(**BNULL)
  303. playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
  304. playback_position = models.CharField(max_length=8, **BNULL)
  305. # Status indicators
  306. is_paused = models.BooleanField(default=False)
  307. played_to_completion = models.BooleanField(default=False)
  308. in_progress = models.BooleanField(default=True)
  309. # Metadata
  310. source = models.CharField(max_length=255, **BNULL)
  311. source_id = models.TextField(**BNULL)
  312. scrobble_log = models.TextField(**BNULL)
  313. # Fields for keeping track of reads between scrobbles
  314. book_pages_read = models.IntegerField(**BNULL)
  315. def save(self, *args, **kwargs):
  316. if not self.uuid:
  317. self.uuid = uuid4()
  318. return super(Scrobble, self).save(*args, **kwargs)
  319. @property
  320. def status(self) -> str:
  321. if self.is_paused:
  322. return 'paused'
  323. if self.played_to_completion:
  324. return 'finished'
  325. if self.in_progress:
  326. return 'in-progress'
  327. return 'zombie'
  328. @property
  329. def is_stale(self) -> bool:
  330. """Mark scrobble as stale if it's been more than an hour since it was updated"""
  331. is_stale = False
  332. now = timezone.now()
  333. seconds_since_last_update = (now - self.modified).seconds
  334. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  335. is_stale = True
  336. return is_stale
  337. @property
  338. def percent_played(self) -> int:
  339. if not self.media_obj:
  340. return 0
  341. if self.media_obj and not self.media_obj.run_time_ticks:
  342. return 100
  343. if not self.playback_position_ticks and self.played_to_completion:
  344. return 100
  345. playback_ticks = self.playback_position_ticks
  346. if not playback_ticks:
  347. playback_ticks = (timezone.now() - self.timestamp).seconds * 1000
  348. percent = int((playback_ticks / self.media_obj.run_time_ticks) * 100)
  349. if percent > 100:
  350. percent = 100
  351. return percent
  352. @property
  353. def can_be_updated(self) -> bool:
  354. updatable = True
  355. if self.percent_played > 100:
  356. logger.info(f"No - 100% played - {self.id} - {self.source}")
  357. updatable = False
  358. if self.is_stale:
  359. logger.info(f"No - stale - {self.id} - {self.source}")
  360. updatable = False
  361. return updatable
  362. @property
  363. def media_obj(self):
  364. media_obj = None
  365. if self.video:
  366. media_obj = self.video
  367. if self.track:
  368. media_obj = self.track
  369. if self.podcast_episode:
  370. media_obj = self.podcast_episode
  371. if self.sport_event:
  372. media_obj = self.sport_event
  373. if self.book:
  374. media_obj = self.book
  375. return media_obj
  376. def __str__(self):
  377. timestamp = self.timestamp.strftime('%Y-%m-%d')
  378. return f"Scrobble of {self.media_obj} ({timestamp})"
  379. @classmethod
  380. def create_or_update(
  381. cls, media, user_id: int, scrobble_data: dict
  382. ) -> "Scrobble":
  383. if media.__class__.__name__ == 'Track':
  384. media_query = models.Q(track=media)
  385. scrobble_data['track_id'] = media.id
  386. if media.__class__.__name__ == 'Video':
  387. media_query = models.Q(video=media)
  388. scrobble_data['video_id'] = media.id
  389. if media.__class__.__name__ == 'Episode':
  390. media_query = models.Q(podcast_episode=media)
  391. scrobble_data['podcast_episode_id'] = media.id
  392. if media.__class__.__name__ == 'SportEvent':
  393. media_query = models.Q(sport_event=media)
  394. scrobble_data['sport_event_id'] = media.id
  395. if media.__class__.__name__ == 'Book':
  396. media_query = models.Q(book=media)
  397. scrobble_data['book_id'] = media.id
  398. scrobble = (
  399. cls.objects.filter(
  400. media_query,
  401. user_id=user_id,
  402. )
  403. .order_by('-modified')
  404. .first()
  405. )
  406. if scrobble and scrobble.can_be_updated:
  407. logger.info(
  408. f"Updating {scrobble.id}",
  409. {"scrobble_data": scrobble_data, "media": media},
  410. )
  411. return scrobble.update(scrobble_data)
  412. source = scrobble_data['source']
  413. logger.info(
  414. f"Creating for {media.id} - {source}",
  415. {"scrobble_data": scrobble_data, "media": media},
  416. )
  417. # If creating a new scrobble, we don't need status
  418. scrobble_data.pop('mopidy_status', None)
  419. scrobble_data.pop('jellyfin_status', None)
  420. return cls.create(scrobble_data)
  421. def update(self, scrobble_data: dict) -> "Scrobble":
  422. # Status is a field we get from Mopidy, which refuses to poll us
  423. scrobble_status = scrobble_data.pop('mopidy_status', None)
  424. if not scrobble_status:
  425. scrobble_status = scrobble_data.pop('jellyfin_status', None)
  426. if self.percent_played < 100:
  427. # Only worry about ticks if we haven't gotten to the end
  428. self.update_ticks(scrobble_data)
  429. # On stop, stop progress and send it to the check for completion
  430. if scrobble_status == "stopped":
  431. self.stop()
  432. # On pause, set is_paused and stop scrobbling
  433. if scrobble_status == "paused":
  434. self.pause()
  435. if scrobble_status == "resumed":
  436. self.resume()
  437. for key, value in scrobble_data.items():
  438. setattr(self, key, value)
  439. self.save()
  440. return self
  441. @classmethod
  442. def create(
  443. cls,
  444. scrobble_data: dict,
  445. ) -> "Scrobble":
  446. scrobble_data['scrobble_log'] = ""
  447. scrobble = cls.objects.create(
  448. **scrobble_data,
  449. )
  450. return scrobble
  451. def stop(self, force_finish=False) -> None:
  452. if not self.in_progress:
  453. return
  454. self.in_progress = False
  455. self.save(update_fields=['in_progress'])
  456. logger.info(f"{self.id} - {self.source}")
  457. check_scrobble_for_finish(self, force_finish)
  458. def pause(self) -> None:
  459. if self.is_paused:
  460. logger.warning(f"{self.id} - already paused - {self.source}")
  461. return
  462. self.is_paused = True
  463. self.save(update_fields=["is_paused"])
  464. logger.info(f"{self.id} - pausing - {self.source}")
  465. check_scrobble_for_finish(self)
  466. def resume(self) -> None:
  467. if self.is_paused or not self.in_progress:
  468. self.is_paused = False
  469. self.in_progress = True
  470. logger.info(f"{self.id} - resuming - {self.source}")
  471. return self.save(update_fields=["is_paused", "in_progress"])
  472. def cancel(self) -> None:
  473. check_scrobble_for_finish(self, force_finish=True)
  474. self.delete()
  475. def update_ticks(self, data) -> None:
  476. self.playback_position_ticks = data.get("playback_position_ticks")
  477. self.playback_position = data.get("playback_position")
  478. logger.info(
  479. f"{self.id} - {self.playback_position_ticks} - {self.source}"
  480. )
  481. self.save(
  482. update_fields=['playback_position_ticks', 'playback_position']
  483. )