models.py 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673
  1. import glob
  2. import json
  3. import logging
  4. import os
  5. import random
  6. import re
  7. import tempfile
  8. import uuid
  9. import m3u8
  10. from django.conf import settings
  11. from django.contrib.postgres.indexes import GinIndex
  12. from django.contrib.postgres.search import SearchVectorField
  13. from django.core.exceptions import ValidationError
  14. from django.core.files import File
  15. from django.db import connection, models
  16. from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
  17. from django.dispatch import receiver
  18. from django.urls import reverse
  19. from django.utils import timezone
  20. from django.utils.html import strip_tags
  21. from imagekit.models import ProcessedImageField
  22. from imagekit.processors import ResizeToFit
  23. from mptt.models import MPTTModel, TreeForeignKey
  24. from . import helpers
  25. from .stop_words import STOP_WORDS
  26. logger = logging.getLogger(__name__)
  27. RE_TIMECODE = re.compile(r"(\d+:\d+:\d+.\d+)")
  28. # this is used by Media and Encoding models
  29. # reflects media encoding status for objects
  30. MEDIA_ENCODING_STATUS = (
  31. ("pending", "Pending"),
  32. ("running", "Running"),
  33. ("fail", "Fail"),
  34. ("success", "Success"),
  35. )
  36. # the media state of a Media object
  37. # this is set by default according to the portal workflow
  38. MEDIA_STATES = (
  39. ("private", "Private"),
  40. ("public", "Public"),
  41. ("unlisted", "Unlisted"),
  42. )
  43. # each uploaded Media gets a media_type hint
  44. # by helpers.get_file_type
  45. MEDIA_TYPES_SUPPORTED = (
  46. ("video", "Video"),
  47. ("image", "Image"),
  48. ("pdf", "Pdf"),
  49. ("audio", "Audio"),
  50. )
  51. ENCODE_EXTENSIONS = (
  52. ("mp4", "mp4"),
  53. ("webm", "webm"),
  54. ("gif", "gif"),
  55. )
  56. ENCODE_RESOLUTIONS = (
  57. (2160, "2160"),
  58. (1440, "1440"),
  59. (1080, "1080"),
  60. (720, "720"),
  61. (480, "480"),
  62. (360, "360"),
  63. (240, "240"),
  64. )
  65. CODECS = (
  66. ("h265", "h265"),
  67. ("h264", "h264"),
  68. ("vp9", "vp9"),
  69. )
  70. ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS]
  71. ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS]
  72. def original_media_file_path(instance, filename):
  73. """Helper function to place original media file"""
  74. file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
  75. return settings.MEDIA_UPLOAD_DIR + "user/{0}/{1}".format(instance.user.username, file_name)
  76. def original_voice_file_path(instance, filename):
  77. """Helper function to place original voice file"""
  78. file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
  79. # MEDIA_UPLOAD_DIR is used for `media` upload. It's used for `voice` too.
  80. return settings.MEDIA_UPLOAD_DIR + "user/{0}/{1}".format(instance.user.username, file_name)
  81. def encoding_media_file_path(instance, filename):
  82. """Helper function to place encoded media file"""
  83. file_name = "{0}.{1}".format(instance.media.uid.hex, helpers.get_file_name(filename))
  84. return settings.MEDIA_ENCODING_DIR + "{0}/{1}/{2}".format(instance.profile.id, instance.media.user.username, file_name)
  85. def original_thumbnail_file_path(instance, filename):
  86. """Helper function to place original media thumbnail file"""
  87. return settings.THUMBNAIL_UPLOAD_DIR + "user/{0}/{1}".format(instance.user.username, filename)
  88. def subtitles_file_path(instance, filename):
  89. """Helper function to place subtitle file"""
  90. return settings.SUBTITLES_UPLOAD_DIR + "user/{0}/{1}".format(instance.media.user.username, filename)
  91. def category_thumb_path(instance, filename):
  92. """Helper function to place category thumbnail file"""
  93. file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
  94. return settings.MEDIA_UPLOAD_DIR + "categories/{0}".format(file_name)
  95. class Media(models.Model):
  96. """The most important model for MediaCMS"""
  97. add_date = models.DateTimeField("Date produced", blank=True, null=True, db_index=True)
  98. allow_download = models.BooleanField(default=True, help_text="Whether option to download media is shown")
  99. category = models.ManyToManyField("Category", blank=True, help_text="Media can be part of one or more categories")
  100. channel = models.ForeignKey(
  101. "users.Channel",
  102. on_delete=models.CASCADE,
  103. blank=True,
  104. null=True,
  105. help_text="Media can exist in one or no Channels",
  106. )
  107. description = models.TextField(blank=True)
  108. dislikes = models.IntegerField(default=0)
  109. duration = models.IntegerField(default=0)
  110. edit_date = models.DateTimeField(auto_now=True)
  111. enable_comments = models.BooleanField(default=True, help_text="Whether comments will be allowed for this media")
  112. encoding_status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending", db_index=True)
  113. featured = models.BooleanField(
  114. default=False,
  115. db_index=True,
  116. help_text="Whether media is globally featured by a MediaCMS editor",
  117. )
  118. friendly_token = models.CharField(blank=True, max_length=12, db_index=True, help_text="Identifier for the Media")
  119. hls_file = models.CharField(max_length=1000, blank=True, help_text="Path to HLS file for videos")
  120. is_reviewed = models.BooleanField(
  121. default=settings.MEDIA_IS_REVIEWED,
  122. db_index=True,
  123. help_text="Whether media is reviewed, so it can appear on public listings",
  124. )
  125. license = models.ForeignKey("License", on_delete=models.CASCADE, db_index=True, blank=True, null=True)
  126. likes = models.IntegerField(db_index=True, default=1)
  127. listable = models.BooleanField(default=False, help_text="Whether it will appear on listings")
  128. md5sum = models.CharField(max_length=50, blank=True, null=True, help_text="Not exposed, used internally")
  129. media_file = models.FileField(
  130. "media file",
  131. upload_to=original_media_file_path,
  132. max_length=500,
  133. help_text="media file",
  134. )
  135. media_info = models.TextField(blank=True, help_text="extracted media metadata info")
  136. media_type = models.CharField(
  137. max_length=20,
  138. blank=True,
  139. choices=MEDIA_TYPES_SUPPORTED,
  140. db_index=True,
  141. default="video",
  142. )
  143. password = models.CharField(max_length=100, blank=True, help_text="password for private media")
  144. preview_file_path = models.CharField(
  145. max_length=500,
  146. blank=True,
  147. help_text="preview gif for videos, path in filesystem",
  148. )
  149. poster = ProcessedImageField(
  150. upload_to=original_thumbnail_file_path,
  151. processors=[ResizeToFit(width=720, height=None)],
  152. format="JPEG",
  153. options={"quality": 95},
  154. blank=True,
  155. max_length=500,
  156. help_text="media extracted big thumbnail, shown on media page",
  157. )
  158. rating_category = models.ManyToManyField(
  159. "RatingCategory",
  160. blank=True,
  161. help_text="Rating category, if media Rating is allowed",
  162. )
  163. reported_times = models.IntegerField(default=0, help_text="how many time a media is reported")
  164. search = SearchVectorField(
  165. null=True,
  166. help_text="used to store all searchable info and metadata for a Media",
  167. )
  168. size = models.CharField(
  169. max_length=20,
  170. blank=True,
  171. null=True,
  172. help_text="media size in bytes, automatically calculated",
  173. )
  174. sprites = models.FileField(
  175. upload_to=original_thumbnail_file_path,
  176. blank=True,
  177. max_length=500,
  178. help_text="sprites file, only for videos, displayed on the video player",
  179. )
  180. state = models.CharField(
  181. max_length=20,
  182. choices=MEDIA_STATES,
  183. default=helpers.get_portal_workflow(),
  184. db_index=True,
  185. help_text="state of Media",
  186. )
  187. tags = models.ManyToManyField("Tag", blank=True, help_text="select one or more out of the existing tags")
  188. title = models.CharField(max_length=100, help_text="media title", blank=True, db_index=True)
  189. thumbnail = ProcessedImageField(
  190. upload_to=original_thumbnail_file_path,
  191. processors=[ResizeToFit(width=344, height=None)],
  192. format="JPEG",
  193. options={"quality": 95},
  194. blank=True,
  195. max_length=500,
  196. help_text="media extracted small thumbnail, shown on listings",
  197. )
  198. thumbnail_time = models.FloatField(blank=True, null=True, help_text="Time on video that a thumbnail will be taken")
  199. uid = models.UUIDField(unique=True, default=uuid.uuid4, help_text="A unique identifier for the Media")
  200. uploaded_thumbnail = ProcessedImageField(
  201. upload_to=original_thumbnail_file_path,
  202. processors=[ResizeToFit(width=344, height=None)],
  203. format="JPEG",
  204. options={"quality": 85},
  205. blank=True,
  206. max_length=500,
  207. help_text="thumbnail from uploaded_poster field",
  208. )
  209. uploaded_poster = ProcessedImageField(
  210. verbose_name="Upload image",
  211. help_text="This image will characterize the media",
  212. upload_to=original_thumbnail_file_path,
  213. processors=[ResizeToFit(width=720, height=None)],
  214. format="JPEG",
  215. options={"quality": 85},
  216. blank=True,
  217. max_length=500,
  218. )
  219. user = models.ForeignKey("users.User", on_delete=models.CASCADE, help_text="user that uploads the media")
  220. user_featured = models.BooleanField(default=False, help_text="Featured by the user")
  221. video_height = models.IntegerField(default=1)
  222. views = models.IntegerField(db_index=True, default=1)
  223. # keep track if media file has changed, on saves
  224. __original_media_file = None
  225. __original_thumbnail_time = None
  226. __original_uploaded_poster = None
  227. class Meta:
  228. ordering = ["-add_date"]
  229. indexes = [
  230. # TODO: check with pgdash.io or other tool what index need be
  231. # removed
  232. GinIndex(fields=["search"])
  233. ]
  234. def __str__(self):
  235. return self.title
  236. def __init__(self, *args, **kwargs):
  237. super(Media, self).__init__(*args, **kwargs)
  238. # keep track if media file has changed, on saves
  239. # thus know when another media was uploaded
  240. # or when thumbnail time change - for videos to
  241. # grep for thumbnail, or even when a new image
  242. # was added as the media poster
  243. self.__original_media_file = self.media_file
  244. self.__original_thumbnail_time = self.thumbnail_time
  245. self.__original_uploaded_poster = self.uploaded_poster
  246. def save(self, *args, **kwargs):
  247. if not self.title:
  248. self.title = self.media_file.path.split("/")[-1]
  249. strip_text_items = ["title", "description"]
  250. for item in strip_text_items:
  251. setattr(self, item, strip_tags(getattr(self, item, None)))
  252. self.title = self.title[:99]
  253. # if thumbnail_time specified, keep up to single digit
  254. if self.thumbnail_time:
  255. self.thumbnail_time = round(self.thumbnail_time, 1)
  256. # by default get an add_date of now
  257. if not self.add_date:
  258. self.add_date = timezone.now()
  259. if not self.friendly_token:
  260. # get a unique identifier
  261. while True:
  262. friendly_token = helpers.produce_friendly_token()
  263. if not Media.objects.filter(friendly_token=friendly_token):
  264. self.friendly_token = friendly_token
  265. break
  266. if self.pk:
  267. # media exists
  268. # check case where another media file was uploaded
  269. if self.media_file != self.__original_media_file:
  270. # set this otherwise gets to infinite loop
  271. self.__original_media_file = self.media_file
  272. self.media_init()
  273. # for video files, if user specified a different time
  274. # to automatically grub thumbnail
  275. if self.thumbnail_time != self.__original_thumbnail_time:
  276. self.__original_thumbnail_time = self.thumbnail_time
  277. self.set_thumbnail(force=True)
  278. else:
  279. # media is going to be created now
  280. # after media is saved, post_save signal will call media_init function
  281. # to take care of post save steps
  282. self.state = helpers.get_default_state(user=self.user)
  283. # condition to appear on listings
  284. if self.state == "public" and self.encoding_status == "success" and self.is_reviewed is True:
  285. self.listable = True
  286. else:
  287. self.listable = False
  288. super(Media, self).save(*args, **kwargs)
  289. # produce a thumbnail out of an uploaded poster
  290. # will run only when a poster is uploaded for the first time
  291. if self.uploaded_poster and self.uploaded_poster != self.__original_uploaded_poster:
  292. with open(self.uploaded_poster.path, "rb") as f:
  293. # set this otherwise gets to infinite loop
  294. self.__original_uploaded_poster = self.uploaded_poster
  295. myfile = File(f)
  296. thumbnail_name = helpers.get_file_name(self.uploaded_poster.path)
  297. self.uploaded_thumbnail.save(content=myfile, name=thumbnail_name)
  298. def update_search_vector(self):
  299. """
  300. Update SearchVector field of SearchModel using raw SQL
  301. search field is used to store SearchVector
  302. """
  303. db_table = self._meta.db_table
  304. # first get anything interesting out of the media
  305. # that needs to be search able
  306. a_tags = b_tags = ""
  307. if self.id:
  308. a_tags = " ".join([tag.title for tag in self.tags.all()])
  309. b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
  310. items = [
  311. self.title,
  312. self.user.username,
  313. self.user.email,
  314. self.user.name,
  315. self.description,
  316. a_tags,
  317. b_tags,
  318. ]
  319. items = [item for item in items if item]
  320. text = " ".join(items)
  321. text = " ".join([token for token in text.lower().split(" ") if token not in STOP_WORDS])
  322. text = helpers.clean_query(text)
  323. sql_code = """
  324. UPDATE {db_table} SET search = to_tsvector(
  325. '{config}', '{text}'
  326. ) WHERE {db_table}.id = {id}
  327. """.format(
  328. db_table=db_table, config="simple", text=text, id=self.id
  329. )
  330. try:
  331. with connection.cursor() as cursor:
  332. cursor.execute(sql_code)
  333. except BaseException:
  334. pass # TODO:add log
  335. return True
  336. def media_init(self):
  337. """Normally this is called when a media is uploaded
  338. Performs all related tasks, as check for media type,
  339. video duration, encode
  340. """
  341. self.set_media_type()
  342. if self.media_type == "video":
  343. self.set_thumbnail(force=True)
  344. self.produce_sprite_from_video()
  345. self.encode()
  346. elif self.media_type == "image":
  347. self.set_thumbnail(force=True)
  348. return True
  349. def set_media_type(self, save=True):
  350. """Sets media type on Media
  351. Set encoding_status as success for non video
  352. content since all listings filter for encoding_status success
  353. """
  354. kind = helpers.get_file_type(self.media_file.path)
  355. if kind is not None:
  356. if kind == "image":
  357. self.media_type = "image"
  358. elif kind == "pdf":
  359. self.media_type = "pdf"
  360. if self.media_type in ["audio", "image", "pdf"]:
  361. self.encoding_status = "success"
  362. else:
  363. ret = helpers.media_file_info(self.media_file.path)
  364. if ret.get("fail"):
  365. self.media_type = ""
  366. self.encoding_status = "fail"
  367. elif ret.get("is_video") or ret.get("is_audio"):
  368. try:
  369. self.media_info = json.dumps(ret)
  370. except TypeError:
  371. self.media_info = ""
  372. self.md5sum = ret.get("md5sum")
  373. self.size = helpers.show_file_size(ret.get("file_size"))
  374. else:
  375. self.media_type = ""
  376. self.encoding_status = "fail"
  377. audio_file_with_thumb = False
  378. # handle case where a file identified as video is actually an
  379. # audio file with thumbnail
  380. if ret.get("is_video"):
  381. # case where Media is video. try to set useful
  382. # metadata as duration/height
  383. self.media_type = "video"
  384. self.duration = int(round(float(ret.get("video_duration", 0))))
  385. self.video_height = int(ret.get("video_height"))
  386. if ret.get("video_info", {}).get("codec_name", {}) in ["mjpeg"]:
  387. audio_file_with_thumb = True
  388. if ret.get("is_audio") or audio_file_with_thumb:
  389. self.media_type = "audio"
  390. self.duration = int(float(ret.get("audio_info", {}).get("duration", 0)))
  391. self.encoding_status = "success"
  392. if save:
  393. self.save(
  394. update_fields=[
  395. "listable",
  396. "media_type",
  397. "duration",
  398. "media_info",
  399. "video_height",
  400. "size",
  401. "md5sum",
  402. "encoding_status",
  403. ]
  404. )
  405. return True
  406. def set_thumbnail(self, force=False):
  407. """sets thumbnail for media
  408. For video call function to produce thumbnail and poster
  409. For image save thumbnail and poster, this will perform
  410. resize action
  411. """
  412. if force or (not self.thumbnail):
  413. if self.media_type == "video":
  414. self.produce_thumbnails_from_video()
  415. if self.media_type == "image":
  416. with open(self.media_file.path, "rb") as f:
  417. myfile = File(f)
  418. thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg"
  419. self.thumbnail.save(content=myfile, name=thumbnail_name)
  420. self.poster.save(content=myfile, name=thumbnail_name)
  421. return True
  422. def produce_thumbnails_from_video(self):
  423. """Produce thumbnail and poster for media
  424. Only for video types. Uses ffmpeg
  425. """
  426. if not self.media_type == "video":
  427. return False
  428. if self.thumbnail_time and 0 <= self.thumbnail_time < self.duration:
  429. thumbnail_time = self.thumbnail_time
  430. else:
  431. thumbnail_time = round(random.uniform(0, self.duration - 0.1), 1)
  432. self.thumbnail_time = thumbnail_time # so that it gets saved
  433. tf = helpers.create_temp_file(suffix=".jpg")
  434. command = [
  435. settings.FFMPEG_COMMAND,
  436. "-ss",
  437. str(thumbnail_time), # -ss need to be firt here otherwise time taken is huge
  438. "-i",
  439. self.media_file.path,
  440. "-vframes",
  441. "1",
  442. "-y",
  443. tf,
  444. ]
  445. helpers.run_command(command)
  446. if os.path.exists(tf) and helpers.get_file_type(tf) == "image":
  447. with open(tf, "rb") as f:
  448. myfile = File(f)
  449. thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg"
  450. self.thumbnail.save(content=myfile, name=thumbnail_name)
  451. self.poster.save(content=myfile, name=thumbnail_name)
  452. helpers.rm_file(tf)
  453. return True
  454. def produce_sprite_from_video(self):
  455. """Start a task that will produce a sprite file
  456. To be used on the video player
  457. """
  458. from . import tasks
  459. tasks.produce_sprite_from_video.delay(self.friendly_token)
  460. return True
  461. def encode(self, profiles=[], force=True, chunkize=True):
  462. """Start video encoding tasks
  463. Create a task per EncodeProfile object, after checking height
  464. so that no EncodeProfile for highter heights than the video
  465. are created
  466. """
  467. if not profiles:
  468. profiles = EncodeProfile.objects.filter(active=True)
  469. profiles = list(profiles)
  470. from . import tasks
  471. # attempt to break media file in chunks
  472. if self.duration > settings.CHUNKIZE_VIDEO_DURATION and chunkize:
  473. for profile in profiles:
  474. if profile.extension == "gif":
  475. profiles.remove(profile)
  476. encoding = Encoding(media=self, profile=profile)
  477. encoding.save()
  478. enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
  479. tasks.encode_media.apply_async(
  480. args=[self.friendly_token, profile.id, encoding.id, enc_url],
  481. kwargs={"force": force},
  482. priority=0,
  483. )
  484. profiles = [p.id for p in profiles]
  485. tasks.chunkize_media.delay(self.friendly_token, profiles, force=force)
  486. else:
  487. for profile in profiles:
  488. if profile.extension != "gif":
  489. if self.video_height and self.video_height < profile.resolution:
  490. if profile.resolution not in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
  491. continue
  492. encoding = Encoding(media=self, profile=profile)
  493. encoding.save()
  494. enc_url = settings.SSL_FRONTEND_HOST + encoding.get_absolute_url()
  495. if profile.resolution in settings.MINIMUM_RESOLUTIONS_TO_ENCODE:
  496. priority = 9
  497. else:
  498. priority = 0
  499. tasks.encode_media.apply_async(
  500. args=[self.friendly_token, profile.id, encoding.id, enc_url],
  501. kwargs={"force": force},
  502. priority=priority,
  503. )
  504. return True
  505. def post_encode_actions(self, encoding=None, action=None):
  506. """perform things after encode has run
  507. whether it has failed or succeeded
  508. """
  509. self.set_encoding_status()
  510. # set a preview url
  511. if encoding:
  512. if self.media_type == "video" and encoding.profile.extension == "gif":
  513. if action == "delete":
  514. self.preview_file_path = ""
  515. else:
  516. self.preview_file_path = encoding.media_file.path
  517. self.save(update_fields=["listable", "preview_file_path"])
  518. self.save(update_fields=["encoding_status", "listable"])
  519. if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add":
  520. from . import tasks
  521. tasks.create_hls(self.friendly_token)
  522. return True
  523. def set_encoding_status(self):
  524. """Set encoding_status for videos
  525. Set success if at least one mp4 or webm exists
  526. """
  527. mp4_statuses = set(encoding.status for encoding in self.encodings.filter(profile__extension="mp4", chunk=False))
  528. webm_statuses = set(encoding.status for encoding in self.encodings.filter(profile__extension="webm", chunk=False))
  529. if not mp4_statuses and not webm_statuses:
  530. encoding_status = "pending"
  531. elif "success" in mp4_statuses or "success" in webm_statuses:
  532. encoding_status = "success"
  533. elif "running" in mp4_statuses or "running" in webm_statuses:
  534. encoding_status = "running"
  535. else:
  536. encoding_status = "fail"
  537. self.encoding_status = encoding_status
  538. return True
  539. @property
  540. def encodings_info(self, full=False):
  541. """Property used on serializers"""
  542. ret = {}
  543. if self.media_type not in ["video"]:
  544. return ret
  545. for key in ENCODE_RESOLUTIONS_KEYS:
  546. ret[key] = {}
  547. for encoding in self.encodings.select_related("profile").filter(chunk=False):
  548. if encoding.profile.extension == "gif":
  549. continue
  550. enc = self.get_encoding_info(encoding, full=full)
  551. resolution = encoding.profile.resolution
  552. ret[resolution][encoding.profile.codec] = enc
  553. # TODO: the following code is untested/needs optimization
  554. # if a file is broken in chunks and they are being
  555. # encoded, the final encoding file won't appear until
  556. # they are finished. Thus, produce the info for these
  557. if full:
  558. extra = []
  559. for encoding in self.encodings.select_related("profile").filter(chunk=True):
  560. resolution = encoding.profile.resolution
  561. if not ret[resolution].get(encoding.profile.codec):
  562. extra.append(encoding.profile.codec)
  563. for codec in extra:
  564. ret[resolution][codec] = {}
  565. v = self.encodings.filter(chunk=True, profile__codec=codec).values("progress")
  566. ret[resolution][codec]["progress"] = sum([p["progress"] for p in v]) / v.count()
  567. # TODO; status/logs/errors
  568. return ret
  569. def get_encoding_info(self, encoding, full=False):
  570. """Property used on serializers"""
  571. ep = {}
  572. ep["title"] = encoding.profile.name
  573. ep["url"] = encoding.media_encoding_url
  574. ep["progress"] = encoding.progress
  575. ep["size"] = encoding.size
  576. ep["encoding_id"] = encoding.id
  577. ep["status"] = encoding.status
  578. if full:
  579. ep["logs"] = encoding.logs
  580. ep["worker"] = encoding.worker
  581. ep["retries"] = encoding.retries
  582. if encoding.total_run_time:
  583. ep["total_run_time"] = encoding.total_run_time
  584. if encoding.commands:
  585. ep["commands"] = encoding.commands
  586. ep["time_started"] = encoding.add_date
  587. ep["updated_time"] = encoding.update_date
  588. return ep
  589. @property
  590. def categories_info(self):
  591. """Property used on serializers"""
  592. ret = []
  593. for cat in self.category.all():
  594. ret.append({"title": cat.title, "url": cat.get_absolute_url()})
  595. return ret
  596. @property
  597. def tags_info(self):
  598. """Property used on serializers"""
  599. ret = []
  600. for tag in self.tags.all():
  601. ret.append({"title": tag.title, "url": tag.get_absolute_url()})
  602. return ret
  603. @property
  604. def original_media_url(self):
  605. """Property used on serializers"""
  606. if settings.SHOW_ORIGINAL_MEDIA:
  607. return helpers.url_from_path(self.media_file.path)
  608. else:
  609. return None
  610. @property
  611. def thumbnail_url(self):
  612. """Property used on serializers
  613. Prioritize uploaded_thumbnail, if exists, then thumbnail
  614. that is auto-generated
  615. """
  616. if self.uploaded_thumbnail:
  617. return helpers.url_from_path(self.uploaded_thumbnail.path)
  618. if self.thumbnail:
  619. return helpers.url_from_path(self.thumbnail.path)
  620. return None
  621. @property
  622. def poster_url(self):
  623. """Property used on serializers
  624. Prioritize uploaded_poster, if exists, then poster
  625. that is auto-generated
  626. """
  627. if self.uploaded_poster:
  628. return helpers.url_from_path(self.uploaded_poster.path)
  629. if self.poster:
  630. return helpers.url_from_path(self.poster.path)
  631. return None
  632. @property
  633. def subtitles_info(self):
  634. """Property used on serializers
  635. Returns subtitles info
  636. """
  637. ret = []
  638. for subtitle in self.subtitles.all():
  639. ret.append(
  640. {
  641. "src": helpers.url_from_path(subtitle.subtitle_file.path),
  642. "srclang": subtitle.language.code,
  643. "label": subtitle.language.title,
  644. }
  645. )
  646. return ret
  647. @property
  648. def sprites_url(self):
  649. """Property used on serializers
  650. Returns sprites url
  651. """
  652. if self.sprites:
  653. return helpers.url_from_path(self.sprites.path)
  654. return None
  655. @property
  656. def preview_url(self):
  657. """Property used on serializers
  658. Returns preview url
  659. """
  660. if self.preview_file_path:
  661. return helpers.url_from_path(self.preview_file_path)
  662. # get preview_file out of the encodings, since some times preview_file_path
  663. # is empty but there is the gif encoding!
  664. preview_media = self.encodings.filter(profile__extension="gif").first()
  665. if preview_media and preview_media.media_file:
  666. return helpers.url_from_path(preview_media.media_file.path)
  667. return None
  668. @property
  669. def hls_info(self):
  670. """Property used on serializers
  671. Returns hls info, curated to be read by video.js
  672. """
  673. res = {}
  674. if self.hls_file:
  675. if os.path.exists(self.hls_file):
  676. hls_file = self.hls_file
  677. p = os.path.dirname(hls_file)
  678. m3u8_obj = m3u8.load(hls_file)
  679. if os.path.exists(hls_file):
  680. res["master_file"] = helpers.url_from_path(hls_file)
  681. for iframe_playlist in m3u8_obj.iframe_playlists:
  682. uri = os.path.join(p, iframe_playlist.uri)
  683. if os.path.exists(uri):
  684. resolution = iframe_playlist.iframe_stream_info.resolution[1]
  685. res["{}_iframe".format(resolution)] = helpers.url_from_path(uri)
  686. for playlist in m3u8_obj.playlists:
  687. uri = os.path.join(p, playlist.uri)
  688. if os.path.exists(uri):
  689. resolution = playlist.stream_info.resolution[1]
  690. res["{}_playlist".format(resolution)] = helpers.url_from_path(uri)
  691. return res
  692. @property
  693. def author_name(self):
  694. return self.user.name
  695. @property
  696. def author_username(self):
  697. return self.user.username
  698. def author_profile(self):
  699. return self.user.get_absolute_url()
  700. def author_thumbnail(self):
  701. return helpers.url_from_path(self.user.logo.path)
  702. def get_absolute_url(self, api=False, edit=False):
  703. if edit:
  704. return reverse("edit_media") + "?m={0}".format(self.friendly_token)
  705. if api:
  706. return reverse("api_get_media", kwargs={"friendly_token": self.friendly_token})
  707. else:
  708. return reverse("get_media") + "?m={0}".format(self.friendly_token)
  709. @property
  710. def edit_url(self):
  711. return self.get_absolute_url(edit=True)
  712. @property
  713. def add_subtitle_url(self):
  714. return "/add_subtitle?m=%s" % self.friendly_token
  715. @property
  716. def ratings_info(self):
  717. """Property used on ratings
  718. If ratings functionality enabled
  719. """
  720. # to be used if user ratings are allowed
  721. ret = []
  722. if not settings.ALLOW_RATINGS:
  723. return []
  724. for category in self.rating_category.filter(enabled=True):
  725. ret.append(
  726. {
  727. "score": -1,
  728. # default score, means no score. In case user has already
  729. # rated for this media, it will be populated
  730. "category_id": category.id,
  731. "category_title": category.title,
  732. }
  733. )
  734. return ret
  735. class License(models.Model):
  736. """A Base license model to be used in Media"""
  737. title = models.CharField(max_length=100, unique=True)
  738. description = models.TextField(blank=True)
  739. def __str__(self):
  740. return self.title
  741. class Category(models.Model):
  742. """A Category base model"""
  743. uid = models.UUIDField(unique=True, default=uuid.uuid4)
  744. add_date = models.DateTimeField(auto_now_add=True)
  745. title = models.CharField(max_length=100, unique=True, db_index=True)
  746. description = models.TextField(blank=True)
  747. user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
  748. is_global = models.BooleanField(default=False, help_text="global categories or user specific")
  749. media_count = models.IntegerField(default=0, help_text="number of media")
  750. thumbnail = ProcessedImageField(
  751. upload_to=category_thumb_path,
  752. processors=[ResizeToFit(width=344, height=None)],
  753. format="JPEG",
  754. options={"quality": 85},
  755. blank=True,
  756. )
  757. listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
  758. def __str__(self):
  759. return self.title
  760. class Meta:
  761. ordering = ["title"]
  762. verbose_name_plural = "Categories"
  763. def get_absolute_url(self):
  764. return reverse("search") + "?c={0}".format(self.title)
  765. def update_category_media(self):
  766. """Set media_count"""
  767. self.media_count = Media.objects.filter(listable=True, category=self).count()
  768. self.save(update_fields=["media_count"])
  769. return True
  770. @property
  771. def thumbnail_url(self):
  772. """Return thumbnail for category
  773. prioritize processed value of listings_thumbnail
  774. then thumbnail
  775. """
  776. if self.listings_thumbnail:
  777. return self.listings_thumbnail
  778. if self.thumbnail:
  779. return helpers.url_from_path(self.thumbnail.path)
  780. media = Media.objects.filter(category=self, state="public").order_by("-views").first()
  781. if media:
  782. return media.thumbnail_url
  783. return None
  784. def save(self, *args, **kwargs):
  785. strip_text_items = ["title", "description"]
  786. for item in strip_text_items:
  787. setattr(self, item, strip_tags(getattr(self, item, None)))
  788. super(Category, self).save(*args, **kwargs)
  789. class Tag(models.Model):
  790. """A Tag model"""
  791. title = models.CharField(max_length=100, unique=True, db_index=True)
  792. user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
  793. media_count = models.IntegerField(default=0, help_text="number of media")
  794. listings_thumbnail = models.CharField(
  795. max_length=400,
  796. blank=True,
  797. null=True,
  798. help_text="Thumbnail to show on listings",
  799. db_index=True,
  800. )
  801. def __str__(self):
  802. return self.title
  803. class Meta:
  804. ordering = ["title"]
  805. def get_absolute_url(self):
  806. return reverse("search") + "?t={0}".format(self.title)
  807. def update_tag_media(self):
  808. self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count()
  809. self.save(update_fields=["media_count"])
  810. return True
  811. def save(self, *args, **kwargs):
  812. self.title = helpers.get_alphanumeric_only(self.title)
  813. self.title = self.title[:99]
  814. super(Tag, self).save(*args, **kwargs)
  815. @property
  816. def thumbnail_url(self):
  817. if self.listings_thumbnail:
  818. return self.listings_thumbnail
  819. media = Media.objects.filter(tags=self, state="public").order_by("-views").first()
  820. if media:
  821. return media.thumbnail_url
  822. return None
  823. class EncodeProfile(models.Model):
  824. """Encode Profile model
  825. keeps information for each profile
  826. """
  827. name = models.CharField(max_length=90)
  828. extension = models.CharField(max_length=10, choices=ENCODE_EXTENSIONS)
  829. resolution = models.IntegerField(choices=ENCODE_RESOLUTIONS, blank=True, null=True)
  830. codec = models.CharField(max_length=10, choices=CODECS, blank=True, null=True)
  831. description = models.TextField(blank=True, help_text="description")
  832. active = models.BooleanField(default=True)
  833. def __str__(self):
  834. return self.name
  835. class Meta:
  836. ordering = ["resolution"]
  837. class Encoding(models.Model):
  838. """Encoding Media Instances"""
  839. add_date = models.DateTimeField(auto_now_add=True)
  840. commands = models.TextField(blank=True, help_text="commands run")
  841. chunk = models.BooleanField(default=False, db_index=True, help_text="is chunk?")
  842. chunk_file_path = models.CharField(max_length=400, blank=True)
  843. chunks_info = models.TextField(blank=True)
  844. logs = models.TextField(blank=True)
  845. md5sum = models.CharField(max_length=50, blank=True, null=True)
  846. media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="encodings")
  847. media_file = models.FileField("encoding file", upload_to=encoding_media_file_path, blank=True, max_length=500)
  848. profile = models.ForeignKey(EncodeProfile, on_delete=models.CASCADE)
  849. progress = models.PositiveSmallIntegerField(default=0)
  850. update_date = models.DateTimeField(auto_now=True)
  851. retries = models.IntegerField(default=0)
  852. size = models.CharField(max_length=20, blank=True)
  853. status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending")
  854. temp_file = models.CharField(max_length=400, blank=True)
  855. task_id = models.CharField(max_length=100, blank=True)
  856. total_run_time = models.IntegerField(default=0)
  857. worker = models.CharField(max_length=100, blank=True)
  858. @property
  859. def media_encoding_url(self):
  860. if self.media_file:
  861. return helpers.url_from_path(self.media_file.path)
  862. return None
  863. @property
  864. def media_chunk_url(self):
  865. if self.chunk_file_path:
  866. return helpers.url_from_path(self.chunk_file_path)
  867. return None
  868. def save(self, *args, **kwargs):
  869. if self.media_file:
  870. cmd = ["stat", "-c", "%s", self.media_file.path]
  871. stdout = helpers.run_command(cmd).get("out")
  872. if stdout:
  873. size = int(stdout.strip())
  874. self.size = helpers.show_file_size(size)
  875. if self.chunk_file_path and not self.md5sum:
  876. cmd = ["md5sum", self.chunk_file_path]
  877. stdout = helpers.run_command(cmd).get("out")
  878. if stdout:
  879. md5sum = stdout.strip().split()[0]
  880. self.md5sum = md5sum
  881. super(Encoding, self).save(*args, **kwargs)
  882. def set_progress(self, progress, commit=True):
  883. if isinstance(progress, int):
  884. if 0 <= progress <= 100:
  885. self.progress = progress
  886. self.save(update_fields=["progress"])
  887. return True
  888. return False
  889. def __str__(self):
  890. return "{0}-{1}".format(self.profile.name, self.media.title)
  891. def get_absolute_url(self):
  892. return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
  893. class Language(models.Model):
  894. """Language model
  895. to be used with Subtitles
  896. """
  897. code = models.CharField(max_length=12, help_text="language code")
  898. title = models.CharField(max_length=100, help_text="language code")
  899. class Meta:
  900. ordering = ["id"]
  901. def __str__(self):
  902. return "{0}-{1}".format(self.code, self.title)
  903. class Subtitle(models.Model):
  904. """Subtitles model"""
  905. language = models.ForeignKey(Language, on_delete=models.CASCADE)
  906. media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="subtitles")
  907. subtitle_file = models.FileField(
  908. "Subtitle/CC file",
  909. help_text="File has to be WebVTT format",
  910. upload_to=subtitles_file_path,
  911. max_length=500,
  912. )
  913. user = models.ForeignKey("users.User", on_delete=models.CASCADE)
  914. def __str__(self):
  915. return "{0}-{1}".format(self.media.title, self.language.title)
  916. class RatingCategory(models.Model):
  917. """Rating Category
  918. Facilitate user ratings.
  919. One or more rating categories per Category can exist
  920. will be shown to the media if they are enabled
  921. """
  922. description = models.TextField(blank=True)
  923. enabled = models.BooleanField(default=True)
  924. title = models.CharField(max_length=200, unique=True, db_index=True)
  925. class Meta:
  926. verbose_name_plural = "Rating Categories"
  927. def __str__(self):
  928. return "{0}".format(self.title)
  929. def validate_rating(value):
  930. if -1 >= value or value > 5:
  931. raise ValidationError("score has to be between 0 and 5")
  932. class Rating(models.Model):
  933. """User Rating"""
  934. add_date = models.DateTimeField(auto_now_add=True)
  935. media = models.ForeignKey(Media, on_delete=models.CASCADE, related_name="ratings")
  936. rating_category = models.ForeignKey(RatingCategory, on_delete=models.CASCADE)
  937. score = models.IntegerField(validators=[validate_rating])
  938. user = models.ForeignKey("users.User", on_delete=models.CASCADE)
  939. class Meta:
  940. verbose_name_plural = "Ratings"
  941. indexes = [
  942. models.Index(fields=["user", "media"]),
  943. ]
  944. unique_together = ("user", "media", "rating_category")
  945. def __str__(self):
  946. return "{0}, rate for {1} for category {2}".format(self.user.username, self.media.title, self.rating_category.title)
  947. class Playlist(models.Model):
  948. """Playlists model"""
  949. add_date = models.DateTimeField(auto_now_add=True, db_index=True)
  950. description = models.TextField(blank=True, help_text="description")
  951. friendly_token = models.CharField(blank=True, max_length=12, db_index=True)
  952. media = models.ManyToManyField(Media, through="playlistmedia", blank=True)
  953. title = models.CharField(max_length=100, db_index=True)
  954. uid = models.UUIDField(unique=True, default=uuid.uuid4)
  955. user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True, related_name="playlists")
  956. def __str__(self):
  957. return self.title
  958. @property
  959. def media_count(self):
  960. return self.media.count()
  961. def get_absolute_url(self, api=False):
  962. if api:
  963. return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
  964. else:
  965. return reverse("get_playlist", kwargs={"friendly_token": self.friendly_token})
  966. @property
  967. def url(self):
  968. return self.get_absolute_url()
  969. @property
  970. def api_url(self):
  971. return self.get_absolute_url(api=True)
  972. def user_thumbnail_url(self):
  973. if self.user.logo:
  974. return helpers.url_from_path(self.user.logo.path)
  975. return None
  976. def set_ordering(self, media, ordering):
  977. if media not in self.media.all():
  978. return False
  979. pm = PlaylistMedia.objects.filter(playlist=self, media=media).first()
  980. if pm and isinstance(ordering, int) and 0 < ordering:
  981. pm.ordering = ordering
  982. pm.save()
  983. return True
  984. return False
  985. def save(self, *args, **kwargs):
  986. strip_text_items = ["title", "description"]
  987. for item in strip_text_items:
  988. setattr(self, item, strip_tags(getattr(self, item, None)))
  989. self.title = self.title[:99]
  990. if not self.friendly_token:
  991. while True:
  992. friendly_token = helpers.produce_friendly_token()
  993. if not Playlist.objects.filter(friendly_token=friendly_token):
  994. self.friendly_token = friendly_token
  995. break
  996. super(Playlist, self).save(*args, **kwargs)
  997. @property
  998. def thumbnail_url(self):
  999. pm = self.playlistmedia_set.first()
  1000. if pm and pm.media.thumbnail:
  1001. return helpers.url_from_path(pm.media.thumbnail.path)
  1002. return None
  1003. class PlaylistMedia(models.Model):
  1004. """Helper model to store playlist specific media"""
  1005. action_date = models.DateTimeField(auto_now=True)
  1006. media = models.ForeignKey(Media, on_delete=models.CASCADE)
  1007. playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
  1008. ordering = models.IntegerField(default=1)
  1009. class Meta:
  1010. ordering = ["ordering", "-action_date"]
  1011. class Comment(MPTTModel):
  1012. """Comments model"""
  1013. add_date = models.DateTimeField(auto_now_add=True)
  1014. media = models.ForeignKey(Media, on_delete=models.CASCADE, db_index=True, related_name="comments")
  1015. parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
  1016. text = models.TextField(help_text="text")
  1017. uid = models.UUIDField(unique=True, default=uuid.uuid4)
  1018. user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True)
  1019. class MPTTMeta:
  1020. order_insertion_by = ["add_date"]
  1021. def __str__(self):
  1022. return "On {0} by {1}".format(self.media.title, self.user.username)
  1023. def save(self, *args, **kwargs):
  1024. strip_text_items = ["text"]
  1025. for item in strip_text_items:
  1026. setattr(self, item, strip_tags(getattr(self, item, None)))
  1027. if self.text:
  1028. self.text = self.text[: settings.MAX_CHARS_FOR_COMMENT]
  1029. super(Comment, self).save(*args, **kwargs)
  1030. def get_absolute_url(self):
  1031. return reverse("get_media") + "?m={0}".format(self.media.friendly_token)
  1032. @property
  1033. def media_url(self):
  1034. return self.get_absolute_url()
  1035. @receiver(post_save, sender=Media)
  1036. def media_save(sender, instance, created, **kwargs):
  1037. # media_file path is not set correctly until mode is saved
  1038. # post_save signal will take care of calling a few functions
  1039. # once model is saved
  1040. # SOS: do not put anything here, as if more logic is added,
  1041. # we have to disconnect signal to avoid infinite recursion
  1042. if created:
  1043. from .methods import notify_users
  1044. instance.media_init()
  1045. notify_users(friendly_token=instance.friendly_token, action="media_added")
  1046. instance.user.update_user_media()
  1047. if instance.category.all():
  1048. # this won't catch when a category
  1049. # is removed from a media, which is what we want...
  1050. for category in instance.category.all():
  1051. category.update_category_media()
  1052. if instance.tags.all():
  1053. for tag in instance.tags.all():
  1054. tag.update_tag_media()
  1055. instance.update_search_vector()
  1056. @receiver(pre_delete, sender=Media)
  1057. def media_file_pre_delete(sender, instance, **kwargs):
  1058. if instance.category.all():
  1059. for category in instance.category.all():
  1060. instance.category.remove(category)
  1061. category.update_category_media()
  1062. if instance.tags.all():
  1063. for tag in instance.tags.all():
  1064. instance.tags.remove(tag)
  1065. tag.update_tag_media()
  1066. @receiver(post_delete, sender=Media)
  1067. def media_file_delete(sender, instance, **kwargs):
  1068. """
  1069. Deletes file from filesystem
  1070. when corresponding `Media` object is deleted.
  1071. """
  1072. if instance.media_file:
  1073. helpers.rm_file(instance.media_file.path)
  1074. if instance.thumbnail:
  1075. helpers.rm_file(instance.thumbnail.path)
  1076. if instance.poster:
  1077. helpers.rm_file(instance.poster.path)
  1078. if instance.uploaded_thumbnail:
  1079. helpers.rm_file(instance.uploaded_thumbnail.path)
  1080. if instance.uploaded_poster:
  1081. helpers.rm_file(instance.uploaded_poster.path)
  1082. if instance.sprites:
  1083. helpers.rm_file(instance.sprites.path)
  1084. if instance.hls_file:
  1085. p = os.path.dirname(instance.hls_file)
  1086. helpers.rm_dir(p)
  1087. instance.user.update_user_media()
  1088. # remove extra zombie thumbnails
  1089. if instance.thumbnail:
  1090. thumbnails_path = os.path.dirname(instance.thumbnail.path)
  1091. thumbnails = glob.glob(f'{thumbnails_path}/{instance.uid.hex}.*')
  1092. for thumbnail in thumbnails:
  1093. helpers.rm_file(thumbnail)
  1094. @receiver(m2m_changed, sender=Media.category.through)
  1095. def media_m2m(sender, instance, **kwargs):
  1096. if instance.category.all():
  1097. for category in instance.category.all():
  1098. category.update_category_media()
  1099. if instance.tags.all():
  1100. for tag in instance.tags.all():
  1101. tag.update_tag_media()
  1102. @receiver(post_save, sender=Encoding)
  1103. def encoding_file_save(sender, instance, created, **kwargs):
  1104. """Performs actions on encoding file delete
  1105. For example, if encoding is a chunk file, with encoding_status success,
  1106. perform a check if this is the final chunk file of a media, then
  1107. concatenate chunks, create final encoding file and delete chunks
  1108. """
  1109. if instance.chunk and instance.status == "success":
  1110. # a chunk got completed
  1111. # check if all chunks are OK
  1112. # then concatenate to new Encoding - and remove chunks
  1113. # this should run only once!
  1114. if instance.media_file:
  1115. try:
  1116. orig_chunks = json.loads(instance.chunks_info).keys()
  1117. except BaseException:
  1118. instance.delete()
  1119. return False
  1120. chunks = Encoding.objects.filter(
  1121. media=instance.media,
  1122. profile=instance.profile,
  1123. chunks_info=instance.chunks_info,
  1124. chunk=True,
  1125. ).order_by("add_date")
  1126. complete = True
  1127. # perform validation, make sure everything is there
  1128. for chunk in orig_chunks:
  1129. if not chunks.filter(chunk_file_path=chunk):
  1130. complete = False
  1131. break
  1132. for chunk in chunks:
  1133. if not (chunk.media_file and chunk.media_file.path):
  1134. complete = False
  1135. break
  1136. if complete:
  1137. # concatenate chunks and create final encoding file
  1138. chunks_paths = [f.media_file.path for f in chunks]
  1139. with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
  1140. seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir)
  1141. tf = helpers.create_temp_file(suffix=".{0}".format(instance.profile.extension), dir=temp_dir)
  1142. with open(seg_file, "w") as ff:
  1143. for f in chunks_paths:
  1144. ff.write("file {}\n".format(f))
  1145. cmd = [
  1146. settings.FFMPEG_COMMAND,
  1147. "-y",
  1148. "-f",
  1149. "concat",
  1150. "-safe",
  1151. "0",
  1152. "-i",
  1153. seg_file,
  1154. "-c",
  1155. "copy",
  1156. "-pix_fmt",
  1157. "yuv420p",
  1158. "-movflags",
  1159. "faststart",
  1160. tf,
  1161. ]
  1162. stdout = helpers.run_command(cmd)
  1163. encoding = Encoding(
  1164. media=instance.media,
  1165. profile=instance.profile,
  1166. status="success",
  1167. progress=100,
  1168. )
  1169. all_logs = "\n".join([st.logs for st in chunks])
  1170. encoding.logs = "{0}\n{1}\n{2}".format(chunks_paths, stdout, all_logs)
  1171. workers = list(set([st.worker for st in chunks]))
  1172. encoding.worker = json.dumps({"workers": workers})
  1173. start_date = min([st.add_date for st in chunks])
  1174. end_date = max([st.update_date for st in chunks])
  1175. encoding.total_run_time = (end_date - start_date).seconds
  1176. encoding.save()
  1177. with open(tf, "rb") as f:
  1178. myfile = File(f)
  1179. output_name = "{0}.{1}".format(
  1180. helpers.get_file_name(instance.media.media_file.path),
  1181. instance.profile.extension,
  1182. )
  1183. encoding.media_file.save(content=myfile, name=output_name)
  1184. # encoding is saved, deleting chunks
  1185. # and any other encoding that might exist
  1186. # first perform one last validation
  1187. # to avoid that this is run twice
  1188. if (
  1189. len(orig_chunks)
  1190. == Encoding.objects.filter( # noqa
  1191. media=instance.media,
  1192. profile=instance.profile,
  1193. chunks_info=instance.chunks_info,
  1194. ).count()
  1195. ):
  1196. # if two chunks are finished at the same time, this
  1197. # will be changed
  1198. who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
  1199. who.delete()
  1200. else:
  1201. encoding.delete()
  1202. if not Encoding.objects.filter(chunks_info=instance.chunks_info):
  1203. # TODO: in case of remote workers, files should be deleted
  1204. # example
  1205. # for worker in workers:
  1206. # for chunk in json.loads(instance.chunks_info).keys():
  1207. # remove_media_file.delay(media_file=chunk)
  1208. for chunk in json.loads(instance.chunks_info).keys():
  1209. helpers.rm_file(chunk)
  1210. instance.media.post_encode_actions(encoding=instance, action="add")
  1211. elif instance.chunk and instance.status == "fail":
  1212. encoding = Encoding(media=instance.media, profile=instance.profile, status="fail", progress=100)
  1213. chunks = Encoding.objects.filter(media=instance.media, chunks_info=instance.chunks_info, chunk=True).order_by("add_date")
  1214. chunks_paths = [f.media_file.path for f in chunks]
  1215. all_logs = "\n".join([st.logs for st in chunks])
  1216. encoding.logs = "{0}\n{1}".format(chunks_paths, all_logs)
  1217. workers = list(set([st.worker for st in chunks]))
  1218. encoding.worker = json.dumps({"workers": workers})
  1219. start_date = min([st.add_date for st in chunks])
  1220. end_date = max([st.update_date for st in chunks])
  1221. encoding.total_run_time = (end_date - start_date).seconds
  1222. encoding.save()
  1223. who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
  1224. who.delete()
  1225. # TODO: merge with above if, do not repeat code
  1226. else:
  1227. if instance.status in ["fail", "success"]:
  1228. instance.media.post_encode_actions(encoding=instance, action="add")
  1229. encodings = set([encoding.status for encoding in Encoding.objects.filter(media=instance.media)])
  1230. if ("running" in encodings) or ("pending" in encodings):
  1231. return
  1232. @receiver(post_delete, sender=Encoding)
  1233. def encoding_file_delete(sender, instance, **kwargs):
  1234. """
  1235. Deletes file from filesystem
  1236. when corresponding `Encoding` object is deleted.
  1237. """
  1238. if instance.media_file:
  1239. helpers.rm_file(instance.media_file.path)
  1240. if not instance.chunk:
  1241. instance.media.post_encode_actions(encoding=instance, action="delete")
  1242. # delete local chunks, and remote chunks + media file. Only when the
  1243. # last encoding of a media is complete
  1244. class Voice(models.Model):
  1245. """The model for voice recordings by user"""
  1246. add_date = models.DateTimeField("Date produced", blank=True, null=True, db_index=True)
  1247. duration = models.IntegerField(default=0)
  1248. friendly_token = models.CharField(blank=True, max_length=12, db_index=True, help_text="Identifier for the voice")
  1249. likes = models.IntegerField(db_index=True, default=0)
  1250. md5sum = models.CharField(max_length=50, blank=True, null=True, help_text="Not exposed, used internally")
  1251. media = models.ForeignKey(Media, on_delete=models.CASCADE, db_index=True, related_name="voices")
  1252. voice_file = models.FileField(
  1253. "voice file",
  1254. upload_to=original_voice_file_path,
  1255. max_length=500,
  1256. help_text="voice file",
  1257. )
  1258. reported_times = models.IntegerField(default=0, help_text="how many times a voice is reported")
  1259. start = models.FloatField(blank=True, null=True, help_text="Time on video that a voice will start playing")
  1260. title = models.CharField(max_length=100, help_text="voice title", blank=True, db_index=True)
  1261. uid = models.UUIDField(unique=True, default=uuid.uuid4, help_text="A unique identifier for the voice")
  1262. user = models.ForeignKey("users.User", on_delete=models.CASCADE, help_text="user that uploads the voice")
  1263. views = models.IntegerField(db_index=True, default=1)
  1264. class Meta:
  1265. ordering = ["-add_date"]
  1266. def __str__(self):
  1267. return self.title
  1268. # Make sure some fields are properly available.
  1269. def save(self, *args, **kwargs):
  1270. if not self.title:
  1271. self.title = self.voice_file.path.split("/")[-1]
  1272. self.title = self.title[:99]
  1273. # by default get an add_date of now
  1274. if not self.add_date:
  1275. self.add_date = timezone.now()
  1276. if not self.friendly_token:
  1277. # get a unique identifier
  1278. while True:
  1279. friendly_token = helpers.produce_friendly_token()
  1280. if not Voice.objects.filter(friendly_token=friendly_token):
  1281. self.friendly_token = friendly_token
  1282. break
  1283. super(Voice, self).save(*args, **kwargs)
  1284. # Copied from `Comment` model.
  1285. def get_absolute_url(self):
  1286. return reverse("get_media") + "?m={0}".format(self.media.friendly_token)
  1287. # Copied from `Comment` model.
  1288. @property
  1289. def media_url(self):
  1290. """Property used on serializers"""
  1291. return self.get_absolute_url()
  1292. # Copied from `Media` model.
  1293. @property
  1294. def original_voice_url(self):
  1295. """Property used on serializers"""
  1296. # TODO: This new config could be added: settings.SHOW_ORIGINAL_VOICE
  1297. if settings.SHOW_ORIGINAL_MEDIA:
  1298. return helpers.url_from_path(self.voice_file.path)
  1299. else:
  1300. return None