models.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023
  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.koreader import process_koreader_sqlite_file
  8. from books.models import Book
  9. from django.conf import settings
  10. from django.contrib.auth import get_user_model
  11. from django.db import models
  12. from django.urls import reverse
  13. from django.utils import timezone
  14. from django.utils.functional import cached_property
  15. from django_extensions.db.models import TimeStampedModel
  16. from imagekit.models import ImageSpecField
  17. from imagekit.processors import ResizeToFit
  18. from locations.models import GeoLocation
  19. from music.lastfm import LastFM
  20. from music.models import Artist, Track
  21. from podcasts.models import Episode
  22. from profiles.utils import (
  23. end_of_day,
  24. end_of_month,
  25. end_of_week,
  26. start_of_day,
  27. start_of_month,
  28. start_of_week,
  29. )
  30. from scrobbles.constants import LONG_PLAY_MEDIA
  31. from scrobbles.stats import build_charts
  32. from scrobbles.utils import (
  33. check_long_play_for_finish,
  34. check_scrobble_for_finish,
  35. )
  36. from sports.models import SportEvent
  37. from videogames import retroarch
  38. from videogames.models import VideoGame
  39. from videos.models import Series, Video
  40. from webpages.models import WebPage
  41. logger = logging.getLogger(__name__)
  42. User = get_user_model()
  43. BNULL = {"blank": True, "null": True}
  44. class BaseFileImportMixin(TimeStampedModel):
  45. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  46. uuid = models.UUIDField(editable=False, default=uuid4)
  47. processing_started = models.DateTimeField(**BNULL)
  48. processed_finished = models.DateTimeField(**BNULL)
  49. process_log = models.TextField(**BNULL)
  50. process_count = models.IntegerField(**BNULL)
  51. class Meta:
  52. abstract = True
  53. def __str__(self):
  54. return f"{self.import_type} import on {self.human_start}"
  55. @property
  56. def human_start(self):
  57. start = "Unknown"
  58. if self.processing_started:
  59. start = self.processing_started.strftime("%B %d, %Y at %H:%M")
  60. return start
  61. @property
  62. def import_type(self) -> str:
  63. return "Unknown Import Source"
  64. def process(self, force=False):
  65. logger.warning("Process not implemented")
  66. def undo(self, dryrun=False):
  67. """Accepts the log from a scrobble import and removes the scrobbles"""
  68. from scrobbles.models import Scrobble
  69. if not self.process_log:
  70. logger.warning("No lines in process log found to undo")
  71. return
  72. for line in self.process_log.split("\n"):
  73. scrobble_id = line.split("\t")[0]
  74. scrobble = Scrobble.objects.filter(id=scrobble_id).first()
  75. if not scrobble:
  76. logger.warning(
  77. f"Could not find scrobble {scrobble_id} to undo"
  78. )
  79. continue
  80. logger.info(f"Removing scrobble {scrobble_id}")
  81. if not dryrun:
  82. scrobble.delete()
  83. self.processed_finished = None
  84. self.processing_started = None
  85. self.process_count = None
  86. self.process_log = ""
  87. self.save(
  88. update_fields=[
  89. "processed_finished",
  90. "processing_started",
  91. "process_log",
  92. "process_count",
  93. ]
  94. )
  95. def scrobbles(self) -> models.QuerySet:
  96. scrobble_ids = []
  97. if self.process_log:
  98. for line in self.process_log.split("\n"):
  99. sid = line.split("\t")[0]
  100. if sid:
  101. scrobble_ids.append(sid)
  102. return Scrobble.objects.filter(id__in=scrobble_ids)
  103. def mark_started(self):
  104. self.processing_started = timezone.now()
  105. self.save(update_fields=["processing_started"])
  106. def mark_finished(self):
  107. self.processed_finished = timezone.now()
  108. self.save(update_fields=["processed_finished"])
  109. def record_log(self, scrobbles):
  110. self.process_log = ""
  111. if not scrobbles:
  112. self.process_count = 0
  113. self.save(update_fields=["process_log", "process_count"])
  114. return
  115. for count, scrobble in enumerate(scrobbles):
  116. scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
  117. log_line = f"{scrobble_str}"
  118. if count > 0:
  119. log_line = "\n" + log_line
  120. self.process_log += log_line
  121. self.process_count = len(scrobbles)
  122. self.save(update_fields=["process_log", "process_count"])
  123. @property
  124. def upload_file_path(self):
  125. raise NotImplementedError
  126. class KoReaderImport(BaseFileImportMixin):
  127. class Meta:
  128. verbose_name = "KOReader Import"
  129. @property
  130. def import_type(self) -> str:
  131. return "KOReader"
  132. def get_absolute_url(self):
  133. return reverse(
  134. "scrobbles:koreader-import-detail", kwargs={"slug": self.uuid}
  135. )
  136. def get_path(instance, filename):
  137. extension = filename.split(".")[-1]
  138. uuid = instance.uuid
  139. return f"koreader-uploads/{uuid}.{extension}"
  140. @property
  141. def upload_file_path(self) -> str:
  142. if getattr(settings, "USE_S3_STORAGE"):
  143. path = self.sqlite_file.url
  144. else:
  145. path = self.sqlite_file.path
  146. return path
  147. sqlite_file = models.FileField(upload_to=get_path, **BNULL)
  148. def process(self, force=False):
  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_file(
  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(**BNULL)
  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 of book data
  434. book_koreader_hash = models.CharField(max_length=50, **BNULL)
  435. book_pages_read = models.IntegerField(**BNULL)
  436. book_page_data = models.JSONField(**BNULL)
  437. # Fields for keeping track of video game data
  438. videogame_save_data = models.FileField(
  439. upload_to="scrobbles/videogame_save_data/", **BNULL
  440. )
  441. videogame_screenshot = models.ImageField(
  442. upload_to="scrobbles/videogame_screenshot/", **BNULL
  443. )
  444. videogame_screenshot_small = ImageSpecField(
  445. source="videogame_screenshot",
  446. processors=[ResizeToFit(100, 100)],
  447. format="JPEG",
  448. options={"quality": 60},
  449. )
  450. videogame_screenshot_medium = ImageSpecField(
  451. source="videogame_screenshot",
  452. processors=[ResizeToFit(300, 300)],
  453. format="JPEG",
  454. options={"quality": 75},
  455. )
  456. long_play_seconds = models.BigIntegerField(**BNULL)
  457. long_play_complete = models.BooleanField(**BNULL)
  458. def save(self, *args, **kwargs):
  459. if not self.uuid:
  460. self.uuid = uuid4()
  461. self.media_type = self.MediaType(self.media_obj.__class__.__name__)
  462. return super(Scrobble, self).save(*args, **kwargs)
  463. @property
  464. def status(self) -> str:
  465. if self.is_paused:
  466. return "paused"
  467. if self.played_to_completion:
  468. return "finished"
  469. if self.in_progress:
  470. return "in-progress"
  471. return "zombie"
  472. @property
  473. def is_stale(self) -> bool:
  474. """Mark scrobble as stale if it's been more than an hour since it was updated"""
  475. is_stale = False
  476. now = timezone.now()
  477. seconds_since_last_update = (now - self.modified).seconds
  478. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  479. is_stale = True
  480. return is_stale
  481. @property
  482. def previous(self) -> "Scrobble":
  483. return (
  484. self.media_obj.scrobble_set.order_by("-timestamp")
  485. .filter(timestamp__lt=self.timestamp)
  486. .first()
  487. )
  488. @property
  489. def next(self) -> "Scrobble":
  490. return (
  491. self.media_obj.scrobble_set.order_by("timestamp")
  492. .filter(timestamp__gt=self.timestamp)
  493. .first()
  494. )
  495. @property
  496. def previous_all(self) -> "Scrobble":
  497. return (
  498. Scrobble.objects.filter(media_type=self.media_type)
  499. .order_by("-timestamp")
  500. .filter(timestamp__lt=self.timestamp)
  501. .first()
  502. )
  503. @property
  504. def next_all(self) -> "Scrobble":
  505. return (
  506. Scrobble.objects.filter(media_type=self.media_type)
  507. .order_by("-timestamp")
  508. .filter(timestamp__gt=self.timestamp)
  509. .first()
  510. )
  511. @property
  512. def previous_all_media(self) -> "Scrobble":
  513. return (
  514. Scrobble.objects.order_by("-timestamp")
  515. .filter(timestamp__lt=self.timestamp)
  516. .first()
  517. )
  518. @property
  519. def next_all_media(self) -> "Scrobble":
  520. return (
  521. Scrobble.objects.order_by("-timestamp")
  522. .filter(timestamp__gt=self.timestamp)
  523. .first()
  524. )
  525. @property
  526. def session_pages_read(self) -> Optional[int]:
  527. """Look one scrobble back, if it isn't complete,"""
  528. if not self.book_pages_read:
  529. return
  530. if self.previous:
  531. return self.book_pages_read - self.previous.book_pages_read
  532. return self.book_pages_read
  533. @property
  534. def is_long_play(self) -> bool:
  535. return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
  536. @property
  537. def percent_played(self) -> int:
  538. if not self.media_obj:
  539. return 0
  540. if self.media_obj and not self.media_obj.run_time_seconds:
  541. return 100
  542. if not self.playback_position_seconds and self.played_to_completion:
  543. return 100
  544. playback_seconds = self.playback_position_seconds
  545. if not playback_seconds:
  546. playback_seconds = (timezone.now() - self.timestamp).seconds
  547. run_time_secs = self.media_obj.run_time_seconds
  548. percent = int((playback_seconds / run_time_secs) * 100)
  549. if self.is_long_play:
  550. long_play_secs = 0
  551. if self.previous and not self.previous.long_play_complete:
  552. long_play_secs = self.previous.long_play_seconds or 0
  553. percent = int(
  554. ((playback_seconds + long_play_secs) / run_time_secs) * 100
  555. )
  556. # if percent > 100:
  557. # percent = 100
  558. return percent
  559. @property
  560. def can_be_updated(self) -> bool:
  561. updatable = True
  562. if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
  563. logger.info(f"No - Long play media")
  564. updatable = False
  565. if self.percent_played >= 100:
  566. logger.info(f"No - 100% played - {self.id} - {self.source}")
  567. updatable = False
  568. if self.is_stale:
  569. logger.info(f"No - stale - {self.id} - {self.source}")
  570. updatable = False
  571. if self.media_obj.__class__.__name__ in ["GeoLocation"]:
  572. if self.previous_all:
  573. last_location = self.previous_all.media_obj
  574. logger.info(
  575. f"Calculate proximity to last scrobble: {last_location}"
  576. )
  577. same_lat = last_location.lat == self.media_obj.lat
  578. same_lon = last_location.lon == self.media_obj.lon
  579. same_title = False
  580. if self.media_obj.title:
  581. same_title = last_location.title == self.media_obj.title
  582. logger.info(f"{self.timestamp}")
  583. logger.info(
  584. f"Last lat: {last_location.lat}, last long {last_location.lon}, last title {last_location.title}"
  585. )
  586. logger.info(
  587. f"Our lat {self.media_obj.lat}, Our lon {self.media_obj.lon} or our title {self.media_obj.title}"
  588. )
  589. if (same_lat and same_lon) or same_title:
  590. logger.info(
  591. f"Yes - We're in the same place: {self.media_obj}"
  592. )
  593. updatable = True
  594. else:
  595. logger.info(
  596. f"No - We've moved, start a new scrobble: {self.media_obj}"
  597. )
  598. # Stop the previous location scrobble
  599. self.previous_all.stop()
  600. updatable = False
  601. return updatable
  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. media_class = media.__class__.__name__
  645. dup = None
  646. media_query = models.Q(track=media)
  647. if media_class == "Track":
  648. scrobble_data["track_id"] = media.id
  649. if media_class == "Video":
  650. media_query = models.Q(video=media)
  651. scrobble_data["video_id"] = media.id
  652. if media_class == "Episode":
  653. media_query = models.Q(podcast_episode=media)
  654. scrobble_data["podcast_episode_id"] = media.id
  655. if media_class == "SportEvent":
  656. media_query = models.Q(sport_event=media)
  657. scrobble_data["sport_event_id"] = media.id
  658. if media_class == "Book":
  659. media_query = models.Q(book=media)
  660. scrobble_data["book_id"] = media.id
  661. if media_class == "VideoGame":
  662. media_query = models.Q(video_game=media)
  663. scrobble_data["video_game_id"] = media.id
  664. if media_class == "BoardGame":
  665. media_query = models.Q(board_game=media)
  666. scrobble_data["board_game_id"] = media.id
  667. if media_class == "WebPage":
  668. media_query = models.Q(webpage=media)
  669. scrobble_data["webpage_id"] = media.id
  670. if media_class == "GeoLocation":
  671. media_query = models.Q(media_type=Scrobble.MediaType.GEO_LOCATION)
  672. scrobble_data["geo_location_id"] = media.id
  673. dup = cls.objects.filter(
  674. media_type=cls.MediaType.GEO_LOCATION,
  675. timestamp=scrobble_data.get("timestamp"),
  676. ).first()
  677. if dup:
  678. logger.info(
  679. "[scrobbling] scrobble for geo location with identical timestamp found"
  680. )
  681. return dup
  682. scrobble = (
  683. cls.objects.filter(
  684. media_query,
  685. user_id=user_id,
  686. )
  687. .order_by("-timestamp")
  688. .first()
  689. )
  690. if scrobble and scrobble.can_be_updated:
  691. source = scrobble_data["source"]
  692. mtype = media.__class__.__name__
  693. logger.info(
  694. f"[scrobbling] updating {scrobble.id} for {mtype} {media.id} from {source}",
  695. {"scrobble_data": scrobble_data, "media": media},
  696. )
  697. return scrobble.update(scrobble_data)
  698. # Discard status before creating
  699. scrobble_data.pop("mopidy_status", None)
  700. scrobble_data.pop("jellyfin_status", None)
  701. source = scrobble_data["source"]
  702. mtype = media.__class__.__name__
  703. logger.info(
  704. f"[scrobbling] creating for {mtype} {media.id} from {source}"
  705. )
  706. return cls.create(scrobble_data)
  707. def update(self, scrobble_data: dict) -> "Scrobble":
  708. # Status is a field we get from Mopidy, which refuses to poll us
  709. scrobble_status = scrobble_data.pop("mopidy_status", None)
  710. if not scrobble_status:
  711. scrobble_status = scrobble_data.pop("jellyfin_status", None)
  712. if self.percent_played < 100:
  713. # Only worry about ticks if we haven't gotten to the end
  714. self.update_ticks(scrobble_data)
  715. # On stop, stop progress and send it to the check for completion
  716. if scrobble_status == "stopped":
  717. self.stop()
  718. # On pause, set is_paused and stop scrobbling
  719. if scrobble_status == "paused":
  720. self.pause()
  721. if scrobble_status == "resumed":
  722. self.resume()
  723. check_scrobble_for_finish(self)
  724. if scrobble_status != "resumed":
  725. scrobble_data["stop_timestamp"] = (
  726. scrobble_data.pop("timestamp", None) or timezone.now()
  727. )
  728. scrobble_data.pop("timestamp", None)
  729. update_fields = []
  730. for key, value in scrobble_data.items():
  731. setattr(self, key, value)
  732. update_fields.append(key)
  733. self.save(update_fields=update_fields)
  734. return self
  735. @classmethod
  736. def create(
  737. cls,
  738. scrobble_data: dict,
  739. ) -> "Scrobble":
  740. scrobble_data["scrobble_log"] = ""
  741. scrobble = cls.objects.create(
  742. **scrobble_data,
  743. )
  744. return scrobble
  745. def stop(self, force_finish=False) -> None:
  746. self.stop_timestamp = timezone.now()
  747. if force_finish:
  748. self.played_to_completion = True
  749. self.in_progress = False
  750. if not self.playback_position_seconds:
  751. self.playback_position_seconds = int(
  752. (self.stop_timestamp - self.timestamp).total_seconds()
  753. )
  754. self.save(
  755. update_fields=[
  756. "in_progress",
  757. "played_to_completion",
  758. "stop_timestamp",
  759. "playback_position_seconds",
  760. ]
  761. )
  762. logger.info(f"stopping {self.id} from {self.source}")
  763. class_name = self.media_obj.__class__.__name__
  764. if class_name in LONG_PLAY_MEDIA.values():
  765. check_long_play_for_finish(self)
  766. def pause(self) -> None:
  767. if self.is_paused:
  768. logger.warning(f"{self.id} - already paused - {self.source}")
  769. return
  770. logger.info(f"{self.id} - pausing - {self.source}")
  771. self.is_paused = True
  772. self.save(update_fields=["is_paused"])
  773. def resume(self) -> None:
  774. if self.is_paused or not self.in_progress:
  775. self.is_paused = False
  776. self.in_progress = True
  777. logger.info(f"{self.id} - resuming - {self.source}")
  778. return self.save(update_fields=["is_paused", "in_progress"])
  779. def cancel(self) -> None:
  780. check_scrobble_for_finish(self, force_finish=True)
  781. self.delete()
  782. def update_ticks(self, data) -> None:
  783. self.playback_position_seconds = data.get("playback_position_seconds")
  784. logger.info(
  785. f"{self.id} - {self.playback_position_seconds} - {self.source}"
  786. )
  787. self.save(update_fields=["playback_position_seconds"])
  788. class ScrobbledPage(TimeStampedModel):
  789. scrobble = models.ForeignKey(Scrobble, on_delete=models.DO_NOTHING)
  790. number = models.IntegerField()
  791. start_time = models.DateTimeField(**BNULL)
  792. end_time = models.DateTimeField(**BNULL)
  793. duration_seconds = models.IntegerField(**BNULL)
  794. notes = models.CharField(max_length=255, **BNULL)
  795. def __str__(self):
  796. return f"Page {self.number} of {self.book.pages} in {self.book.title}"
  797. def save(self, *args, **kwargs):
  798. if not self.end_time and self.duration_seconds:
  799. self._set_end_time()
  800. return super(ScrobbledPage, self).save(*args, **kwargs)
  801. @cached_property
  802. def book(self):
  803. return self.scrobble.book
  804. @property
  805. def next(self):
  806. user_pages_qs = self.book.scrobbledpage_set.filter(
  807. user=self.scrobble.user
  808. )
  809. page = user_pages_qs.filter(number=self.number + 1).first()
  810. if not page:
  811. page = (
  812. user_pages_qs.filter(created__gt=self.created)
  813. .order_by("created")
  814. .first()
  815. )
  816. return page
  817. @property
  818. def previous(self):
  819. user_pages_qs = self.book.scrobbledpage_set.filter(
  820. user=self.scrobble.user
  821. )
  822. page = user_pages_qs.filter(number=self.number - 1).first()
  823. if not page:
  824. page = (
  825. user_pages_qs.filter(created__lt=self.created)
  826. .order_by("-created")
  827. .first()
  828. )
  829. return page
  830. @property
  831. def seconds_to_next_page(self) -> int:
  832. seconds = 999999 # Effectively infnity time as we have no next
  833. if not self.end_time:
  834. self._set_end_time()
  835. if self.next:
  836. seconds = (self.next.start_time - self.end_time).seconds
  837. return seconds
  838. @property
  839. def is_scrobblable(self) -> bool:
  840. """A page defines the start of a scrobble if the seconds to next page
  841. are greater than an hour, or 3600 seconds, and it's not a single page,
  842. so the next seconds to next_page is less than an hour as well.
  843. As a special case, the first recorded page is a scrobble, so we establish
  844. when the book was started.
  845. """
  846. is_scrobblable = False
  847. over_an_hour_since_last_page = False
  848. if not self.previous:
  849. is_scrobblable = True
  850. if self.previous:
  851. over_an_hour_since_last_page = (
  852. self.previous.seconds_to_next_page >= 3600
  853. )
  854. blip = self.seconds_to_next_page >= 3600
  855. if over_an_hour_since_last_page and not blip:
  856. is_scrobblable = True
  857. return is_scrobblable
  858. def _set_end_time(self) -> None:
  859. if self.end_time:
  860. return
  861. self.end_time = self.start_time + datetime.timedelta(
  862. seconds=self.duration_seconds
  863. )
  864. self.save(update_fields=["end_time"])