models.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994
  1. import calendar
  2. import datetime
  3. from decimal import Decimal
  4. import logging
  5. from typing import Optional
  6. from uuid import uuid4
  7. from boardgames.models import BoardGame
  8. from books.koreader import process_koreader_sqlite_file
  9. from books.models import Book
  10. from django.conf import settings
  11. from django.contrib.auth import get_user_model
  12. from django.db import models
  13. from django.urls import reverse
  14. from django.utils import timezone
  15. from django.utils.functional import cached_property
  16. from django_extensions.db.models import TimeStampedModel
  17. from imagekit.models import ImageSpecField
  18. from imagekit.processors import ResizeToFit
  19. from locations.models import GeoLocation
  20. from music.lastfm import LastFM
  21. from music.models import Artist, Track
  22. from podcasts.models import PodcastEpisode
  23. from profiles.utils import (
  24. end_of_day,
  25. end_of_month,
  26. end_of_week,
  27. start_of_day,
  28. start_of_month,
  29. start_of_week,
  30. )
  31. from scrobbles.constants import LONG_PLAY_MEDIA
  32. from scrobbles.stats import build_charts
  33. from scrobbles.utils import (
  34. check_long_play_for_finish,
  35. check_scrobble_for_finish,
  36. media_class_to_foreign_key,
  37. )
  38. from sports.models import SportEvent
  39. from videogames import retroarch
  40. from videogames.models import VideoGame
  41. from videos.models import Series, Video
  42. from webpages.models import WebPage
  43. logger = logging.getLogger(__name__)
  44. User = get_user_model()
  45. BNULL = {"blank": True, "null": True}
  46. POINTS_FOR_MOVEMENT_HISTORY = int(
  47. getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3)
  48. )
  49. class BaseFileImportMixin(TimeStampedModel):
  50. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  51. uuid = models.UUIDField(editable=False, default=uuid4)
  52. processing_started = models.DateTimeField(**BNULL)
  53. processed_finished = models.DateTimeField(**BNULL)
  54. process_log = models.TextField(**BNULL)
  55. process_count = models.IntegerField(**BNULL)
  56. class Meta:
  57. abstract = True
  58. def __str__(self):
  59. return f"{self.import_type} import on {self.human_start}"
  60. @property
  61. def human_start(self):
  62. start = "Unknown"
  63. if self.processing_started:
  64. start = self.processing_started.strftime("%B %d, %Y at %H:%M")
  65. return start
  66. @property
  67. def import_type(self) -> str:
  68. return "Unknown Import Source"
  69. def process(self, force=False):
  70. logger.warning("Process not implemented")
  71. def undo(self, dryrun=False):
  72. """Accepts the log from a scrobble import and removes the scrobbles"""
  73. from scrobbles.models import Scrobble
  74. if not self.process_log:
  75. logger.warning("No lines in process log found to undo")
  76. return
  77. for line in self.process_log.split("\n"):
  78. scrobble_id = line.split("\t")[0]
  79. scrobble = Scrobble.objects.filter(id=scrobble_id).first()
  80. if not scrobble:
  81. logger.warning(
  82. f"Could not find scrobble {scrobble_id} to undo"
  83. )
  84. continue
  85. logger.info(f"Removing scrobble {scrobble_id}")
  86. if not dryrun:
  87. scrobble.delete()
  88. self.processed_finished = None
  89. self.processing_started = None
  90. self.process_count = None
  91. self.process_log = ""
  92. self.save(
  93. update_fields=[
  94. "processed_finished",
  95. "processing_started",
  96. "process_log",
  97. "process_count",
  98. ]
  99. )
  100. def scrobbles(self) -> models.QuerySet:
  101. scrobble_ids = []
  102. if self.process_log:
  103. for line in self.process_log.split("\n"):
  104. sid = line.split("\t")[0]
  105. if sid:
  106. scrobble_ids.append(sid)
  107. return Scrobble.objects.filter(id__in=scrobble_ids)
  108. def mark_started(self):
  109. self.processing_started = timezone.now()
  110. self.save(update_fields=["processing_started"])
  111. def mark_finished(self):
  112. self.processed_finished = timezone.now()
  113. self.save(update_fields=["processed_finished"])
  114. def record_log(self, scrobbles):
  115. self.process_log = ""
  116. if not scrobbles:
  117. self.process_count = 0
  118. self.save(update_fields=["process_log", "process_count"])
  119. return
  120. for count, scrobble in enumerate(scrobbles):
  121. scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
  122. log_line = f"{scrobble_str}"
  123. if count > 0:
  124. log_line = "\n" + log_line
  125. self.process_log += log_line
  126. self.process_count = len(scrobbles)
  127. self.save(update_fields=["process_log", "process_count"])
  128. @property
  129. def upload_file_path(self):
  130. raise NotImplementedError
  131. class KoReaderImport(BaseFileImportMixin):
  132. class Meta:
  133. verbose_name = "KOReader Import"
  134. @property
  135. def import_type(self) -> str:
  136. return "KOReader"
  137. def get_absolute_url(self):
  138. return reverse(
  139. "scrobbles:koreader-import-detail", kwargs={"slug": self.uuid}
  140. )
  141. def get_path(instance, filename):
  142. extension = filename.split(".")[-1]
  143. uuid = instance.uuid
  144. return f"koreader-uploads/{uuid}.{extension}"
  145. @property
  146. def upload_file_path(self) -> str:
  147. if getattr(settings, "USE_S3_STORAGE"):
  148. path = self.sqlite_file.url
  149. else:
  150. path = self.sqlite_file.path
  151. return path
  152. sqlite_file = models.FileField(upload_to=get_path, **BNULL)
  153. def process(self, force=False):
  154. if self.processed_finished and not force:
  155. logger.info(
  156. f"{self} already processed on {self.processed_finished}"
  157. )
  158. return
  159. self.mark_started()
  160. scrobbles = process_koreader_sqlite_file(
  161. self.upload_file_path, self.user.id
  162. )
  163. self.record_log(scrobbles)
  164. self.mark_finished()
  165. class AudioScrobblerTSVImport(BaseFileImportMixin):
  166. class Meta:
  167. verbose_name = "AudioScrobbler TSV Import"
  168. @property
  169. def import_type(self) -> str:
  170. return "AudiosScrobbler"
  171. def get_absolute_url(self):
  172. return reverse(
  173. "scrobbles:tsv-import-detail", kwargs={"slug": self.uuid}
  174. )
  175. def get_path(instance, filename):
  176. extension = filename.split(".")[-1]
  177. uuid = instance.uuid
  178. return f"audioscrobbler-uploads/{uuid}.{extension}"
  179. @property
  180. def upload_file_path(self):
  181. if getattr(settings, "USE_S3_STORAGE"):
  182. path = self.tsv_file.url
  183. else:
  184. path = self.tsv_file.path
  185. return path
  186. tsv_file = models.FileField(upload_to=get_path, **BNULL)
  187. def process(self, force=False):
  188. from scrobbles.tsv import process_audioscrobbler_tsv_file
  189. if self.processed_finished and not force:
  190. logger.info(
  191. f"{self} already processed on {self.processed_finished}"
  192. )
  193. return
  194. self.mark_started()
  195. tz = None
  196. user_id = None
  197. if self.user:
  198. user_id = self.user.id
  199. tz = self.user.profile.tzinfo
  200. scrobbles = process_audioscrobbler_tsv_file(
  201. self.upload_file_path, user_id, user_tz=tz
  202. )
  203. self.record_log(scrobbles)
  204. self.mark_finished()
  205. class LastFmImport(BaseFileImportMixin):
  206. class Meta:
  207. verbose_name = "Last.FM Import"
  208. @property
  209. def import_type(self) -> str:
  210. return "LastFM"
  211. def get_absolute_url(self):
  212. return reverse(
  213. "scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid}
  214. )
  215. def process(self, import_all=False):
  216. """Import scrobbles found on LastFM"""
  217. if self.processed_finished:
  218. logger.info(
  219. f"{self} already processed on {self.processed_finished}"
  220. )
  221. return
  222. last_import = None
  223. if not import_all:
  224. try:
  225. last_import = LastFmImport.objects.exclude(id=self.id).last()
  226. except:
  227. pass
  228. if not import_all and not last_import:
  229. logger.warn(
  230. "No previous import, to import all Last.fm scrobbles, pass import_all=True"
  231. )
  232. return
  233. lastfm = LastFM(self.user)
  234. last_processed = None
  235. if last_import:
  236. last_processed = last_import.processed_finished
  237. self.mark_started()
  238. scrobbles = lastfm.import_from_lastfm(last_processed)
  239. self.record_log(scrobbles)
  240. self.mark_finished()
  241. class RetroarchImport(BaseFileImportMixin):
  242. class Meta:
  243. verbose_name = "Retroarch Import"
  244. @property
  245. def import_type(self) -> str:
  246. return "Retroarch"
  247. def get_absolute_url(self):
  248. return reverse(
  249. "scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid}
  250. )
  251. def process(self, import_all=False, force=False):
  252. """Import scrobbles found on Retroarch"""
  253. if self.processed_finished and not force:
  254. logger.info(
  255. f"{self} already processed on {self.processed_finished}"
  256. )
  257. return
  258. if force:
  259. logger.info(f"You told me to force import from Retroarch")
  260. if not self.user.profile.retroarch_path:
  261. logger.info(
  262. "Tying to import Retroarch logs, but user has no retroarch_path configured"
  263. )
  264. self.mark_started()
  265. scrobbles = retroarch.import_retroarch_lrtl_files(
  266. self.user.profile.retroarch_path,
  267. self.user.id,
  268. )
  269. self.record_log(scrobbles)
  270. self.mark_finished()
  271. class ChartRecord(TimeStampedModel):
  272. """Sort of like a materialized view for what we could dynamically generate,
  273. but would kill the DB as it gets larger. Collects time-based records
  274. generated by a cron-like archival job
  275. 1972 by Josh Rouse - #3 in 2023, January
  276. """
  277. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  278. rank = models.IntegerField(db_index=True)
  279. count = models.IntegerField(default=0)
  280. year = models.IntegerField(**BNULL)
  281. month = models.IntegerField(**BNULL)
  282. week = models.IntegerField(**BNULL)
  283. day = models.IntegerField(**BNULL)
  284. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  285. series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  286. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
  287. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  288. period_start = models.DateTimeField(**BNULL)
  289. period_end = models.DateTimeField(**BNULL)
  290. def save(self, *args, **kwargs):
  291. profile = self.user.profile
  292. if self.week:
  293. # set start and end to start and end of week
  294. period = datetime.date.fromisocalendar(self.year, self.week, 1)
  295. self.period_start = start_of_week(period, profile)
  296. self.period_start = end_of_week(period, profile)
  297. if self.day:
  298. period = datetime.datetime(self.year, self.month, self.day)
  299. self.period_start = start_of_day(period, profile)
  300. self.period_end = end_of_day(period, profile)
  301. if self.month and not self.day:
  302. period = datetime.datetime(self.year, self.month, 1)
  303. self.period_start = start_of_month(period, profile)
  304. self.period_end = end_of_month(period, profile)
  305. super(ChartRecord, self).save(*args, **kwargs)
  306. @property
  307. def media_obj(self):
  308. media_obj = None
  309. if self.video:
  310. media_obj = self.video
  311. if self.track:
  312. media_obj = self.track
  313. if self.artist:
  314. media_obj = self.artist
  315. return media_obj
  316. @property
  317. def month_str(self) -> str:
  318. month_str = ""
  319. if self.month:
  320. month_str = calendar.month_name[self.month]
  321. return month_str
  322. @property
  323. def day_str(self) -> str:
  324. day_str = ""
  325. if self.day:
  326. day_str = str(self.day)
  327. return day_str
  328. @property
  329. def week_str(self) -> str:
  330. week_str = ""
  331. if self.week:
  332. week_str = str(self.week)
  333. return "Week " + week_str
  334. @property
  335. def period(self) -> str:
  336. period = str(self.year)
  337. if self.month:
  338. period = " ".join([self.month_str, period])
  339. if self.week:
  340. period = " ".join([self.week_str, period])
  341. if self.day:
  342. period = " ".join([self.day_str, period])
  343. return period
  344. @property
  345. def period_type(self) -> str:
  346. period = "year"
  347. if self.month:
  348. period = "month"
  349. if self.week:
  350. period = "week"
  351. if self.day:
  352. period = "day"
  353. return period
  354. def __str__(self):
  355. title = f"#{self.rank} in {self.period}"
  356. if self.day or self.week:
  357. title = f"#{self.rank} on {self.period}"
  358. return title
  359. def link(self):
  360. get_params = f"?date={self.year}"
  361. if self.week:
  362. get_params = get_params = get_params + f"-W{self.week}"
  363. if self.month:
  364. get_params = get_params = get_params + f"-{self.month}"
  365. if self.day:
  366. get_params = get_params = get_params + f"-{self.day}"
  367. if self.artist:
  368. get_params = get_params + "&media=Artist"
  369. return reverse("scrobbles:charts-home") + get_params
  370. @classmethod
  371. def build(cls, user, **kwargs):
  372. build_charts(user=user, **kwargs)
  373. @classmethod
  374. def for_year(cls, user, year):
  375. return cls.objects.filter(year=year, user=user)
  376. @classmethod
  377. def for_month(cls, user, year, month):
  378. return cls.objects.filter(year=year, month=month, user=user)
  379. @classmethod
  380. def for_day(cls, user, year, day, month):
  381. return cls.objects.filter(year=year, month=month, day=day, user=user)
  382. @classmethod
  383. def for_week(cls, user, year, week):
  384. return cls.objects.filter(year=year, week=week, user=user)
  385. class Scrobble(TimeStampedModel):
  386. """A scrobble tracks played media items by a user."""
  387. class MediaType(models.TextChoices):
  388. """Enum mapping a media model type to a string"""
  389. VIDEO = "Video", "Video"
  390. TRACK = "Track", "Track"
  391. PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
  392. SPORT_EVENT = "SportEvent", "Sport event"
  393. BOOK = "Book", "Book"
  394. VIDEO_GAME = "VideoGame", "Video game"
  395. BOARD_GAME = "BoardGame", "Board game"
  396. GEO_LOCATION = "GeoLocation", "GeoLocation"
  397. WEBPAGE = "WebPage", "Web Page"
  398. uuid = models.UUIDField(editable=False, **BNULL)
  399. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  400. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  401. podcast_episode = models.ForeignKey(
  402. PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
  403. )
  404. sport_event = models.ForeignKey(
  405. SportEvent, on_delete=models.DO_NOTHING, **BNULL
  406. )
  407. book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
  408. video_game = models.ForeignKey(
  409. VideoGame, on_delete=models.DO_NOTHING, **BNULL
  410. )
  411. board_game = models.ForeignKey(
  412. BoardGame, on_delete=models.DO_NOTHING, **BNULL
  413. )
  414. geo_location = models.ForeignKey(
  415. GeoLocation, on_delete=models.DO_NOTHING, **BNULL
  416. )
  417. webpage = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
  418. media_type = models.CharField(
  419. max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
  420. )
  421. user = models.ForeignKey(
  422. User, blank=True, null=True, on_delete=models.DO_NOTHING
  423. )
  424. # Time keeping
  425. timestamp = models.DateTimeField(**BNULL)
  426. stop_timestamp = models.DateTimeField(**BNULL)
  427. playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
  428. playback_position_seconds = models.IntegerField(**BNULL)
  429. # Status indicators
  430. is_paused = models.BooleanField(default=False)
  431. played_to_completion = models.BooleanField(default=False)
  432. in_progress = models.BooleanField(default=True)
  433. # Metadata
  434. source = models.CharField(max_length=255, **BNULL)
  435. source_id = models.TextField(**BNULL)
  436. scrobble_log = models.TextField(**BNULL)
  437. notes = models.TextField(**BNULL)
  438. # Fields for keeping track of book data
  439. book_koreader_hash = models.CharField(max_length=50, **BNULL)
  440. book_pages_read = models.IntegerField(**BNULL)
  441. book_page_data = models.JSONField(**BNULL)
  442. # Fields for keeping track of video game data
  443. videogame_save_data = models.FileField(
  444. upload_to="scrobbles/videogame_save_data/", **BNULL
  445. )
  446. videogame_screenshot = models.ImageField(
  447. upload_to="scrobbles/videogame_screenshot/", **BNULL
  448. )
  449. videogame_screenshot_small = ImageSpecField(
  450. source="videogame_screenshot",
  451. processors=[ResizeToFit(100, 100)],
  452. format="JPEG",
  453. options={"quality": 60},
  454. )
  455. videogame_screenshot_medium = ImageSpecField(
  456. source="videogame_screenshot",
  457. processors=[ResizeToFit(300, 300)],
  458. format="JPEG",
  459. options={"quality": 75},
  460. )
  461. long_play_seconds = models.BigIntegerField(**BNULL)
  462. long_play_complete = models.BooleanField(**BNULL)
  463. def save(self, *args, **kwargs):
  464. if not self.uuid:
  465. self.uuid = uuid4()
  466. # Microseconds mess up Django's filtering, and we don't need be that specific
  467. self.timestamp = self.timestamp.replace(microsecond=0)
  468. self.media_type = self.MediaType(self.media_obj.__class__.__name__)
  469. return super(Scrobble, self).save(*args, **kwargs)
  470. @property
  471. def scrobble_media_key(self) -> str:
  472. return media_class_to_foreign_key(self.media_type) + "_id"
  473. @property
  474. def status(self) -> str:
  475. if self.is_paused:
  476. return "paused"
  477. if self.played_to_completion:
  478. return "finished"
  479. if self.in_progress:
  480. return "in-progress"
  481. return "zombie"
  482. @property
  483. def is_stale(self) -> bool:
  484. """Mark scrobble as stale if it's been more than an hour since it was updated"""
  485. is_stale = False
  486. now = timezone.now()
  487. seconds_since_last_update = (now - self.modified).seconds
  488. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  489. is_stale = True
  490. return is_stale
  491. @property
  492. def previous(self) -> "Scrobble":
  493. return (
  494. self.media_obj.scrobble_set.order_by("-timestamp")
  495. .filter(timestamp__lt=self.timestamp)
  496. .first()
  497. )
  498. @property
  499. def next(self) -> "Scrobble":
  500. return (
  501. self.media_obj.scrobble_set.order_by("timestamp")
  502. .filter(timestamp__gt=self.timestamp)
  503. .first()
  504. )
  505. @property
  506. def previous_by_media(self) -> "Scrobble":
  507. return (
  508. Scrobble.objects.filter(
  509. media_type=self.media_type,
  510. user=self.user,
  511. timestamp__lt=self.timestamp,
  512. )
  513. .order_by("-timestamp")
  514. .first()
  515. )
  516. @property
  517. def next_by_media(self) -> "Scrobble":
  518. return (
  519. Scrobble.objects.filter(
  520. media_type=self.media_type,
  521. user=self.user,
  522. timestamp__gt=self.timestamp,
  523. )
  524. .order_by("-timestamp")
  525. .first()
  526. )
  527. @property
  528. def previous_by_user(self) -> "Scrobble":
  529. return (
  530. Scrobble.objects.order_by("-timestamp")
  531. .filter(timestamp__lt=self.timestamp)
  532. .first()
  533. )
  534. @property
  535. def next_by_user(self) -> "Scrobble":
  536. return (
  537. Scrobble.objects.order_by("-timestamp")
  538. .filter(timestamp__gt=self.timestamp)
  539. .first()
  540. )
  541. @property
  542. def session_pages_read(self) -> Optional[int]:
  543. """Look one scrobble back, if it isn't complete,"""
  544. if not self.book_pages_read:
  545. return
  546. if self.previous:
  547. return self.book_pages_read - self.previous.book_pages_read
  548. return self.book_pages_read
  549. @property
  550. def is_long_play(self) -> bool:
  551. return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
  552. @property
  553. def percent_played(self) -> int:
  554. if not self.media_obj:
  555. return 0
  556. if self.media_obj and not self.media_obj.run_time_seconds:
  557. return 100
  558. if not self.playback_position_seconds and self.played_to_completion:
  559. return 100
  560. playback_seconds = self.playback_position_seconds
  561. if not playback_seconds:
  562. playback_seconds = (timezone.now() - self.timestamp).seconds
  563. run_time_secs = self.media_obj.run_time_seconds
  564. percent = int((playback_seconds / run_time_secs) * 100)
  565. if self.is_long_play:
  566. long_play_secs = 0
  567. if self.previous and not self.previous.long_play_complete:
  568. long_play_secs = self.previous.long_play_seconds or 0
  569. percent = int(
  570. ((playback_seconds + long_play_secs) / run_time_secs) * 100
  571. )
  572. # if percent > 100:
  573. # percent = 100
  574. return percent
  575. def can_be_updated(self, media, user_id) -> bool:
  576. updatable = True
  577. if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
  578. logger.info(f"No - Long play media")
  579. updatable = False
  580. if self.percent_played >= 100:
  581. logger.info(f"No - 100% played - {self.id} - {self.source}")
  582. updatable = False
  583. if self.is_stale:
  584. logger.info(f"No - stale - {self.id} - {self.source}")
  585. updatable = False
  586. if self.media_obj.__class__.__name__ in [
  587. "GeoLocation"
  588. ] and not self.has_moved(media, user_id):
  589. logger.info(f"Yes - in the same place - {self.id} - {self.source}")
  590. updatable = True
  591. return updatable
  592. @classmethod
  593. def has_moved(cls, new_location: GeoLocation, user_id: int) -> bool:
  594. """Given a new location, let us know if we've moved from there"""
  595. has_moved = False
  596. past_scrobbles = Scrobble.objects.filter(
  597. media_type="GeoLocation",
  598. user_id=user_id,
  599. ).order_by("-timestamp")[1:POINTS_FOR_MOVEMENT_HISTORY]
  600. past_points = [s.media_obj for s in past_scrobbles]
  601. return new_location.has_moved(past_points)
  602. @property
  603. def media_obj(self):
  604. media_obj = None
  605. if self.video:
  606. media_obj = self.video
  607. if self.track:
  608. media_obj = self.track
  609. if self.podcast_episode:
  610. media_obj = self.podcast_episode
  611. if self.sport_event:
  612. media_obj = self.sport_event
  613. if self.book:
  614. media_obj = self.book
  615. if self.video_game:
  616. media_obj = self.video_game
  617. if self.board_game:
  618. media_obj = self.board_game
  619. if self.geo_location:
  620. media_obj = self.geo_location
  621. if self.webpage:
  622. media_obj = self.webpage
  623. return media_obj
  624. def __str__(self):
  625. timestamp = self.timestamp.strftime("%Y-%m-%d")
  626. return f"Scrobble of {self.media_obj} ({timestamp})"
  627. def calc_reading_duration(self) -> int:
  628. duration = 0
  629. if self.book_page_data:
  630. for k, v in self.book_page_data.items():
  631. duration += v.get("duration")
  632. return duration
  633. def calc_pages_read(self) -> int:
  634. pages_read = 0
  635. if self.book_page_data:
  636. pages = [int(k) for k in self.book_page_data.keys()]
  637. pages.sort()
  638. pages_read = pages[-1] - pages[0]
  639. return pages_read
  640. @classmethod
  641. def create_or_update(
  642. cls, media, user_id: int, scrobble_data: dict, **kwargs
  643. ) -> "Scrobble":
  644. key = media_class_to_foreign_key(media.__class__.__name__)
  645. media_query = models.Q(**{key: media})
  646. scrobble_data[key + "_id"] = media.id
  647. scrobble = (
  648. cls.objects.filter(
  649. media_query,
  650. user_id=user_id,
  651. )
  652. .order_by("-timestamp")
  653. .first()
  654. )
  655. if scrobble and scrobble.can_be_updated(media, user_id):
  656. source = scrobble_data["source"]
  657. mtype = media.__class__.__name__
  658. logger.info(
  659. f"[scrobbling] updating {scrobble.id} for {mtype} {media.id} from {source}",
  660. {"scrobble_data": scrobble_data, "media": media},
  661. )
  662. return scrobble.update(scrobble_data)
  663. if scrobble:
  664. logger.info(
  665. f"[scrobbling] stopping existing scrobble {scrobble.id} before creating new one"
  666. )
  667. scrobble.stop()
  668. # Discard status before creating
  669. scrobble_data.pop("mopidy_status", None)
  670. scrobble_data.pop("jellyfin_status", None)
  671. source = scrobble_data["source"]
  672. mtype = media.__class__.__name__
  673. logger.info(
  674. f"[scrobbling] creating for {mtype} {media.id} from {source}"
  675. )
  676. return cls.create(scrobble_data)
  677. def update(self, scrobble_data: dict) -> "Scrobble":
  678. # Status is a field we get from Mopidy, which refuses to poll us
  679. scrobble_status = scrobble_data.pop("mopidy_status", None)
  680. if not scrobble_status:
  681. scrobble_status = scrobble_data.pop("jellyfin_status", None)
  682. if self.percent_played < 100:
  683. # Only worry about ticks if we haven't gotten to the end
  684. self.update_ticks(scrobble_data)
  685. # On stop, stop progress and send it to the check for completion
  686. if scrobble_status == "stopped":
  687. self.stop()
  688. # On pause, set is_paused and stop scrobbling
  689. if scrobble_status == "paused":
  690. self.pause()
  691. if scrobble_status == "resumed":
  692. self.resume()
  693. check_scrobble_for_finish(self)
  694. if scrobble_status != "resumed":
  695. scrobble_data["stop_timestamp"] = (
  696. scrobble_data.pop("timestamp", None) or timezone.now()
  697. )
  698. scrobble_data.pop("timestamp", None)
  699. update_fields = []
  700. for key, value in scrobble_data.items():
  701. setattr(self, key, value)
  702. update_fields.append(key)
  703. self.save(update_fields=update_fields)
  704. return self
  705. @classmethod
  706. def create(
  707. cls,
  708. scrobble_data: dict,
  709. ) -> "Scrobble":
  710. scrobble_data["scrobble_log"] = ""
  711. scrobble = cls.objects.create(
  712. **scrobble_data,
  713. )
  714. return scrobble
  715. def stop(self, force_finish=False) -> None:
  716. self.stop_timestamp = timezone.now()
  717. if force_finish:
  718. self.played_to_completion = True
  719. self.in_progress = False
  720. if not self.playback_position_seconds:
  721. self.playback_position_seconds = int(
  722. (self.stop_timestamp - self.timestamp).total_seconds()
  723. )
  724. self.save(
  725. update_fields=[
  726. "in_progress",
  727. "played_to_completion",
  728. "stop_timestamp",
  729. "playback_position_seconds",
  730. ]
  731. )
  732. logger.info(f"stopping {self.id} from {self.source}")
  733. class_name = self.media_obj.__class__.__name__
  734. if class_name in LONG_PLAY_MEDIA.values():
  735. check_long_play_for_finish(self)
  736. def pause(self) -> None:
  737. if self.is_paused:
  738. logger.warning(f"{self.id} - already paused - {self.source}")
  739. return
  740. logger.info(f"{self.id} - pausing - {self.source}")
  741. self.is_paused = True
  742. self.save(update_fields=["is_paused"])
  743. def resume(self) -> None:
  744. if self.is_paused or not self.in_progress:
  745. self.is_paused = False
  746. self.in_progress = True
  747. logger.info(f"{self.id} - resuming - {self.source}")
  748. return self.save(update_fields=["is_paused", "in_progress"])
  749. def cancel(self) -> None:
  750. check_scrobble_for_finish(self, force_finish=True)
  751. self.delete()
  752. def update_ticks(self, data) -> None:
  753. self.playback_position_seconds = data.get("playback_position_seconds")
  754. logger.info(
  755. f"{self.id} - {self.playback_position_seconds} - {self.source}"
  756. )
  757. self.save(update_fields=["playback_position_seconds"])
  758. class ScrobbledPage(TimeStampedModel):
  759. scrobble = models.ForeignKey(Scrobble, on_delete=models.DO_NOTHING)
  760. number = models.IntegerField()
  761. start_time = models.DateTimeField(**BNULL)
  762. end_time = models.DateTimeField(**BNULL)
  763. duration_seconds = models.IntegerField(**BNULL)
  764. notes = models.CharField(max_length=255, **BNULL)
  765. def __str__(self):
  766. return f"Page {self.number} of {self.book.pages} in {self.book.title}"
  767. def save(self, *args, **kwargs):
  768. if not self.end_time and self.duration_seconds:
  769. self._set_end_time()
  770. return super(ScrobbledPage, self).save(*args, **kwargs)
  771. @cached_property
  772. def book(self):
  773. return self.scrobble.book
  774. @property
  775. def next(self):
  776. user_pages_qs = self.book.scrobbledpage_set.filter(
  777. user=self.scrobble.user
  778. )
  779. page = user_pages_qs.filter(number=self.number + 1).first()
  780. if not page:
  781. page = (
  782. user_pages_qs.filter(created__gt=self.created)
  783. .order_by("created")
  784. .first()
  785. )
  786. return page
  787. @property
  788. def previous(self):
  789. user_pages_qs = self.book.scrobbledpage_set.filter(
  790. user=self.scrobble.user
  791. )
  792. page = user_pages_qs.filter(number=self.number - 1).first()
  793. if not page:
  794. page = (
  795. user_pages_qs.filter(created__lt=self.created)
  796. .order_by("-created")
  797. .first()
  798. )
  799. return page
  800. @property
  801. def seconds_to_next_page(self) -> int:
  802. seconds = 999999 # Effectively infnity time as we have no next
  803. if not self.end_time:
  804. self._set_end_time()
  805. if self.next:
  806. seconds = (self.next.start_time - self.end_time).seconds
  807. return seconds
  808. @property
  809. def is_scrobblable(self) -> bool:
  810. """A page defines the start of a scrobble if the seconds to next page
  811. are greater than an hour, or 3600 seconds, and it's not a single page,
  812. so the next seconds to next_page is less than an hour as well.
  813. As a special case, the first recorded page is a scrobble, so we establish
  814. when the book was started.
  815. """
  816. is_scrobblable = False
  817. over_an_hour_since_last_page = False
  818. if not self.previous:
  819. is_scrobblable = True
  820. if self.previous:
  821. over_an_hour_since_last_page = (
  822. self.previous.seconds_to_next_page >= 3600
  823. )
  824. blip = self.seconds_to_next_page >= 3600
  825. if over_an_hour_since_last_page and not blip:
  826. is_scrobblable = True
  827. return is_scrobblable
  828. def _set_end_time(self) -> None:
  829. if self.end_time:
  830. return
  831. self.end_time = self.start_time + datetime.timedelta(
  832. seconds=self.duration_seconds
  833. )
  834. self.save(update_fields=["end_time"])