models.py 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  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 = False
  577. if self.media_obj.title:
  578. same_title = last_location.title == self.media_obj.title
  579. logger.info(f"{self.timestamp}")
  580. logger.info(f"Last lat: {last_location.lat}, last long {last_location.lon}, last title {last_location.title}")
  581. logger.info(
  582. f"Our lat {self.media_obj.lat}, Our lon {self.media_obj.lon} or our title {self.media_obj.title}"
  583. )
  584. if (same_lat and same_lon) or same_title:
  585. logger.info(
  586. f"Yes - We're in the same place: {self.media_obj}"
  587. )
  588. updatable = True
  589. else:
  590. logger.info(
  591. f"No - We've moved, start a new scrobble: {self.media_obj}"
  592. )
  593. # Stop the previous location scrobble
  594. self.previous_all.stop()
  595. updatable = False
  596. return updatable
  597. @property
  598. def media_obj(self):
  599. media_obj = None
  600. if self.video:
  601. media_obj = self.video
  602. if self.track:
  603. media_obj = self.track
  604. if self.podcast_episode:
  605. media_obj = self.podcast_episode
  606. if self.sport_event:
  607. media_obj = self.sport_event
  608. if self.book:
  609. media_obj = self.book
  610. if self.video_game:
  611. media_obj = self.video_game
  612. if self.board_game:
  613. media_obj = self.board_game
  614. if self.geo_location:
  615. media_obj = self.geo_location
  616. if self.webpage:
  617. media_obj = self.webpage
  618. return media_obj
  619. def __str__(self):
  620. timestamp = self.timestamp.strftime("%Y-%m-%d")
  621. return f"Scrobble of {self.media_obj} ({timestamp})"
  622. @classmethod
  623. def create_or_update(
  624. cls, media, user_id: int, scrobble_data: dict, **kwargs
  625. ) -> "Scrobble":
  626. media_class = media.__class__.__name__
  627. dup = None
  628. media_query = models.Q(track=media)
  629. if media_class == "Track":
  630. scrobble_data["track_id"] = media.id
  631. if media_class == "Video":
  632. media_query = models.Q(video=media)
  633. scrobble_data["video_id"] = media.id
  634. if media_class == "Episode":
  635. media_query = models.Q(podcast_episode=media)
  636. scrobble_data["podcast_episode_id"] = media.id
  637. if media_class == "SportEvent":
  638. media_query = models.Q(sport_event=media)
  639. scrobble_data["sport_event_id"] = media.id
  640. if media_class == "Book":
  641. media_query = models.Q(book=media)
  642. scrobble_data["book_id"] = media.id
  643. if media_class == "VideoGame":
  644. media_query = models.Q(video_game=media)
  645. scrobble_data["video_game_id"] = media.id
  646. if media_class == "BoardGame":
  647. media_query = models.Q(board_game=media)
  648. scrobble_data["board_game_id"] = media.id
  649. if media_class == "WebPage":
  650. media_query = models.Q(webpage=media)
  651. scrobble_data["webpage_id"] = media.id
  652. if media_class == "GeoLocation":
  653. media_query = models.Q(media_type=Scrobble.MediaType.GEO_LOCATION)
  654. scrobble_data["geo_location_id"] = media.id
  655. dup = cls.objects.filter(
  656. media_type=cls.MediaType.GEO_LOCATION,
  657. timestamp=scrobble_data.get("timestamp"),
  658. ).first()
  659. if dup:
  660. logger.info(
  661. "[scrobbling] scrobble for geo location with identical timestamp found"
  662. )
  663. # TODO Fix return type, can we ever return a Scrobble?
  664. return
  665. scrobble = (
  666. cls.objects.filter(
  667. media_query,
  668. user_id=user_id,
  669. )
  670. .order_by("-timestamp")
  671. .first()
  672. )
  673. if scrobble and scrobble.can_be_updated:
  674. source = scrobble_data["source"]
  675. mtype = media.__class__.__name__
  676. logger.info(
  677. f"[scrobbling] updating {scrobble.id} for {mtype} {media.id} from {source}",
  678. {"scrobble_data": scrobble_data, "media": media},
  679. )
  680. return scrobble.update(scrobble_data)
  681. # Discard status before creating
  682. scrobble_data.pop("mopidy_status", None)
  683. scrobble_data.pop("jellyfin_status", None)
  684. source = scrobble_data["source"]
  685. mtype = media.__class__.__name__
  686. logger.info(
  687. f"[scrobbling] creating for {mtype} {media.id} from {source}"
  688. )
  689. return cls.create(scrobble_data)
  690. def update(self, scrobble_data: dict) -> "Scrobble":
  691. # Status is a field we get from Mopidy, which refuses to poll us
  692. scrobble_status = scrobble_data.pop("mopidy_status", None)
  693. if not scrobble_status:
  694. scrobble_status = scrobble_data.pop("jellyfin_status", None)
  695. if self.percent_played < 100:
  696. # Only worry about ticks if we haven't gotten to the end
  697. self.update_ticks(scrobble_data)
  698. # On stop, stop progress and send it to the check for completion
  699. if scrobble_status == "stopped":
  700. self.stop()
  701. # On pause, set is_paused and stop scrobbling
  702. if scrobble_status == "paused":
  703. self.pause()
  704. if scrobble_status == "resumed":
  705. self.resume()
  706. check_scrobble_for_finish(self)
  707. if scrobble_status != "resumed":
  708. scrobble_data["stop_timestamp"] = (
  709. scrobble_data.pop("timestamp", None) or timezone.now()
  710. )
  711. scrobble_data.pop("timestamp", None)
  712. update_fields = []
  713. for key, value in scrobble_data.items():
  714. setattr(self, key, value)
  715. update_fields.append(key)
  716. self.save(update_fields=update_fields)
  717. return self
  718. @classmethod
  719. def create(
  720. cls,
  721. scrobble_data: dict,
  722. ) -> "Scrobble":
  723. scrobble_data["scrobble_log"] = ""
  724. scrobble = cls.objects.create(
  725. **scrobble_data,
  726. )
  727. return scrobble
  728. def stop(self, force_finish=False) -> None:
  729. self.stop_timestamp = timezone.now()
  730. if force_finish:
  731. self.played_to_completion = True
  732. self.in_progress = False
  733. if not self.playback_position_seconds:
  734. self.playback_position_seconds = int(
  735. (self.stop_timestamp - self.timestamp).total_seconds()
  736. )
  737. self.save(
  738. update_fields=[
  739. "in_progress",
  740. "played_to_completion",
  741. "stop_timestamp",
  742. "playback_position_seconds",
  743. ]
  744. )
  745. logger.info(f"stopping {self.id} from {self.source}")
  746. class_name = self.media_obj.__class__.__name__
  747. if class_name in LONG_PLAY_MEDIA.values():
  748. check_long_play_for_finish(self)
  749. def pause(self) -> None:
  750. if self.is_paused:
  751. logger.warning(f"{self.id} - already paused - {self.source}")
  752. return
  753. logger.info(f"{self.id} - pausing - {self.source}")
  754. self.is_paused = True
  755. self.save(update_fields=["is_paused"])
  756. def resume(self) -> None:
  757. if self.is_paused or not self.in_progress:
  758. self.is_paused = False
  759. self.in_progress = True
  760. logger.info(f"{self.id} - resuming - {self.source}")
  761. return self.save(update_fields=["is_paused", "in_progress"])
  762. def cancel(self) -> None:
  763. check_scrobble_for_finish(self, force_finish=True)
  764. self.delete()
  765. def update_ticks(self, data) -> None:
  766. self.playback_position_seconds = data.get("playback_position_seconds")
  767. logger.info(
  768. f"{self.id} - {self.playback_position_seconds} - {self.source}"
  769. )
  770. self.save(update_fields=["playback_position_seconds"])
  771. class ScrobbledPage(TimeStampedModel):
  772. scrobble = models.ForeignKey(Scrobble, on_delete=models.DO_NOTHING)
  773. number = models.IntegerField()
  774. start_time = models.DateTimeField(**BNULL)
  775. end_time = models.DateTimeField(**BNULL)
  776. duration_seconds = models.IntegerField(**BNULL)
  777. notes = models.CharField(max_length=255, **BNULL)
  778. def __str__(self):
  779. return f"Page {self.number} of {self.book.pages} in {self.book.title}"
  780. def save(self, *args, **kwargs):
  781. if not self.end_time and self.duration_seconds:
  782. self._set_end_time()
  783. return super(ScrobbledPage, self).save(*args, **kwargs)
  784. @cached_property
  785. def book(self):
  786. return self.scrobble.book
  787. @property
  788. def next(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__gt=self.created)
  796. .order_by("created")
  797. .first()
  798. )
  799. return page
  800. @property
  801. def previous(self):
  802. user_pages_qs = self.book.scrobbledpage_set.filter(
  803. user=self.scrobble.user
  804. )
  805. page = user_pages_qs.filter(number=self.number - 1).first()
  806. if not page:
  807. page = (
  808. user_pages_qs.filter(created__lt=self.created)
  809. .order_by("-created")
  810. .first()
  811. )
  812. return page
  813. @property
  814. def seconds_to_next_page(self) -> int:
  815. seconds = 999999 # Effectively infnity time as we have no next
  816. if not self.end_time:
  817. self._set_end_time()
  818. if self.next:
  819. seconds = (self.next.start_time - self.end_time).seconds
  820. return seconds
  821. @property
  822. def is_scrobblable(self) -> bool:
  823. """A page defines the start of a scrobble if the seconds to next page
  824. are greater than an hour, or 3600 seconds, and it's not a single page,
  825. so the next seconds to next_page is less than an hour as well.
  826. As a special case, the first recorded page is a scrobble, so we establish
  827. when the book was started.
  828. """
  829. is_scrobblable = False
  830. over_an_hour_since_last_page = False
  831. if not self.previous:
  832. is_scrobblable = True
  833. if self.previous:
  834. over_an_hour_since_last_page = (
  835. self.previous.seconds_to_next_page >= 3600
  836. )
  837. blip = self.seconds_to_next_page >= 3600
  838. if over_an_hour_since_last_page and not blip:
  839. is_scrobblable = True
  840. return is_scrobblable
  841. def _set_end_time(self) -> None:
  842. if self.end_time:
  843. return
  844. self.end_time = self.start_time + datetime.timedelta(
  845. seconds=self.duration_seconds
  846. )
  847. self.save(update_fields=["end_time"])