models.py 54 KB

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