models.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200
  1. import calendar
  2. import datetime
  3. import logging
  4. from decimal import Decimal
  5. from typing import Iterable, Optional
  6. from uuid import uuid4
  7. import pendulum
  8. from boardgames.models import BoardGame
  9. from books.koreader import process_koreader_sqlite_file
  10. from books.models import Book
  11. from django.conf import settings
  12. from django.contrib.auth import get_user_model
  13. from django.db import models
  14. from django.urls import reverse
  15. from django.utils import timezone
  16. from django.utils.functional import cached_property
  17. from django_extensions.db.models import TimeStampedModel
  18. from imagekit.models import ImageSpecField
  19. from imagekit.processors import ResizeToFit
  20. from locations.models import GeoLocation
  21. from music.lastfm import LastFM
  22. from music.models import Artist, Track
  23. from podcasts.models import PodcastEpisode
  24. from profiles.utils import (
  25. end_of_day,
  26. end_of_month,
  27. end_of_week,
  28. start_of_day,
  29. start_of_month,
  30. start_of_week,
  31. )
  32. from scrobbles.constants import LONG_PLAY_MEDIA
  33. from scrobbles.stats import build_charts
  34. from scrobbles.utils import media_class_to_foreign_key
  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. from vrobbler.apps.scrobbles.constants import MEDIA_END_PADDING_SECONDS
  41. logger = logging.getLogger(__name__)
  42. User = get_user_model()
  43. BNULL = {"blank": True, "null": True}
  44. POINTS_FOR_MOVEMENT_HISTORY = int(
  45. getattr(settings, "POINTS_FOR_MOVEMENT_HISTORY", 3)
  46. )
  47. class BaseFileImportMixin(TimeStampedModel):
  48. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  49. uuid = models.UUIDField(editable=False, default=uuid4)
  50. processing_started = models.DateTimeField(**BNULL)
  51. processed_finished = models.DateTimeField(**BNULL)
  52. process_log = models.TextField(**BNULL)
  53. process_count = models.IntegerField(**BNULL)
  54. class Meta:
  55. abstract = True
  56. def __str__(self):
  57. return f"{self.import_type} import on {self.human_start}"
  58. @property
  59. def human_start(self):
  60. start = "Unknown"
  61. if self.processing_started:
  62. start = self.processing_started.strftime("%B %d, %Y at %H:%M")
  63. return start
  64. @property
  65. def import_type(self) -> str:
  66. return "Unknown Import Source"
  67. def process(self, force=False):
  68. logger.warning("Process not implemented")
  69. def undo(self, dryrun=False):
  70. """Accepts the log from a scrobble import and removes the scrobbles"""
  71. from scrobbles.models import Scrobble
  72. if not self.process_log:
  73. logger.warning("No lines in process log found to undo")
  74. return
  75. for line in self.process_log.split("\n"):
  76. scrobble_id = line.split("\t")[0]
  77. scrobble = Scrobble.objects.filter(id=scrobble_id).first()
  78. if not scrobble:
  79. logger.warning(
  80. f"Could not find scrobble {scrobble_id} to undo"
  81. )
  82. continue
  83. logger.info(f"Removing scrobble {scrobble_id}")
  84. if not dryrun:
  85. scrobble.delete()
  86. self.processed_finished = None
  87. self.processing_started = None
  88. self.process_count = None
  89. self.process_log = ""
  90. self.save(
  91. update_fields=[
  92. "processed_finished",
  93. "processing_started",
  94. "process_log",
  95. "process_count",
  96. ]
  97. )
  98. def scrobbles(self) -> models.QuerySet:
  99. scrobble_ids = []
  100. if self.process_log:
  101. for line in self.process_log.split("\n"):
  102. sid = line.split("\t")[0]
  103. if sid:
  104. scrobble_ids.append(sid)
  105. return Scrobble.objects.filter(id__in=scrobble_ids)
  106. def mark_started(self):
  107. self.processing_started = timezone.now()
  108. self.save(update_fields=["processing_started"])
  109. def mark_finished(self):
  110. self.processed_finished = timezone.now()
  111. self.save(update_fields=["processed_finished"])
  112. def record_log(self, scrobbles):
  113. self.process_log = ""
  114. if not scrobbles:
  115. self.process_count = 0
  116. self.save(update_fields=["process_log", "process_count"])
  117. return
  118. for count, scrobble in enumerate(scrobbles):
  119. scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj.title}"
  120. log_line = f"{scrobble_str}"
  121. if count > 0:
  122. log_line = "\n" + log_line
  123. self.process_log += log_line
  124. self.process_count = len(scrobbles)
  125. self.save(update_fields=["process_log", "process_count"])
  126. @property
  127. def upload_file_path(self):
  128. raise NotImplementedError
  129. class KoReaderImport(BaseFileImportMixin):
  130. class Meta:
  131. verbose_name = "KOReader Import"
  132. @property
  133. def import_type(self) -> str:
  134. return "KOReader"
  135. def get_absolute_url(self):
  136. return reverse(
  137. "scrobbles:koreader-import-detail", kwargs={"slug": self.uuid}
  138. )
  139. def get_path(instance, filename):
  140. extension = filename.split(".")[-1]
  141. uuid = instance.uuid
  142. return f"koreader-uploads/{uuid}.{extension}"
  143. @property
  144. def upload_file_path(self) -> str:
  145. if getattr(settings, "USE_S3_STORAGE"):
  146. path = self.sqlite_file.url
  147. else:
  148. path = self.sqlite_file.path
  149. return path
  150. sqlite_file = models.FileField(upload_to=get_path, **BNULL)
  151. def process(self, force=False):
  152. if self.processed_finished and not force:
  153. logger.info(
  154. f"{self} already processed on {self.processed_finished}"
  155. )
  156. return
  157. self.mark_started()
  158. scrobbles = process_koreader_sqlite_file(
  159. self.upload_file_path, self.user.id
  160. )
  161. self.record_log(scrobbles)
  162. self.mark_finished()
  163. class AudioScrobblerTSVImport(BaseFileImportMixin):
  164. class Meta:
  165. verbose_name = "AudioScrobbler TSV Import"
  166. @property
  167. def import_type(self) -> str:
  168. return "AudiosScrobbler"
  169. def get_absolute_url(self):
  170. return reverse(
  171. "scrobbles:tsv-import-detail", kwargs={"slug": self.uuid}
  172. )
  173. def get_path(instance, filename):
  174. extension = filename.split(".")[-1]
  175. uuid = instance.uuid
  176. return f"audioscrobbler-uploads/{uuid}.{extension}"
  177. @property
  178. def upload_file_path(self):
  179. if getattr(settings, "USE_S3_STORAGE"):
  180. path = self.tsv_file.url
  181. else:
  182. path = self.tsv_file.path
  183. return path
  184. tsv_file = models.FileField(upload_to=get_path, **BNULL)
  185. def process(self, force=False):
  186. from scrobbles.tsv import process_audioscrobbler_tsv_file
  187. if self.processed_finished and not force:
  188. logger.info(
  189. f"{self} already processed on {self.processed_finished}"
  190. )
  191. return
  192. self.mark_started()
  193. tz = None
  194. user_id = None
  195. if self.user:
  196. user_id = self.user.id
  197. tz = self.user.profile.tzinfo
  198. scrobbles = process_audioscrobbler_tsv_file(
  199. self.upload_file_path, user_id, user_tz=tz
  200. )
  201. self.record_log(scrobbles)
  202. self.mark_finished()
  203. class LastFmImport(BaseFileImportMixin):
  204. class Meta:
  205. verbose_name = "Last.FM Import"
  206. @property
  207. def import_type(self) -> str:
  208. return "LastFM"
  209. def get_absolute_url(self):
  210. return reverse(
  211. "scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid}
  212. )
  213. def process(self, import_all=False):
  214. """Import scrobbles found on LastFM"""
  215. if self.processed_finished:
  216. logger.info(
  217. f"{self} already processed on {self.processed_finished}"
  218. )
  219. return
  220. last_import = None
  221. if not import_all:
  222. try:
  223. last_import = LastFmImport.objects.exclude(id=self.id).last()
  224. except:
  225. pass
  226. if not import_all and not last_import:
  227. logger.warn(
  228. "No previous import, to import all Last.fm scrobbles, pass import_all=True"
  229. )
  230. return
  231. lastfm = LastFM(self.user)
  232. last_processed = None
  233. if last_import:
  234. last_processed = last_import.processed_finished
  235. self.mark_started()
  236. scrobbles = lastfm.import_from_lastfm(last_processed)
  237. self.record_log(scrobbles)
  238. self.mark_finished()
  239. class RetroarchImport(BaseFileImportMixin):
  240. class Meta:
  241. verbose_name = "Retroarch Import"
  242. @property
  243. def import_type(self) -> str:
  244. return "Retroarch"
  245. def get_absolute_url(self):
  246. return reverse(
  247. "scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid}
  248. )
  249. def process(self, import_all=False, force=False):
  250. """Import scrobbles found on Retroarch"""
  251. if self.processed_finished and not force:
  252. logger.info(
  253. f"{self} already processed on {self.processed_finished}"
  254. )
  255. return
  256. if force:
  257. logger.info(f"You told me to force import from Retroarch")
  258. if not self.user.profile.retroarch_path:
  259. logger.info(
  260. "Tying to import Retroarch logs, but user has no retroarch_path configured"
  261. )
  262. self.mark_started()
  263. scrobbles = retroarch.import_retroarch_lrtl_files(
  264. self.user.profile.retroarch_path,
  265. self.user.id,
  266. )
  267. self.record_log(scrobbles)
  268. self.mark_finished()
  269. class ChartRecord(TimeStampedModel):
  270. """Sort of like a materialized view for what we could dynamically generate,
  271. but would kill the DB as it gets larger. Collects time-based records
  272. generated by a cron-like archival job
  273. 1972 by Josh Rouse - #3 in 2023, January
  274. """
  275. user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
  276. rank = models.IntegerField(db_index=True)
  277. count = models.IntegerField(default=0)
  278. year = models.IntegerField(**BNULL)
  279. month = models.IntegerField(**BNULL)
  280. week = models.IntegerField(**BNULL)
  281. day = models.IntegerField(**BNULL)
  282. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  283. series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  284. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
  285. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  286. period_start = models.DateTimeField(**BNULL)
  287. period_end = models.DateTimeField(**BNULL)
  288. def save(self, *args, **kwargs):
  289. profile = self.user.profile
  290. if self.week:
  291. # set start and end to start and end of week
  292. period = datetime.date.fromisocalendar(self.year, self.week, 1)
  293. self.period_start = start_of_week(period, profile)
  294. self.period_start = end_of_week(period, profile)
  295. if self.day:
  296. period = datetime.datetime(self.year, self.month, self.day)
  297. self.period_start = start_of_day(period, profile)
  298. self.period_end = end_of_day(period, profile)
  299. if self.month and not self.day:
  300. period = datetime.datetime(self.year, self.month, 1)
  301. self.period_start = start_of_month(period, profile)
  302. self.period_end = end_of_month(period, profile)
  303. super(ChartRecord, self).save(*args, **kwargs)
  304. @property
  305. def media_obj(self):
  306. media_obj = None
  307. if self.video:
  308. media_obj = self.video
  309. if self.track:
  310. media_obj = self.track
  311. if self.artist:
  312. media_obj = self.artist
  313. return media_obj
  314. @property
  315. def month_str(self) -> str:
  316. month_str = ""
  317. if self.month:
  318. month_str = calendar.month_name[self.month]
  319. return month_str
  320. @property
  321. def day_str(self) -> str:
  322. day_str = ""
  323. if self.day:
  324. day_str = str(self.day)
  325. return day_str
  326. @property
  327. def week_str(self) -> str:
  328. week_str = ""
  329. if self.week:
  330. week_str = str(self.week)
  331. return "Week " + week_str
  332. @property
  333. def period(self) -> str:
  334. period = str(self.year)
  335. if self.month:
  336. period = " ".join([self.month_str, period])
  337. if self.week:
  338. period = " ".join([self.week_str, period])
  339. if self.day:
  340. period = " ".join([self.day_str, period])
  341. return period
  342. @property
  343. def period_type(self) -> str:
  344. period = "year"
  345. if self.month:
  346. period = "month"
  347. if self.week:
  348. period = "week"
  349. if self.day:
  350. period = "day"
  351. return period
  352. def __str__(self):
  353. title = f"#{self.rank} in {self.period}"
  354. if self.day or self.week:
  355. title = f"#{self.rank} on {self.period}"
  356. return title
  357. def link(self):
  358. get_params = f"?date={self.year}"
  359. if self.week:
  360. get_params = get_params = get_params + f"-W{self.week}"
  361. if self.month:
  362. get_params = get_params = get_params + f"-{self.month}"
  363. if self.day:
  364. get_params = get_params = get_params + f"-{self.day}"
  365. if self.artist:
  366. get_params = get_params + "&media=Artist"
  367. return reverse("scrobbles:charts-home") + get_params
  368. @classmethod
  369. def build(cls, user, **kwargs):
  370. build_charts(user=user, **kwargs)
  371. @classmethod
  372. def for_year(cls, user, year):
  373. return cls.objects.filter(year=year, user=user)
  374. @classmethod
  375. def for_month(cls, user, year, month):
  376. return cls.objects.filter(year=year, month=month, user=user)
  377. @classmethod
  378. def for_day(cls, user, year, day, month):
  379. return cls.objects.filter(year=year, month=month, day=day, user=user)
  380. @classmethod
  381. def for_week(cls, user, year, week):
  382. return cls.objects.filter(year=year, week=week, user=user)
  383. class Scrobble(TimeStampedModel):
  384. """A scrobble tracks played media items by a user."""
  385. class MediaType(models.TextChoices):
  386. """Enum mapping a media model type to a string"""
  387. VIDEO = "Video", "Video"
  388. TRACK = "Track", "Track"
  389. PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
  390. SPORT_EVENT = "SportEvent", "Sport event"
  391. BOOK = "Book", "Book"
  392. VIDEO_GAME = "VideoGame", "Video game"
  393. BOARD_GAME = "BoardGame", "Board game"
  394. GEO_LOCATION = "GeoLocation", "GeoLocation"
  395. WEBPAGE = "WebPage", "Web Page"
  396. uuid = models.UUIDField(editable=False, **BNULL)
  397. video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
  398. track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
  399. podcast_episode = models.ForeignKey(
  400. PodcastEpisode, on_delete=models.DO_NOTHING, **BNULL
  401. )
  402. sport_event = models.ForeignKey(
  403. SportEvent, on_delete=models.DO_NOTHING, **BNULL
  404. )
  405. book = models.ForeignKey(Book, on_delete=models.DO_NOTHING, **BNULL)
  406. video_game = models.ForeignKey(
  407. VideoGame, on_delete=models.DO_NOTHING, **BNULL
  408. )
  409. board_game = models.ForeignKey(
  410. BoardGame, on_delete=models.DO_NOTHING, **BNULL
  411. )
  412. geo_location = models.ForeignKey(
  413. GeoLocation, on_delete=models.DO_NOTHING, **BNULL
  414. )
  415. web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL)
  416. media_type = models.CharField(
  417. max_length=14, choices=MediaType.choices, default=MediaType.VIDEO
  418. )
  419. user = models.ForeignKey(
  420. User, blank=True, null=True, on_delete=models.DO_NOTHING
  421. )
  422. # Time keeping
  423. timestamp = models.DateTimeField(**BNULL)
  424. stop_timestamp = models.DateTimeField(**BNULL)
  425. playback_position_ticks = models.PositiveBigIntegerField(**BNULL)
  426. playback_position_seconds = models.IntegerField(**BNULL)
  427. # Status indicators
  428. is_paused = models.BooleanField(default=False)
  429. played_to_completion = models.BooleanField(default=False)
  430. in_progress = models.BooleanField(default=True)
  431. # Metadata
  432. source = models.CharField(max_length=255, **BNULL)
  433. source_id = models.TextField(**BNULL)
  434. scrobble_log = models.TextField(**BNULL)
  435. notes = models.TextField(**BNULL)
  436. # Fields for keeping track of book data
  437. book_koreader_hash = models.CharField(max_length=50, **BNULL)
  438. book_pages_read = models.IntegerField(**BNULL)
  439. book_page_data = models.JSONField(**BNULL)
  440. # Fields for keeping track of video game data
  441. videogame_save_data = models.FileField(
  442. upload_to="scrobbles/videogame_save_data/", **BNULL
  443. )
  444. videogame_screenshot = models.ImageField(
  445. upload_to="scrobbles/videogame_screenshot/", **BNULL
  446. )
  447. videogame_screenshot_small = ImageSpecField(
  448. source="videogame_screenshot",
  449. processors=[ResizeToFit(100, 100)],
  450. format="JPEG",
  451. options={"quality": 60},
  452. )
  453. videogame_screenshot_medium = ImageSpecField(
  454. source="videogame_screenshot",
  455. processors=[ResizeToFit(300, 300)],
  456. format="JPEG",
  457. options={"quality": 75},
  458. )
  459. long_play_seconds = models.BigIntegerField(**BNULL)
  460. long_play_complete = models.BooleanField(**BNULL)
  461. def save(self, *args, **kwargs):
  462. if not self.uuid:
  463. self.uuid = uuid4()
  464. # Microseconds mess up Django's filtering, and we don't need be that specific
  465. if self.timestamp:
  466. self.timestamp = self.timestamp.replace(microsecond=0)
  467. self.media_type = self.MediaType(self.media_obj.__class__.__name__)
  468. return super(Scrobble, self).save(*args, **kwargs)
  469. @property
  470. def scrobble_media_key(self) -> str:
  471. return media_class_to_foreign_key(self.media_type) + "_id"
  472. @property
  473. def status(self) -> str:
  474. if self.is_paused:
  475. return "paused"
  476. if self.played_to_completion:
  477. return "finished"
  478. if self.in_progress:
  479. return "in-progress"
  480. return "zombie"
  481. @property
  482. def is_stale(self) -> bool:
  483. """Mark scrobble as stale if it's been more than an hour since it was updated"""
  484. is_stale = False
  485. now = timezone.now()
  486. seconds_since_last_update = (now - self.modified).seconds
  487. if seconds_since_last_update >= self.media_obj.SECONDS_TO_STALE:
  488. is_stale = True
  489. return is_stale
  490. @property
  491. def previous(self) -> "Scrobble":
  492. return (
  493. self.media_obj.scrobble_set.order_by("-timestamp")
  494. .filter(timestamp__lt=self.timestamp)
  495. .first()
  496. )
  497. @property
  498. def next(self) -> "Scrobble":
  499. return (
  500. self.media_obj.scrobble_set.order_by("timestamp")
  501. .filter(timestamp__gt=self.timestamp)
  502. .first()
  503. )
  504. @property
  505. def previous_by_media(self) -> "Scrobble":
  506. return (
  507. Scrobble.objects.filter(
  508. media_type=self.media_type,
  509. user=self.user,
  510. timestamp__lt=self.timestamp,
  511. )
  512. .order_by("-timestamp")
  513. .first()
  514. )
  515. @property
  516. def next_by_media(self) -> "Scrobble":
  517. return (
  518. Scrobble.objects.filter(
  519. media_type=self.media_type,
  520. user=self.user,
  521. timestamp__gt=self.timestamp,
  522. )
  523. .order_by("-timestamp")
  524. .first()
  525. )
  526. @property
  527. def previous_by_user(self) -> "Scrobble":
  528. return (
  529. Scrobble.objects.order_by("-timestamp")
  530. .filter(timestamp__lt=self.timestamp)
  531. .first()
  532. )
  533. @property
  534. def next_by_user(self) -> "Scrobble":
  535. return (
  536. Scrobble.objects.order_by("-timestamp")
  537. .filter(timestamp__gt=self.timestamp)
  538. .first()
  539. )
  540. @property
  541. def session_pages_read(self) -> Optional[int]:
  542. """Look one scrobble back, if it isn't complete,"""
  543. if not self.book_pages_read:
  544. return
  545. if self.previous:
  546. return self.book_pages_read - self.previous.book_pages_read
  547. return self.book_pages_read
  548. @property
  549. def is_long_play(self) -> bool:
  550. return self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values()
  551. @property
  552. def percent_played(self) -> int:
  553. if not self.media_obj:
  554. return 0
  555. if self.media_obj and not self.media_obj.run_time_seconds:
  556. return 100
  557. if not self.playback_position_seconds and self.played_to_completion:
  558. return 100
  559. playback_seconds = self.playback_position_seconds
  560. if not playback_seconds:
  561. playback_seconds = (timezone.now() - self.timestamp).seconds
  562. run_time_secs = self.media_obj.run_time_seconds
  563. percent = int((playback_seconds / run_time_secs) * 100)
  564. if self.is_long_play:
  565. long_play_secs = 0
  566. if self.previous and not self.previous.long_play_complete:
  567. long_play_secs = self.previous.long_play_seconds or 0
  568. percent = int(
  569. ((playback_seconds + long_play_secs) / run_time_secs) * 100
  570. )
  571. return percent
  572. @property
  573. def probably_still_in_progress(self) -> bool:
  574. """Add our start time to our media run time to get when we expect to
  575. Audio tracks should be given a second or two of grace, videos should
  576. be given closer to 30 minutes, because the odds of watching it back to
  577. back are very slim.
  578. """
  579. is_in_progress = False
  580. padding_seconds = MEDIA_END_PADDING_SECONDS.get(self.media_type)
  581. if not padding_seconds:
  582. return is_in_progress
  583. expected_end = self.timestamp + datetime.timedelta(
  584. seconds=self.media_obj.run_time_seconds
  585. )
  586. expected_end_padded = expected_end + datetime.timedelta(
  587. seconds=padding_seconds
  588. )
  589. # Take our start time, add our media length and an extra 30 min (1800s) is it still in the future? keep going
  590. is_in_progress = expected_end_padded > pendulum.now()
  591. logger.info(
  592. "[scrobbling] checking if we're probably still playing",
  593. extra={
  594. "media_id": self.media_obj.id,
  595. "scrobble_id": self.id,
  596. "media_type": self.media_type,
  597. "probably_still_in_progress": is_in_progress,
  598. },
  599. )
  600. return is_in_progress
  601. @property
  602. def can_be_updated(self) -> bool:
  603. if self.media_obj.__class__.__name__ in LONG_PLAY_MEDIA.values():
  604. logger.info(
  605. "[scrobbling] cannot be updated, long play media",
  606. extra={
  607. "media_id": self.media_obj.id,
  608. "scrobble_id": self.id,
  609. "media_type": self.media_type,
  610. },
  611. )
  612. return False
  613. if self.percent_played >= 100 and not self.probably_still_in_progress:
  614. logger.info(
  615. "[scrobbling] cannot be updated, existing scrobble is 100% played",
  616. extra={
  617. "media_id": self.media_obj.id,
  618. "scrobble_id": self.id,
  619. "media_type": self.media_type,
  620. },
  621. )
  622. return False
  623. if self.is_stale:
  624. logger.info(
  625. "[scrobbling] cannot be udpated, stale",
  626. extra={
  627. "media_id": self.media_obj.id,
  628. "scrobble_id": self.id,
  629. "media_type": self.media_type,
  630. },
  631. )
  632. return False
  633. logger.info(
  634. "[scrobbling] can be updated",
  635. extra={
  636. "media_id": self.media_obj.id,
  637. "scrobble_id": self.id,
  638. "media_type": self.media_type,
  639. },
  640. )
  641. return True
  642. @property
  643. def media_obj(self):
  644. media_obj = None
  645. if self.video:
  646. media_obj = self.video
  647. if self.track:
  648. media_obj = self.track
  649. if self.podcast_episode:
  650. media_obj = self.podcast_episode
  651. if self.sport_event:
  652. media_obj = self.sport_event
  653. if self.book:
  654. media_obj = self.book
  655. if self.video_game:
  656. media_obj = self.video_game
  657. if self.board_game:
  658. media_obj = self.board_game
  659. if self.geo_location:
  660. media_obj = self.geo_location
  661. if self.web_page:
  662. media_obj = self.web_page
  663. return media_obj
  664. def __str__(self):
  665. timestamp = self.timestamp.strftime("%Y-%m-%d")
  666. return f"Scrobble of {self.media_obj} ({timestamp})"
  667. def calc_reading_duration(self) -> int:
  668. duration = 0
  669. if self.book_page_data:
  670. for k, v in self.book_page_data.items():
  671. duration += v.get("duration")
  672. return duration
  673. def calc_pages_read(self) -> int:
  674. pages_read = 0
  675. if self.book_page_data:
  676. pages = [int(k) for k in self.book_page_data.keys()]
  677. pages.sort()
  678. if len(pages) == 1:
  679. pages_read = 1
  680. elif len(pages) >= 2:
  681. pages_read += pages[-1] - pages[0]
  682. else:
  683. pages_read = pages[-1] - pages[0]
  684. return pages_read
  685. @property
  686. def last_page_read(self) -> int:
  687. last_page = 0
  688. if self.book_page_data:
  689. pages = [int(k) for k in self.book_page_data.keys()]
  690. pages.sort()
  691. last_page = pages[-1]
  692. return last_page
  693. @classmethod
  694. def create_or_update(
  695. cls, media, user_id: int, scrobble_data: dict, **kwargs
  696. ) -> "Scrobble":
  697. key = media_class_to_foreign_key(media.__class__.__name__)
  698. media_query = models.Q(**{key: media})
  699. scrobble_data[key + "_id"] = media.id
  700. # Find our last scrobble of this media item (track, video, etc)
  701. scrobble = (
  702. cls.objects.filter(
  703. media_query,
  704. user_id=user_id,
  705. )
  706. .order_by("-timestamp")
  707. .first()
  708. )
  709. source = scrobble_data["source"]
  710. mtype = media.__class__.__name__
  711. mopidy_status = scrobble_data.get("mopidy_status", None)
  712. # GeoLocations are a special case scrobble
  713. if mtype == cls.MediaType.GEO_LOCATION:
  714. scrobble = cls.create_or_update_location(
  715. media, scrobble_data, user_id
  716. )
  717. return scrobble
  718. logger.info(
  719. f"[scrobbling] check for existing scrobble to update ",
  720. extra={
  721. "scrobble_id": scrobble.id if scrobble else None,
  722. "media_type": mtype,
  723. "media_id": media.id,
  724. "scrobble_data": scrobble_data,
  725. "percent_played": scrobble.percent_played if scrobble else 0,
  726. "can_be_updated": scrobble.can_be_updated
  727. if scrobble
  728. else False,
  729. },
  730. )
  731. if scrobble and (
  732. scrobble.can_be_updated or mopidy_status == "stopped"
  733. ):
  734. return scrobble.update(scrobble_data)
  735. # Discard status before creating
  736. scrobble_data.pop("mopidy_status", None)
  737. scrobble_data.pop("jellyfin_status", None)
  738. logger.info(
  739. f"[scrobbling] creating new scrobble",
  740. extra={
  741. "scrobble_id": scrobble.id if scrobble else None,
  742. "media_type": mtype,
  743. "media_id": media.id,
  744. "source": source,
  745. },
  746. )
  747. return cls.create(scrobble_data)
  748. @classmethod
  749. def create_or_update_location(
  750. cls, location: GeoLocation, scrobble_data: dict, user_id: int
  751. ) -> "Scrobble":
  752. """Location is special type, where the current scrobble for a user is always the
  753. current active scrobble, and we only finish it a move on if we get a new location
  754. that is far enough (and far enough over the last three past scrobbles) to have
  755. actually moved.
  756. """
  757. scrobble = (
  758. cls.objects.filter(
  759. media_type=cls.MediaType.GEO_LOCATION,
  760. user_id=user_id,
  761. timestamp__lte=scrobble_data.get("timestamp"),
  762. )
  763. .order_by("-timestamp")
  764. .first()
  765. )
  766. logger.info(
  767. f"[scrobbling] check for existing scrobble to update ",
  768. extra={
  769. "scrobble_id": scrobble.id if scrobble else None,
  770. "media_type": cls.MediaType.GEO_LOCATION,
  771. "media_id": location.id,
  772. "scrobble_data": scrobble_data,
  773. "percent_played": scrobble.percent_played if scrobble else 0,
  774. "can_be_updated": scrobble.can_be_updated
  775. if scrobble
  776. else False,
  777. },
  778. )
  779. if not scrobble:
  780. logger.info(
  781. f"[scrobbling] no existing location scrobbles, scrobble ahoy!",
  782. extra={
  783. "new_location_id": location.id,
  784. "media_type": cls.MediaType.GEO_LOCATION,
  785. },
  786. )
  787. return cls.create(scrobble_data)
  788. logger.info(
  789. f"[scrobbling] checking if last location matches current location",
  790. extra={
  791. "scrobble_id": scrobble.id,
  792. "media_type": cls.MediaType.GEO_LOCATION,
  793. "location_id": location.id,
  794. "scrobble_location_id": scrobble.media_obj.id,
  795. },
  796. )
  797. if scrobble.media_obj == location:
  798. logger.info(
  799. f"[scrobbling] last location and new location are the same, not moving",
  800. extra={
  801. "scrobble_id": scrobble.id,
  802. "media_type": cls.MediaType.GEO_LOCATION,
  803. "new_location_id": location.id,
  804. },
  805. )
  806. return scrobble
  807. jitter_correction_locations = cls.past_scrobbled_locations(
  808. user_id, POINTS_FOR_MOVEMENT_HISTORY
  809. )
  810. has_moved = location.has_moved(jitter_correction_locations)
  811. logger.info(
  812. f"[scrobbling] checking if last location has moved",
  813. extra={
  814. "scrobble_id": scrobble.id,
  815. "media_type": cls.MediaType.GEO_LOCATION,
  816. "location_id": location.id,
  817. "scrobble_location_id": scrobble.media_obj.id,
  818. "has_moved": has_moved,
  819. "past_locations_used": jitter_correction_locations,
  820. },
  821. )
  822. if not has_moved:
  823. logger.info(
  824. f"[scrobbling] new location received, but not far from old location, not moving",
  825. extra={
  826. "new_location_id": location.id,
  827. "media_type": cls.MediaType.GEO_LOCATION,
  828. "old_location_id": scrobble.media_obj.id,
  829. },
  830. )
  831. return scrobble
  832. scrobble.stop(force_finish=True)
  833. logger.info(
  834. f"[scrobbling] finish location scrobble, moved from old location",
  835. extra={
  836. "scrobble_id": scrobble.id,
  837. "media_type": cls.MediaType.GEO_LOCATION,
  838. "media_id": location.id,
  839. },
  840. )
  841. if existing_locations := location.in_proximity(named=True):
  842. existing_location = existing_locations.first()
  843. logger.info(
  844. f"[scrobbling] has moved but found existing named location, using it",
  845. extra={
  846. "media_id": location.id,
  847. "media_type": cls.MediaType.GEO_LOCATION,
  848. "existing_location_id": existing_location.id,
  849. },
  850. )
  851. scrobble_data["geo_location"] = existing_location
  852. scrobble = cls.create(scrobble_data)
  853. logger.info(
  854. f"[scrobbling] created for location",
  855. extra={
  856. "scrobble_id": scrobble.id,
  857. "media_id": location.id,
  858. "scrobble_data": scrobble_data,
  859. "media_type": cls.MediaType.GEO_LOCATION,
  860. "source": scrobble_data.get("source"),
  861. },
  862. )
  863. return scrobble
  864. @classmethod
  865. def past_scrobbled_locations(
  866. cls, user_id: int, num: int
  867. ) -> list["Location"]:
  868. past_scrobbles = cls.objects.filter(
  869. media_type="GeoLocation",
  870. user_id=user_id,
  871. ).order_by("-timestamp")[1:num]
  872. return [s.geo_location for s in past_scrobbles]
  873. def update(self, scrobble_data: dict) -> "Scrobble":
  874. # Status is a field we get from Mopidy, which refuses to poll us
  875. scrobble_status = scrobble_data.pop("mopidy_status", None)
  876. if not scrobble_status:
  877. scrobble_status = scrobble_data.pop("jellyfin_status", None)
  878. logger.info(
  879. "[scrobbling] update called",
  880. extra={
  881. "scrobble_id": self.id,
  882. "scrobble_data": scrobble_data,
  883. "media_type": self.media_type,
  884. "scrobble_status": scrobble_status,
  885. },
  886. )
  887. # This is really expensive on the DB ... do we need to track this?
  888. # if self.percent_played < 100:
  889. # # Only worry about ticks if we haven't gotten to the end
  890. # self.update_ticks(scrobble_data)
  891. if self.beyond_completion_percent:
  892. scrobble_status = "stopped"
  893. if scrobble_status == "stopped":
  894. self.stop()
  895. if scrobble_status == "paused":
  896. self.pause()
  897. if scrobble_status == "resumed":
  898. self.resume()
  899. if scrobble_status != "resumed":
  900. scrobble_data["stop_timestamp"] = (
  901. scrobble_data.pop("timestamp", None) or timezone.now()
  902. )
  903. # timestamp should be more-or-less immutable
  904. scrobble_data.pop("timestamp", None)
  905. update_fields = []
  906. for key, value in scrobble_data.items():
  907. setattr(self, key, value)
  908. update_fields.append(key)
  909. self.save(update_fields=update_fields)
  910. logger.info(
  911. "[scrobbling] update finished",
  912. extra={
  913. "scrobble_id": self.id,
  914. "scrobble_data": scrobble_data,
  915. "scrobble_status": scrobble_status,
  916. "media_type": self.media_type,
  917. },
  918. )
  919. return self
  920. @classmethod
  921. def create(
  922. cls,
  923. scrobble_data: dict,
  924. ) -> "Scrobble":
  925. scrobble_data["scrobble_log"] = ""
  926. scrobble = cls.objects.create(
  927. **scrobble_data,
  928. )
  929. return scrobble
  930. def stop(self, force_finish=False) -> None:
  931. self.stop_timestamp = timezone.now()
  932. self.played_to_completion = True
  933. self.in_progress = False
  934. if not self.playback_position_seconds:
  935. self.playback_position_seconds = int(
  936. (self.stop_timestamp - self.timestamp).total_seconds()
  937. )
  938. self.save(
  939. update_fields=[
  940. "in_progress",
  941. "played_to_completion",
  942. "stop_timestamp",
  943. "playback_position_seconds",
  944. ]
  945. )
  946. class_name = self.media_obj.__class__.__name__
  947. if class_name in LONG_PLAY_MEDIA.values():
  948. self.finish_long_play()
  949. logger.info(
  950. f"[scrobbling] stopped",
  951. extra={
  952. "scrobble_id": self.id,
  953. "media_id": self.media_obj.id,
  954. "media_type": self.media_type,
  955. "source": self.source,
  956. },
  957. )
  958. def pause(self) -> None:
  959. if self.is_paused:
  960. logger.warning(f"{self.id} - already paused - {self.source}")
  961. return
  962. self.is_paused = True
  963. self.save(update_fields=["is_paused"])
  964. logger.info(
  965. f"[scrobbling] paused",
  966. extra={
  967. "scrobble_id": self.id,
  968. "media_type": self.media_type,
  969. "source": self.source,
  970. },
  971. )
  972. def resume(self) -> None:
  973. if self.is_paused or not self.in_progress:
  974. self.is_paused = False
  975. self.in_progress = True
  976. self.save(update_fields=["is_paused", "in_progress"])
  977. logger.info(
  978. f"[scrobbling] resumed",
  979. extra={
  980. "scrobble_id": self.id,
  981. "media_type": self.media_type,
  982. "source": self.source,
  983. },
  984. )
  985. def cancel(self) -> None:
  986. self.delete()
  987. def update_ticks(self, data) -> None:
  988. self.playback_position_seconds = data.get("playback_position_seconds")
  989. self.save(update_fields=["playback_position_seconds"])
  990. def finish_long_play(self):
  991. seconds_elapsed = (timezone.now() - self.timestamp).seconds
  992. past_seconds = 0
  993. # Set our playback seconds, and calc long play seconds
  994. self.playback_position_seconds = seconds_elapsed
  995. if self.previous:
  996. past_seconds = self.previous.long_play_seconds
  997. self.long_play_seconds = past_seconds + seconds_elapsed
  998. # Long play scrobbles are always finished when we say they are
  999. self.played_to_completion = True
  1000. self.save(
  1001. update_fields=[
  1002. "playback_position_seconds",
  1003. "played_to_completion",
  1004. "long_play_seconds",
  1005. ]
  1006. )
  1007. logger.info(
  1008. f"[scrobbling] finishing long play",
  1009. extra={
  1010. "scrobble_id": self.id,
  1011. },
  1012. )
  1013. @property
  1014. def beyond_completion_percent(self) -> bool:
  1015. """Returns true if our media is beyond our completion percent, unless
  1016. our type is geolocation in which case we always return false
  1017. """
  1018. beyond_completion = (
  1019. self.percent_played >= self.media_obj.COMPLETION_PERCENT
  1020. )
  1021. if self.media_type == "GeoLocation":
  1022. logger.info(
  1023. f"[scrobbling] locations are ONLY completed when new one is created",
  1024. extra={
  1025. "scrobble_id": self.id,
  1026. "media_type": self.media_type,
  1027. "beyond_completion": beyond_completion,
  1028. },
  1029. )
  1030. beyond_completion = False
  1031. return beyond_completion