models.py 32 KB

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