models.py 21 KB

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