mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-21 22:07:59 -05:00
feat: bulk actions API
This commit is contained in:
25
files/models/__init__.py
Normal file
25
files/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Import all models for backward compatibility
|
||||
from .category import Category, Tag # noqa: F401
|
||||
from .comment import Comment # noqa: F401
|
||||
from .encoding import EncodeProfile, Encoding # noqa: F401
|
||||
from .license import License # noqa: F401
|
||||
from .media import Media, MediaPermission # noqa: F401
|
||||
from .playlist import Playlist, PlaylistMedia # noqa: F401
|
||||
from .rating import Rating, RatingCategory # noqa: F401
|
||||
from .subtitle import Language, Subtitle # noqa: F401
|
||||
from .utils import CODECS # noqa: F401
|
||||
from .utils import ENCODE_EXTENSIONS # noqa: F401
|
||||
from .utils import ENCODE_EXTENSIONS_KEYS # noqa: F401
|
||||
from .utils import ENCODE_RESOLUTIONS # noqa: F401
|
||||
from .utils import ENCODE_RESOLUTIONS_KEYS # noqa: F401
|
||||
from .utils import MEDIA_ENCODING_STATUS # noqa: F401
|
||||
from .utils import MEDIA_STATES # noqa: F401
|
||||
from .utils import MEDIA_TYPES_SUPPORTED # noqa: F401
|
||||
from .utils import category_thumb_path # noqa: F401
|
||||
from .utils import encoding_media_file_path # noqa: F401
|
||||
from .utils import generate_uid # noqa: F401
|
||||
from .utils import original_media_file_path # noqa: F401
|
||||
from .utils import original_thumbnail_file_path # noqa: F401
|
||||
from .utils import subtitles_file_path # noqa: F401
|
||||
from .utils import validate_rating # noqa: F401
|
||||
from .video_data import VideoChapterData, VideoTrimRequest # noqa: F401
|
||||
156
files/models/category.py
Normal file
156
files/models/category.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import strip_tags
|
||||
from imagekit.models import ProcessedImageField
|
||||
from imagekit.processors import ResizeToFit
|
||||
|
||||
from .. import helpers
|
||||
from .utils import category_thumb_path, generate_uid
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
"""A Category base model"""
|
||||
|
||||
uid = models.CharField(unique=True, max_length=36, default=generate_uid)
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
title = models.CharField(max_length=100, db_index=True)
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
is_global = models.BooleanField(default=False, help_text="global categories or user specific")
|
||||
|
||||
media_count = models.IntegerField(default=0, help_text="number of media")
|
||||
|
||||
thumbnail = ProcessedImageField(
|
||||
upload_to=category_thumb_path,
|
||||
processors=[ResizeToFit(width=344, height=None)],
|
||||
format="JPEG",
|
||||
options={"quality": 85},
|
||||
blank=True,
|
||||
)
|
||||
|
||||
listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
|
||||
|
||||
is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups')
|
||||
|
||||
identity_provider = models.ForeignKey(
|
||||
'socialaccount.SocialApp',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='categories',
|
||||
help_text='If category is related with a specific Identity Provider',
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ["title"]
|
||||
verbose_name_plural = "Categories"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{reverse('search')}?c={self.title}"
|
||||
|
||||
def update_category_media(self):
|
||||
"""Set media_count"""
|
||||
|
||||
# Always set number of Category the total number of media
|
||||
# Depending on how RBAC is set and Permissions etc it is
|
||||
# possible that users won't see all media in a Category
|
||||
# but it's worth to handle this on the UI level
|
||||
# (eg through a message that says that you see only files you have permissions to see)
|
||||
|
||||
self.media_count = Media.objects.filter(category=self).count()
|
||||
self.save(update_fields=["media_count"])
|
||||
|
||||
# OLD logic
|
||||
# if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category:
|
||||
# self.media_count = Media.objects.filter(category=self).count()
|
||||
# else:
|
||||
# self.media_count = Media.objects.filter(listable=True, category=self).count()
|
||||
|
||||
self.save(update_fields=["media_count"])
|
||||
return True
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
"""Return thumbnail for category
|
||||
prioritize processed value of listings_thumbnail
|
||||
then thumbnail
|
||||
"""
|
||||
|
||||
if self.thumbnail:
|
||||
return helpers.url_from_path(self.thumbnail.path)
|
||||
|
||||
if self.listings_thumbnail:
|
||||
return self.listings_thumbnail
|
||||
|
||||
if Media.objects.filter(category=self, state="public").exists():
|
||||
media = Media.objects.filter(category=self, state="public").order_by("-views").first()
|
||||
if media:
|
||||
return media.thumbnail_url
|
||||
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
strip_text_items = ["title", "description"]
|
||||
for item in strip_text_items:
|
||||
setattr(self, item, strip_tags(getattr(self, item, None)))
|
||||
super(Category, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
"""A Tag model"""
|
||||
|
||||
title = models.CharField(max_length=100, unique=True, db_index=True)
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
media_count = models.IntegerField(default=0, help_text="number of media")
|
||||
|
||||
listings_thumbnail = models.CharField(
|
||||
max_length=400,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Thumbnail to show on listings",
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
ordering = ["title"]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{reverse('search')}?t={self.title}"
|
||||
|
||||
def update_tag_media(self):
|
||||
self.media_count = Media.objects.filter(state="public", is_reviewed=True, tags=self).count()
|
||||
self.save(update_fields=["media_count"])
|
||||
return True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.title = helpers.get_alphanumeric_only(self.title)
|
||||
self.title = self.title[:99]
|
||||
super(Tag, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
if self.listings_thumbnail:
|
||||
return self.listings_thumbnail
|
||||
media = Media.objects.filter(tags=self, state="public").order_by("-views").first()
|
||||
if media:
|
||||
return media.thumbnail_url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Import Media to avoid circular imports
|
||||
from .media import Media # noqa
|
||||
46
files/models/comment.py
Normal file
46
files/models/comment.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import strip_tags
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
|
||||
class Comment(MPTTModel):
|
||||
"""Comments model"""
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE, db_index=True, related_name="comments")
|
||||
|
||||
parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
|
||||
|
||||
text = models.TextField(help_text="text")
|
||||
|
||||
uid = models.UUIDField(unique=True, default=uuid.uuid4)
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True)
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ["add_date"]
|
||||
|
||||
def __str__(self):
|
||||
return f"On {self.media.title} by {self.user.username}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
strip_text_items = ["text"]
|
||||
for item in strip_text_items:
|
||||
setattr(self, item, strip_tags(getattr(self, item, None)))
|
||||
|
||||
if self.text:
|
||||
self.text = self.text[: settings.MAX_CHARS_FOR_COMMENT]
|
||||
|
||||
super(Comment, self).save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{reverse('get_media')}?m={self.media.friendly_token}"
|
||||
|
||||
@property
|
||||
def media_url(self):
|
||||
return self.get_absolute_url()
|
||||
303
files/models/encoding.py
Normal file
303
files/models/encoding.py
Normal file
@@ -0,0 +1,303 @@
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
|
||||
from .. import helpers
|
||||
from .utils import (
|
||||
CODECS,
|
||||
ENCODE_EXTENSIONS,
|
||||
ENCODE_RESOLUTIONS,
|
||||
MEDIA_ENCODING_STATUS,
|
||||
encoding_media_file_path,
|
||||
)
|
||||
|
||||
|
||||
class EncodeProfile(models.Model):
|
||||
"""Encode Profile model
|
||||
keeps information for each profile
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=90)
|
||||
|
||||
extension = models.CharField(max_length=10, choices=ENCODE_EXTENSIONS)
|
||||
|
||||
resolution = models.IntegerField(choices=ENCODE_RESOLUTIONS, blank=True, null=True)
|
||||
|
||||
codec = models.CharField(max_length=10, choices=CODECS, blank=True, null=True)
|
||||
|
||||
description = models.TextField(blank=True, help_text="description")
|
||||
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ["resolution"]
|
||||
|
||||
|
||||
class Encoding(models.Model):
|
||||
"""Encoding Media Instances"""
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
commands = models.TextField(blank=True, help_text="commands run")
|
||||
|
||||
chunk = models.BooleanField(default=False, db_index=True, help_text="is chunk?")
|
||||
|
||||
chunk_file_path = models.CharField(max_length=400, blank=True)
|
||||
|
||||
chunks_info = models.TextField(blank=True)
|
||||
|
||||
logs = models.TextField(blank=True)
|
||||
|
||||
md5sum = models.CharField(max_length=50, blank=True, null=True)
|
||||
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="encodings")
|
||||
|
||||
media_file = models.FileField("encoding file", upload_to=encoding_media_file_path, blank=True, max_length=500)
|
||||
|
||||
profile = models.ForeignKey(EncodeProfile, on_delete=models.CASCADE)
|
||||
|
||||
progress = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
update_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
retries = models.IntegerField(default=0)
|
||||
|
||||
size = models.CharField(max_length=20, blank=True)
|
||||
|
||||
status = models.CharField(max_length=20, choices=MEDIA_ENCODING_STATUS, default="pending")
|
||||
|
||||
temp_file = models.CharField(max_length=400, blank=True)
|
||||
|
||||
task_id = models.CharField(max_length=100, blank=True)
|
||||
|
||||
total_run_time = models.IntegerField(default=0)
|
||||
|
||||
worker = models.CharField(max_length=100, blank=True)
|
||||
|
||||
@property
|
||||
def media_encoding_url(self):
|
||||
if self.media_file:
|
||||
return helpers.url_from_path(self.media_file.path)
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_chunk_url(self):
|
||||
if self.chunk_file_path:
|
||||
return helpers.url_from_path(self.chunk_file_path)
|
||||
return None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.media_file:
|
||||
cmd = ["stat", "-c", "%s", self.media_file.path]
|
||||
stdout = helpers.run_command(cmd).get("out")
|
||||
if stdout:
|
||||
size = int(stdout.strip())
|
||||
self.size = helpers.show_file_size(size)
|
||||
if self.chunk_file_path and not self.md5sum:
|
||||
cmd = ["md5sum", self.chunk_file_path]
|
||||
stdout = helpers.run_command(cmd).get("out")
|
||||
if stdout:
|
||||
md5sum = stdout.strip().split()[0]
|
||||
self.md5sum = md5sum
|
||||
|
||||
super(Encoding, self).save(*args, **kwargs)
|
||||
|
||||
def update_size_without_save(self):
|
||||
"""Update the size of an encoding without saving to avoid calling signals"""
|
||||
if self.media_file:
|
||||
cmd = ["stat", "-c", "%s", self.media_file.path]
|
||||
stdout = helpers.run_command(cmd).get("out")
|
||||
if stdout:
|
||||
size = int(stdout.strip())
|
||||
size = helpers.show_file_size(size)
|
||||
Encoding.objects.filter(pk=self.pk).update(size=size)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_progress(self, progress, commit=True):
|
||||
if isinstance(progress, int):
|
||||
if 0 <= progress <= 100:
|
||||
self.progress = progress
|
||||
# save object with filter update
|
||||
# to avoid calling signals
|
||||
Encoding.objects.filter(pk=self.pk).update(progress=progress)
|
||||
return True
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.profile.name}-{self.media.title}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("api_get_encoding", kwargs={"encoding_id": self.id})
|
||||
|
||||
|
||||
@receiver(post_save, sender=Encoding)
|
||||
def encoding_file_save(sender, instance, created, **kwargs):
|
||||
"""Performs actions on encoding file delete
|
||||
For example, if encoding is a chunk file, with encoding_status success,
|
||||
perform a check if this is the final chunk file of a media, then
|
||||
concatenate chunks, create final encoding file and delete chunks
|
||||
"""
|
||||
|
||||
if instance.chunk and instance.status == "success":
|
||||
# a chunk got completed
|
||||
|
||||
# check if all chunks are OK
|
||||
# then concatenate to new Encoding - and remove chunks
|
||||
# this should run only once!
|
||||
if instance.media_file:
|
||||
try:
|
||||
orig_chunks = json.loads(instance.chunks_info).keys()
|
||||
except BaseException:
|
||||
instance.delete()
|
||||
return False
|
||||
|
||||
chunks = Encoding.objects.filter(
|
||||
media=instance.media,
|
||||
profile=instance.profile,
|
||||
chunks_info=instance.chunks_info,
|
||||
chunk=True,
|
||||
).order_by("add_date")
|
||||
|
||||
complete = True
|
||||
|
||||
# perform validation, make sure everything is there
|
||||
for chunk in orig_chunks:
|
||||
if not chunks.filter(chunk_file_path=chunk):
|
||||
complete = False
|
||||
break
|
||||
|
||||
for chunk in chunks:
|
||||
if not (chunk.media_file and chunk.media_file.path):
|
||||
complete = False
|
||||
break
|
||||
|
||||
if complete:
|
||||
# concatenate chunks and create final encoding file
|
||||
chunks_paths = [f.media_file.path for f in chunks]
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
|
||||
seg_file = helpers.create_temp_file(suffix=".txt", dir=temp_dir)
|
||||
tf = helpers.create_temp_file(suffix=f".{instance.profile.extension}", dir=temp_dir)
|
||||
with open(seg_file, "w") as ff:
|
||||
for f in chunks_paths:
|
||||
ff.write(f"file {f}\n")
|
||||
cmd = [
|
||||
settings.FFMPEG_COMMAND,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
seg_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"faststart",
|
||||
tf,
|
||||
]
|
||||
stdout = helpers.run_command(cmd)
|
||||
|
||||
encoding = Encoding(
|
||||
media=instance.media,
|
||||
profile=instance.profile,
|
||||
status="success",
|
||||
progress=100,
|
||||
)
|
||||
all_logs = "\n".join([st.logs for st in chunks])
|
||||
encoding.logs = f"{chunks_paths}\n{stdout}\n{all_logs}"
|
||||
workers = list(set([st.worker for st in chunks]))
|
||||
encoding.worker = json.dumps({"workers": workers})
|
||||
|
||||
start_date = min([st.add_date for st in chunks])
|
||||
end_date = max([st.update_date for st in chunks])
|
||||
encoding.total_run_time = (end_date - start_date).seconds
|
||||
encoding.save()
|
||||
|
||||
with open(tf, "rb") as f:
|
||||
myfile = File(f)
|
||||
output_name = f"{helpers.get_file_name(instance.media.media_file.path)}.{instance.profile.extension}"
|
||||
encoding.media_file.save(content=myfile, name=output_name)
|
||||
|
||||
# encoding is saved, deleting chunks
|
||||
# and any other encoding that might exist
|
||||
# first perform one last validation
|
||||
# to avoid that this is run twice
|
||||
if (
|
||||
len(orig_chunks)
|
||||
== Encoding.objects.filter( # noqa
|
||||
media=instance.media,
|
||||
profile=instance.profile,
|
||||
chunks_info=instance.chunks_info,
|
||||
).count()
|
||||
):
|
||||
# if two chunks are finished at the same time, this
|
||||
# will be changed
|
||||
who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
|
||||
who.delete()
|
||||
else:
|
||||
encoding.delete()
|
||||
if not Encoding.objects.filter(chunks_info=instance.chunks_info):
|
||||
# TODO: in case of remote workers, files should be deleted
|
||||
# example
|
||||
# for worker in workers:
|
||||
# for chunk in json.loads(instance.chunks_info).keys():
|
||||
# remove_media_file.delay(media_file=chunk)
|
||||
for chunk in json.loads(instance.chunks_info).keys():
|
||||
helpers.rm_file(chunk)
|
||||
instance.media.post_encode_actions(encoding=instance, action="add")
|
||||
|
||||
elif instance.chunk and instance.status == "fail":
|
||||
encoding = Encoding(media=instance.media, profile=instance.profile, status="fail", progress=100)
|
||||
|
||||
chunks = Encoding.objects.filter(media=instance.media, chunks_info=instance.chunks_info, chunk=True).order_by("add_date")
|
||||
|
||||
chunks_paths = [f.media_file.path for f in chunks]
|
||||
|
||||
all_logs = "\n".join([st.logs for st in chunks])
|
||||
encoding.logs = f"{chunks_paths}\n{all_logs}"
|
||||
workers = list(set([st.worker for st in chunks]))
|
||||
encoding.worker = json.dumps({"workers": workers})
|
||||
start_date = min([st.add_date for st in chunks])
|
||||
end_date = max([st.update_date for st in chunks])
|
||||
encoding.total_run_time = (end_date - start_date).seconds
|
||||
encoding.save()
|
||||
|
||||
who = Encoding.objects.filter(media=encoding.media, profile=encoding.profile).exclude(id=encoding.id)
|
||||
|
||||
who.delete()
|
||||
# TODO: merge with above if, do not repeat code
|
||||
else:
|
||||
if instance.status in ["fail", "success"]:
|
||||
instance.media.post_encode_actions(encoding=instance, action="add")
|
||||
|
||||
encodings = set([encoding.status for encoding in Encoding.objects.filter(media=instance.media)])
|
||||
if ("running" in encodings) or ("pending" in encodings):
|
||||
return
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Encoding)
|
||||
def encoding_file_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Deletes file from filesystem
|
||||
when corresponding `Encoding` object is deleted.
|
||||
"""
|
||||
|
||||
if instance.media_file:
|
||||
helpers.rm_file(instance.media_file.path)
|
||||
if not instance.chunk:
|
||||
instance.media.post_encode_actions(encoding=instance, action="delete")
|
||||
# delete local chunks, and remote chunks + media file. Only when the
|
||||
# last encoding of a media is complete
|
||||
11
files/models/license.py
Normal file
11
files/models/license.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class License(models.Model):
|
||||
"""A Base license model to be used in Media"""
|
||||
|
||||
title = models.CharField(max_length=100, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
1012
files/models/media.py
Normal file
1012
files/models/media.py
Normal file
File diff suppressed because it is too large
Load Diff
97
files/models/playlist.py
Normal file
97
files/models/playlist.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
from .. import helpers
|
||||
|
||||
|
||||
class Playlist(models.Model):
|
||||
"""Playlists model"""
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
description = models.TextField(blank=True, help_text="description")
|
||||
|
||||
friendly_token = models.CharField(blank=True, max_length=12, db_index=True)
|
||||
|
||||
media = models.ManyToManyField("Media", through="playlistmedia", blank=True)
|
||||
|
||||
title = models.CharField(max_length=100, db_index=True)
|
||||
|
||||
uid = models.UUIDField(unique=True, default=uuid.uuid4)
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, db_index=True, related_name="playlists")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def media_count(self):
|
||||
return self.media.filter(listable=True).count()
|
||||
|
||||
def get_absolute_url(self, api=False):
|
||||
if api:
|
||||
return reverse("api_get_playlist", kwargs={"friendly_token": self.friendly_token})
|
||||
else:
|
||||
return reverse("get_playlist", kwargs={"friendly_token": self.friendly_token})
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.get_absolute_url()
|
||||
|
||||
@property
|
||||
def api_url(self):
|
||||
return self.get_absolute_url(api=True)
|
||||
|
||||
def user_thumbnail_url(self):
|
||||
if self.user.logo:
|
||||
return helpers.url_from_path(self.user.logo.path)
|
||||
return None
|
||||
|
||||
def set_ordering(self, media, ordering):
|
||||
if media not in self.media.all():
|
||||
return False
|
||||
pm = PlaylistMedia.objects.filter(playlist=self, media=media).first()
|
||||
if pm and isinstance(ordering, int) and 0 < ordering:
|
||||
pm.ordering = ordering
|
||||
pm.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
strip_text_items = ["title", "description"]
|
||||
for item in strip_text_items:
|
||||
setattr(self, item, strip_tags(getattr(self, item, None)))
|
||||
self.title = self.title[:99]
|
||||
|
||||
if not self.friendly_token:
|
||||
while True:
|
||||
friendly_token = helpers.produce_friendly_token()
|
||||
if not Playlist.objects.filter(friendly_token=friendly_token):
|
||||
self.friendly_token = friendly_token
|
||||
break
|
||||
super(Playlist, self).save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
pm = self.playlistmedia_set.filter(media__listable=True).first()
|
||||
if pm and pm.media.thumbnail:
|
||||
return helpers.url_from_path(pm.media.thumbnail.path)
|
||||
return None
|
||||
|
||||
|
||||
class PlaylistMedia(models.Model):
|
||||
"""Helper model to store playlist specific media"""
|
||||
|
||||
action_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE)
|
||||
|
||||
playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
|
||||
|
||||
ordering = models.IntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
ordering = ["ordering", "-action_date"]
|
||||
47
files/models/rating.py
Normal file
47
files/models/rating.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.db import models
|
||||
|
||||
from .utils import validate_rating
|
||||
|
||||
|
||||
class RatingCategory(models.Model):
|
||||
"""Rating Category
|
||||
Facilitate user ratings.
|
||||
One or more rating categories per Category can exist
|
||||
will be shown to the media if they are enabled
|
||||
"""
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
title = models.CharField(max_length=200, unique=True, db_index=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Rating Categories"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title}"
|
||||
|
||||
|
||||
class Rating(models.Model):
|
||||
"""User Rating"""
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="ratings")
|
||||
|
||||
rating_category = models.ForeignKey(RatingCategory, on_delete=models.CASCADE)
|
||||
|
||||
score = models.IntegerField(validators=[validate_rating])
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Ratings"
|
||||
indexes = [
|
||||
models.Index(fields=["user", "media"]),
|
||||
]
|
||||
unique_together = ("user", "media", "rating_category")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username}, rate for {self.media.title} for category {self.rating_category.title}"
|
||||
72
files/models/subtitle.py
Normal file
72
files/models/subtitle.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from .. import helpers
|
||||
from .utils import subtitles_file_path
|
||||
|
||||
|
||||
class Language(models.Model):
|
||||
"""Language model
|
||||
to be used with Subtitles
|
||||
"""
|
||||
|
||||
code = models.CharField(max_length=12, help_text="language code")
|
||||
|
||||
title = models.CharField(max_length=100, help_text="language code")
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code}-{self.title}"
|
||||
|
||||
|
||||
class Subtitle(models.Model):
|
||||
"""Subtitles model"""
|
||||
|
||||
language = models.ForeignKey(Language, on_delete=models.CASCADE)
|
||||
|
||||
media = models.ForeignKey("Media", on_delete=models.CASCADE, related_name="subtitles")
|
||||
|
||||
subtitle_file = models.FileField(
|
||||
"Subtitle/CC file",
|
||||
help_text="File has to be WebVTT format",
|
||||
upload_to=subtitles_file_path,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ["language__title"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media.title}-{self.language.title}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{reverse('edit_subtitle')}?id={self.id}"
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.get_absolute_url()
|
||||
|
||||
def convert_to_srt(self):
|
||||
input_path = self.subtitle_file.path
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
|
||||
pysub = settings.PYSUBS_COMMAND
|
||||
|
||||
cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname]
|
||||
stdout = helpers.run_command(cmd)
|
||||
|
||||
list_of_files = os.listdir(tmpdirname)
|
||||
if list_of_files:
|
||||
subtitles_file = os.path.join(tmpdirname, list_of_files[0])
|
||||
cmd = ["cp", subtitles_file, input_path]
|
||||
stdout = helpers.run_command(cmd) # noqa
|
||||
else:
|
||||
raise Exception("Could not convert to srt")
|
||||
return True
|
||||
99
files/models/utils.py
Normal file
99
files/models/utils.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from .. import helpers
|
||||
|
||||
# this is used by Media and Encoding models
|
||||
# reflects media encoding status for objects
|
||||
MEDIA_ENCODING_STATUS = (
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("fail", "Fail"),
|
||||
("success", "Success"),
|
||||
)
|
||||
|
||||
# the media state of a Media object
|
||||
# this is set by default according to the portal workflow
|
||||
MEDIA_STATES = (
|
||||
("private", "Private"),
|
||||
("public", "Public"),
|
||||
("unlisted", "Unlisted"),
|
||||
)
|
||||
|
||||
# each uploaded Media gets a media_type hint
|
||||
# by helpers.get_file_type
|
||||
|
||||
MEDIA_TYPES_SUPPORTED = (
|
||||
("video", "Video"),
|
||||
("image", "Image"),
|
||||
("pdf", "Pdf"),
|
||||
("audio", "Audio"),
|
||||
)
|
||||
|
||||
ENCODE_EXTENSIONS = (
|
||||
("mp4", "mp4"),
|
||||
("webm", "webm"),
|
||||
("gif", "gif"),
|
||||
)
|
||||
|
||||
ENCODE_RESOLUTIONS = (
|
||||
(2160, "2160"),
|
||||
(1440, "1440"),
|
||||
(1080, "1080"),
|
||||
(720, "720"),
|
||||
(480, "480"),
|
||||
(360, "360"),
|
||||
(240, "240"),
|
||||
(144, "144"),
|
||||
)
|
||||
|
||||
CODECS = (
|
||||
("h265", "h265"),
|
||||
("h264", "h264"),
|
||||
("vp9", "vp9"),
|
||||
)
|
||||
|
||||
ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS]
|
||||
ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS]
|
||||
|
||||
|
||||
def generate_uid():
|
||||
return get_random_string(length=16)
|
||||
|
||||
|
||||
def original_media_file_path(instance, filename):
|
||||
"""Helper function to place original media file"""
|
||||
file_name = f"{instance.uid.hex}.{helpers.get_file_name(filename)}"
|
||||
return settings.MEDIA_UPLOAD_DIR + f"user/{instance.user.username}/{file_name}"
|
||||
|
||||
|
||||
def encoding_media_file_path(instance, filename):
|
||||
"""Helper function to place encoded media file"""
|
||||
|
||||
file_name = f"{instance.media.uid.hex}.{helpers.get_file_name(filename)}"
|
||||
return settings.MEDIA_ENCODING_DIR + f"{instance.profile.id}/{instance.media.user.username}/{file_name}"
|
||||
|
||||
|
||||
def original_thumbnail_file_path(instance, filename):
|
||||
"""Helper function to place original media thumbnail file"""
|
||||
|
||||
return settings.THUMBNAIL_UPLOAD_DIR + f"user/{instance.user.username}/{filename}"
|
||||
|
||||
|
||||
def subtitles_file_path(instance, filename):
|
||||
"""Helper function to place subtitle file"""
|
||||
|
||||
return settings.SUBTITLES_UPLOAD_DIR + f"user/{instance.media.user.username}/{filename}"
|
||||
|
||||
|
||||
def category_thumb_path(instance, filename):
|
||||
"""Helper function to place category thumbnail file"""
|
||||
|
||||
file_name = f"{instance.uid}.{helpers.get_file_name(filename)}"
|
||||
return settings.MEDIA_UPLOAD_DIR + f"categories/{file_name}"
|
||||
|
||||
|
||||
def validate_rating(value):
|
||||
if -1 >= value or value > 5:
|
||||
raise ValidationError("score has to be between 0 and 5")
|
||||
86
files/models/video_data.py
Normal file
86
files/models/video_data.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .. import helpers
|
||||
|
||||
|
||||
class VideoChapterData(models.Model):
|
||||
data = models.JSONField(null=False, blank=False, help_text="Chapter data")
|
||||
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters')
|
||||
|
||||
class Meta:
|
||||
unique_together = ['media']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from .. import tasks
|
||||
|
||||
is_new = self.pk is None
|
||||
if is_new or (not is_new and self._check_data_changed()):
|
||||
super().save(*args, **kwargs)
|
||||
tasks.produce_video_chapters.delay(self.pk)
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _check_data_changed(self):
|
||||
if self.pk:
|
||||
old_instance = VideoChapterData.objects.get(pk=self.pk)
|
||||
return old_instance.data != self.data
|
||||
return False
|
||||
|
||||
@property
|
||||
def chapter_data(self):
|
||||
# ensure response is consistent
|
||||
data = []
|
||||
for item in self.data:
|
||||
if item.get("start") and item.get("title"):
|
||||
thumbnail = item.get("thumbnail")
|
||||
if thumbnail:
|
||||
thumbnail = helpers.url_from_path(thumbnail)
|
||||
else:
|
||||
thumbnail = "static/images/chapter_default.jpg"
|
||||
data.append(
|
||||
{
|
||||
"start": item.get("start"),
|
||||
"title": item.get("title"),
|
||||
"thumbnail": thumbnail,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class VideoTrimRequest(models.Model):
|
||||
"""Model to handle video trimming requests"""
|
||||
|
||||
VIDEO_TRIM_STATUS = (
|
||||
("initial", "Initial"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("fail", "Fail"),
|
||||
)
|
||||
|
||||
VIDEO_ACTION_CHOICES = (
|
||||
("replace", "Replace Original"),
|
||||
("save_new", "Save as New"),
|
||||
("create_segments", "Create Segments"),
|
||||
)
|
||||
|
||||
TRIM_STYLE_CHOICES = (
|
||||
("no_encoding", "No Encoding"),
|
||||
("precise", "Precise"),
|
||||
)
|
||||
|
||||
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests')
|
||||
status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial")
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES)
|
||||
media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding")
|
||||
timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming")
|
||||
|
||||
def __str__(self):
|
||||
return f"Trim request for {self.media.title} ({self.status})"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=VideoChapterData)
|
||||
def videochapterdata_delete(sender, instance, **kwargs):
|
||||
helpers.rm_dir(instance.media.video_chapters_folder)
|
||||
Reference in New Issue
Block a user