models.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import logging
  2. from typing import Dict, Optional
  3. from uuid import uuid4
  4. import requests
  5. from django.conf import settings
  6. from django.core.files.base import ContentFile
  7. from django.db import models
  8. from django.urls import reverse
  9. from django.utils.translation import gettext_lazy as _
  10. from django_extensions.db.models import TimeStampedModel
  11. from scrobbles.mixins import ObjectWithGenres, ScrobblableMixin
  12. from taggit.managers import TaggableManager
  13. from videos.imdb import lookup_video_from_imdb
  14. logger = logging.getLogger(__name__)
  15. BNULL = {"blank": True, "null": True}
  16. class Series(TimeStampedModel):
  17. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  18. name = models.CharField(max_length=255)
  19. plot = models.TextField(**BNULL)
  20. imdb_id = models.CharField(max_length=20, **BNULL)
  21. imdb_rating = models.FloatField(**BNULL)
  22. cover_image = models.ImageField(upload_to="videos/series/", **BNULL)
  23. preferred_source = models.CharField(max_length=100, **BNULL)
  24. genre = TaggableManager(through=ObjectWithGenres)
  25. class Meta:
  26. verbose_name_plural = "series"
  27. def __str__(self):
  28. return self.name
  29. def get_absolute_url(self):
  30. return reverse("videos:series_detail", kwargs={"slug": self.uuid})
  31. def imdb_link(self):
  32. return f"https://www.imdb.com/title/tt{self.imdb_id}"
  33. def scrobbles_for_user(self, user_id: int, include_playing=False):
  34. from scrobbles.models import Scrobble
  35. played_query = models.Q(played_to_completion=True)
  36. if include_playing:
  37. played_query = models.Q()
  38. return Scrobble.objects.filter(
  39. played_query,
  40. video__tv_series=self,
  41. user=user_id,
  42. ).order_by("-timestamp")
  43. def last_scrobbled_episode(self, user_id: int) -> Optional["Video"]:
  44. episode = None
  45. last_scrobble = self.scrobbles_for_user(
  46. user_id, include_playing=True
  47. ).first()
  48. if last_scrobble:
  49. episode = last_scrobble.media_obj
  50. return episode
  51. def is_episode_playing(self, user_id: int) -> bool:
  52. last_scrobble = self.scrobbles_for_user(
  53. user_id, include_playing=True
  54. ).first()
  55. return not last_scrobble.played_to_completion
  56. def fix_metadata(self, force_update=False):
  57. name_or_id = self.name
  58. if self.imdb_id:
  59. name_or_id = self.imdb_id
  60. imdb_dict = lookup_video_from_imdb(name_or_id)
  61. if not imdb_dict:
  62. logger.warn(f"No imdb data for {self}")
  63. return
  64. self.imdb_id = imdb_dict.data.get("imdbID")
  65. self.imdb_rating = imdb_dict.data.get("arithmetic mean")
  66. self.plot = imdb_dict.data.get("plot outline")
  67. self.save(update_fields=["imdb_id", "imdb_rating", "plot"])
  68. cover_url = imdb_dict.get("cover url")
  69. if (not self.cover_image or force_update) and cover_url:
  70. r = requests.get(cover_url)
  71. if r.status_code == 200:
  72. fname = f"{self.name}_{self.uuid}.jpg"
  73. self.cover_image.save(fname, ContentFile(r.content), save=True)
  74. if genres := imdb_dict.data.get("genres"):
  75. self.genre.add(*genres)
  76. class Video(ScrobblableMixin):
  77. COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
  78. SECONDS_TO_STALE = getattr(settings, "VIDEO_SECONDS_TO_STALE", 14400)
  79. class VideoType(models.TextChoices):
  80. UNKNOWN = "U", _("Unknown")
  81. TV_EPISODE = "E", _("TV Episode")
  82. MOVIE = "M", _("Movie")
  83. video_type = models.CharField(
  84. max_length=1,
  85. choices=VideoType.choices,
  86. default=VideoType.UNKNOWN,
  87. )
  88. overview = models.TextField(**BNULL)
  89. tagline = models.TextField(**BNULL)
  90. year = models.IntegerField(**BNULL)
  91. # TV show specific fields
  92. tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  93. season_number = models.IntegerField(**BNULL)
  94. episode_number = models.IntegerField(**BNULL)
  95. next_imdb_id = models.CharField(max_length=20, **BNULL)
  96. imdb_id = models.CharField(max_length=20, **BNULL)
  97. imdb_rating = models.FloatField(**BNULL)
  98. cover_image = models.ImageField(upload_to="videos/video/", **BNULL)
  99. tvrage_id = models.CharField(max_length=20, **BNULL)
  100. tvdb_id = models.CharField(max_length=20, **BNULL)
  101. plot = models.TextField(**BNULL)
  102. year = models.IntegerField(**BNULL)
  103. class Meta:
  104. unique_together = [["title", "imdb_id"]]
  105. def __str__(self):
  106. if self.video_type == self.VideoType.TV_EPISODE:
  107. return f"{self.tv_series} - Season {self.season_number}, Episode {self.episode_number}"
  108. return self.title
  109. def get_absolute_url(self):
  110. return reverse("videos:video_detail", kwargs={"slug": self.uuid})
  111. @property
  112. def subtitle(self):
  113. if self.tv_series:
  114. return self.tv_series
  115. return ""
  116. @property
  117. def imdb_link(self):
  118. return f"https://www.imdb.com/title/tt{self.imdb_id}"
  119. @property
  120. def info_link(self):
  121. return self.imdb_link
  122. @property
  123. def link(self):
  124. return self.imdb_link
  125. def fix_metadata(self, force_update=False):
  126. imdb_dict = lookup_video_from_imdb(self.imdb_id)
  127. if not imdb_dict:
  128. logger.warn(f"No imdb data for {self}")
  129. return
  130. if imdb_dict.get("runtimes") and len(imdb_dict.get("runtimes")) > 0:
  131. self.run_time_seconds = int(imdb_dict.get("runtimes")[0]) * 60
  132. self.imdb_rating = imdb_dict.data.get("rating")
  133. self.plot = imdb_dict.data.get("plot")
  134. self.year = imdb_dict.data.get("year")
  135. self.save(
  136. update_fields=["imdb_rating", "plot", "year", "run_time_seconds"]
  137. )
  138. cover_url = imdb_dict.get("cover url")
  139. if (not self.cover_image or force_update) and cover_url:
  140. r = requests.get(cover_url)
  141. if r.status_code == 200:
  142. fname = f"{self.title}_{self.uuid}.jpg"
  143. self.cover_image.save(fname, ContentFile(r.content), save=True)
  144. if genres := imdb_dict.data.get("genres"):
  145. self.genre.add(*genres)
  146. @classmethod
  147. def find_or_create(cls, data_dict: Dict) -> "Video":
  148. """Given a data dict from Jellyfin, does the heavy lifting of looking up
  149. the video and, if need, TV Series, creating both if they don't yet
  150. exist.
  151. """
  152. from videos.utils import (
  153. get_or_create_video,
  154. get_or_create_video_from_jellyfin,
  155. )
  156. if "NotificationType" not in data_dict.keys():
  157. name_or_id = data_dict.get("imdb_id") or data_dict.get("title")
  158. video = get_or_create_video(name_or_id)
  159. return video
  160. if not data_dict.get("Provider_imdb"):
  161. title = data_dict.get("Name", "")
  162. logger.warn(
  163. f"No IMDB ID from Jellyfin, check metadata for {title}"
  164. )
  165. return
  166. return get_or_create_video_from_jellyfin(data_dict)