models.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import logging
  2. from typing import Optional
  3. from uuid import uuid4
  4. import pendulum
  5. import requests
  6. from django.conf import settings
  7. from django.core.files.base import ContentFile
  8. from django.db import models
  9. from django.urls import reverse
  10. from django.utils.translation import gettext_lazy as _
  11. from django_extensions.db.models import TimeStampedModel
  12. from imagekit.models import ImageSpecField
  13. from imagekit.processors import ResizeToFit
  14. from music.constants import JELLYFIN_POST_KEYS
  15. from scrobbles.mixins import (
  16. ObjectWithGenres,
  17. ScrobblableConstants,
  18. ScrobblableMixin,
  19. )
  20. from taggit.managers import TaggableManager
  21. from videos.services.metadata import VideoMetadata
  22. from videos.sources.imdb import lookup_video_from_imdb
  23. from vrobbler.apps.videos.sources.youtube import lookup_video_from_youtube
  24. YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
  25. YOUTUBE_CHANNEL_URL = "https://www.youtube.com/channel/"
  26. IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
  27. logger = logging.getLogger(__name__)
  28. BNULL = {"blank": True, "null": True}
  29. class Channel(TimeStampedModel):
  30. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  31. name = models.CharField(max_length=255)
  32. cover_image = models.ImageField(upload_to="videos/channels/", **BNULL)
  33. cover_small = ImageSpecField(
  34. source="cover_image",
  35. processors=[ResizeToFit(100, 100)],
  36. format="JPEG",
  37. options={"quality": 60},
  38. )
  39. cover_medium = ImageSpecField(
  40. source="cover_image",
  41. processors=[ResizeToFit(300, 300)],
  42. format="JPEG",
  43. options={"quality": 75},
  44. )
  45. youtube_id = models.CharField(max_length=255, **BNULL)
  46. genre = TaggableManager(through=ObjectWithGenres)
  47. def __str__(self) -> str:
  48. return self.name
  49. def get_absolute_url(self):
  50. return reverse("videos:channel_detail", kwargs={"slug": self.uuid})
  51. @property
  52. def youtube_url(self):
  53. return YOUTUBE_CHANNEL_URL + self.youtube_id
  54. @property
  55. def primary_image_url(self) -> str:
  56. url = ""
  57. if self.cover_image:
  58. url = self.cover_image_medium.url
  59. return url
  60. def scrobbles_for_user(self, user_id: int, include_playing=False):
  61. from scrobbles.models import Scrobble
  62. played_query = models.Q(played_to_completion=True)
  63. if include_playing:
  64. played_query = models.Q()
  65. return Scrobble.objects.filter(
  66. played_query,
  67. video__channel=self,
  68. user=user_id,
  69. ).order_by("-timestamp")
  70. def fix_metadata(self, force: bool = False):
  71. # TODO Scrape channel info from Youtube
  72. logger.warning("Not implemented yet")
  73. return
  74. class Series(TimeStampedModel):
  75. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  76. name = models.CharField(max_length=255)
  77. plot = models.TextField(**BNULL)
  78. imdb_id = models.CharField(max_length=20, **BNULL)
  79. imdb_rating = models.FloatField(**BNULL)
  80. cover_image = models.ImageField(upload_to="videos/series/", **BNULL)
  81. cover_small = ImageSpecField(
  82. source="cover_image",
  83. processors=[ResizeToFit(100, 100)],
  84. format="JPEG",
  85. options={"quality": 60},
  86. )
  87. cover_medium = ImageSpecField(
  88. source="cover_image",
  89. processors=[ResizeToFit(300, 300)],
  90. format="JPEG",
  91. options={"quality": 75},
  92. )
  93. preferred_source = models.CharField(max_length=100, **BNULL)
  94. genre = TaggableManager(through=ObjectWithGenres)
  95. class Meta:
  96. verbose_name_plural = "series"
  97. def __str__(self):
  98. return self.name
  99. def get_absolute_url(self):
  100. return reverse("videos:series_detail", kwargs={"slug": self.uuid})
  101. def imdb_link(self) -> str:
  102. if self.imdb_id:
  103. return IMDB_VIDEO_URL + self.imdb_id
  104. return ""
  105. @property
  106. def primary_image_url(self) -> str:
  107. url = ""
  108. if self.cover_image:
  109. url = self.cover_image_medium.url
  110. return url
  111. def scrobbles_for_user(self, user_id: int, include_playing=False):
  112. from scrobbles.models import Scrobble
  113. played_query = models.Q(played_to_completion=True)
  114. if include_playing:
  115. played_query = models.Q()
  116. return Scrobble.objects.filter(
  117. played_query,
  118. video__tv_series=self,
  119. user=user_id,
  120. ).order_by("-timestamp")
  121. def last_scrobbled_episode(self, user_id: int) -> Optional["Video"]:
  122. episode = None
  123. last_scrobble = self.scrobbles_for_user(
  124. user_id, include_playing=True
  125. ).first()
  126. if last_scrobble:
  127. episode = last_scrobble.media_obj
  128. return episode
  129. def is_episode_playing(self, user_id: int) -> bool:
  130. last_scrobble = self.scrobbles_for_user(
  131. user_id, include_playing=True
  132. ).first()
  133. return not last_scrobble.played_to_completion
  134. def fix_metadata(self, force_update=False):
  135. name_or_id = self.name
  136. if self.imdb_id:
  137. name_or_id = self.imdb_id
  138. video_metadata: VideoMetadata = lookup_video_from_imdb(name_or_id)
  139. if not video_metadata.title:
  140. logger.warning(f"No imdb data for {self}")
  141. return
  142. cover_url = imdb_dict.get("cover_url")
  143. if (not self.cover_image or force_update) and cover_url:
  144. r = requests.get(cover_url)
  145. if r.status_code == 200:
  146. fname = f"{self.name}_{self.uuid}.jpg"
  147. self.cover_image.save(fname, ContentFile(r.content), save=True)
  148. if genres := imdb_dict.get("genres"):
  149. self.genre.add(*genres)
  150. class Video(ScrobblableMixin):
  151. COMPLETION_PERCENT = getattr(settings, "VIDEO_COMPLETION_PERCENT", 90)
  152. SECONDS_TO_STALE = getattr(settings, "VIDEO_SECONDS_TO_STALE", 14400)
  153. METADATA_CLASS = VideoMetadata
  154. class VideoType(models.TextChoices):
  155. UNKNOWN = "U", _("Unknown")
  156. TV_EPISODE = "E", _("TV Episode")
  157. MOVIE = "M", _("Movie")
  158. SKATE_VIDEO = "S", _("Skate Video")
  159. YOUTUBE = "Y", _("YouTube Video")
  160. video_type = models.CharField(
  161. max_length=1,
  162. choices=VideoType.choices,
  163. default=VideoType.UNKNOWN,
  164. )
  165. overview = models.TextField(**BNULL)
  166. tagline = models.TextField(**BNULL)
  167. year = models.IntegerField(**BNULL)
  168. # TV show specific fields
  169. tv_series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
  170. channel = models.ForeignKey(Channel, on_delete=models.DO_NOTHING, **BNULL)
  171. season_number = models.IntegerField(**BNULL)
  172. episode_number = models.IntegerField(**BNULL)
  173. next_imdb_id = models.CharField(max_length=20, **BNULL)
  174. imdb_id = models.CharField(max_length=20, **BNULL)
  175. imdb_rating = models.FloatField(**BNULL)
  176. cover_image = models.ImageField(upload_to="videos/video/", **BNULL)
  177. cover_image_small = ImageSpecField(
  178. source="cover_image",
  179. processors=[ResizeToFit(100, 100)],
  180. format="JPEG",
  181. options={"quality": 60},
  182. )
  183. cover_image_medium = ImageSpecField(
  184. source="cover_image",
  185. processors=[ResizeToFit(300, 300)],
  186. format="JPEG",
  187. options={"quality": 75},
  188. )
  189. tvrage_id = models.CharField(max_length=20, **BNULL)
  190. tvdb_id = models.CharField(max_length=20, **BNULL)
  191. tmdb_id = models.CharField(max_length=20, **BNULL)
  192. youtube_id = models.CharField(max_length=255, **BNULL)
  193. plot = models.TextField(**BNULL)
  194. upload_date = models.DateField(**BNULL)
  195. class Meta:
  196. unique_together = [["title", "imdb_id"]]
  197. def __str__(self):
  198. if not self.title:
  199. return self.youtube_id or self.imdb_id
  200. if self.video_type == self.VideoType.TV_EPISODE:
  201. return f"{self.title} / [S{self.season_number}E{self.episode_number}] {self.tv_series}"
  202. if self.video_type == self.VideoType.YOUTUBE:
  203. return f"{self.title} / {self.channel}"
  204. return self.title
  205. def get_absolute_url(self):
  206. return reverse("videos:video_detail", kwargs={"slug": self.uuid})
  207. @property
  208. def subtitle(self):
  209. if self.tv_series:
  210. return self.tv_series
  211. return ""
  212. @property
  213. def imdb_link(self):
  214. return f"https://www.imdb.com/title/tt{self.imdb_id}"
  215. @property
  216. def info_link(self):
  217. return self.imdb_link
  218. @property
  219. def link(self) -> str:
  220. return self.imdb_link
  221. @property
  222. def youtube_link(self) -> str:
  223. if self.youtube_id:
  224. return YOUTUBE_BASE_URL + self.youtube_id
  225. return ""
  226. @property
  227. def primary_image_url(self) -> str:
  228. url = ""
  229. if self.cover_image:
  230. url = self.cover_image_medium.url
  231. return url
  232. @property
  233. def strings(self) -> ScrobblableConstants:
  234. return ScrobblableConstants(verb="Watching", tags="movie_camera")
  235. def save_image_from_url(self, url: str, force_update: bool = False):
  236. if not self.cover_image or (force_update and url):
  237. r = requests.get(url)
  238. if r.status_code == 200:
  239. fname = f"{self.title}_{self.uuid}.jpg"
  240. self.cover_image.save(fname, ContentFile(r.content), save=True)
  241. @classmethod
  242. def get_from_youtube_id(
  243. cls, youtube_id: str, overwrite: bool = False
  244. ) -> "Video":
  245. video, created = cls.objects.get_or_create(youtube_id=youtube_id)
  246. if not created and not overwrite:
  247. return video
  248. vdict, cover, genres = lookup_video_from_youtube(
  249. youtube_id
  250. ).as_dict_with_cover_and_genres()
  251. if created or overwrite:
  252. for k, v in vdict.items():
  253. setattr(video, k, v)
  254. video.save()
  255. video.save_image_from_url(cover)
  256. video.genre.add(*genres)
  257. return video
  258. @classmethod
  259. def get_from_imdb_id(cls, imdb_id: str, overwrite: bool = False):
  260. video, created = cls.objects.get_or_create(imdb_id=imdb_id)
  261. if not created and not overwrite:
  262. return video
  263. vdict, cover, genres = lookup_video_from_imdb(
  264. imdb_id
  265. ).as_dict_with_cover_and_genres()
  266. if created or overwrite:
  267. for k, v in vdict.items():
  268. if k == "imdb_id":
  269. v = "tt" + v
  270. setattr(video, k, v)
  271. video.save()
  272. video.save_image_from_url(cover)
  273. video.genre.add(*genres)
  274. return video
  275. @classmethod
  276. def find_or_create(
  277. cls, data_dict: dict, post_keys: dict = JELLYFIN_POST_KEYS
  278. ) -> Optional["Video"]:
  279. """Thes smallest of wrappers around our actual get or create utility."""
  280. from videos.utils import get_or_create_video
  281. return get_or_create_video(data_dict, post_keys)