feat: bulk actions API

This commit is contained in:
Markos Gogoulos
2025-08-07 13:21:12 +03:00
committed by GitHub
parent de99d84c18
commit e790795bfd
69 changed files with 3879 additions and 2689 deletions

View File

@@ -68,14 +68,18 @@ class MediaMetadataForm(forms.ModelForm):
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
layout_fields = [
CustomField('title'),
CustomField('new_tags'),
CustomField('add_date'),
CustomField('description'),
CustomField('uploaded_poster'),
CustomField('enable_comments'),
)
]
if self.instance.media_type != "image":
layout_fields.append(CustomField('uploaded_poster'))
self.helper.layout = Layout(*layout_fields)
if self.instance.media_type == "video":
self.helper.layout.append(CustomField('thumbnail_time'))

View File

@@ -567,3 +567,42 @@ def handle_video_chapters(media, chapters):
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return media.chapter_data
def change_media_owner(media_id, new_user):
"""Change the owner of a media
Args:
media_id: ID of the media to change owner
new_user: New user object to set as owner
Returns:
Media object or None if media not found
"""
media = models.Media.objects.filter(id=media_id).first()
if not media:
return None
# Change the owner
media.user = new_user
media.save(update_fields=["user"])
# Update any related permissions
media_permissions = models.MediaPermission.objects.filter(media=media)
for permission in media_permissions:
permission.owner_user = new_user
permission.save(update_fields=["owner_user"])
return media
def copy_media(media_id):
"""Create a copy of a media
Args:
media_id: ID of the media to copy
Returns:
None
"""
pass

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.1.6 on 2025-07-08 19:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0010_alter_encodeprofile_resolution'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MediaPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('permission', models.CharField(choices=[('viewer', 'Viewer'), ('editor', 'Editor'), ('owner', 'Owner')], max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='permissions', to='files.media')),
('owner_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='granted_permissions', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'media')},
},
),
]

25
files/models/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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

View File

@@ -3,15 +3,12 @@ import json
import logging
import os
import random
import re
import tempfile
import uuid
import m3u8
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.exceptions import ValidationError
from django.core.files import File
from django.db import models
from django.db.models import Func, Value
@@ -19,108 +16,25 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import strip_tags
from imagekit.models import ProcessedImageField
from imagekit.processors import ResizeToFit
from mptt.models import MPTTModel, TreeForeignKey
from . import helpers
from .stop_words import STOP_WORDS
from .. import helpers
from ..stop_words import STOP_WORDS
from .encoding import EncodeProfile, Encoding
from .utils import (
ENCODE_RESOLUTIONS_KEYS,
MEDIA_ENCODING_STATUS,
MEDIA_STATES,
MEDIA_TYPES_SUPPORTED,
original_media_file_path,
original_thumbnail_file_path,
)
from .video_data import VideoTrimRequest
logger = logging.getLogger(__name__)
RE_TIMECODE = re.compile(r"(\d+:\d+:\d+.\d+)")
# 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}"
class Media(models.Model):
"""The most important model for MediaCMS"""
@@ -138,7 +52,6 @@ class Media(models.Model):
null=True,
help_text="Media can exist in one or no Channels",
)
description = models.TextField(blank=True)
dislikes = models.IntegerField(default=0)
@@ -566,7 +479,7 @@ class Media(models.Model):
To be used on the video player
"""
from . import tasks
from .. import tasks
tasks.produce_sprite_from_video.delay(self.friendly_token)
return True
@@ -582,7 +495,7 @@ class Media(models.Model):
profiles = EncodeProfile.objects.filter(active=True)
profiles = list(profiles)
from . import tasks
from .. import tasks
# attempt to break media file in chunks
if self.duration > settings.CHUNKIZE_VIDEO_DURATION and chunkize:
@@ -638,7 +551,7 @@ class Media(models.Model):
self.save(update_fields=["encoding_status", "listable", "preview_file_path"])
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk:
from . import tasks
from .. import tasks
tasks.create_hls.delay(self.friendly_token)
@@ -993,585 +906,26 @@ class Media(models.Model):
return data
class License(models.Model):
"""A Base license model to be used in Media"""
class MediaPermission(models.Model):
"""Model to store user permissions for media"""
title = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True)
def __str__(self):
return self.title
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,
PERMISSION_CHOICES = (
("viewer", "Viewer"),
("editor", "Editor"),
("owner", "Owner"),
)
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
owner_user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='granted_permissions')
user = models.ForeignKey('users.User', on_delete=models.CASCADE)
media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='permissions')
permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
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"""
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.listings_thumbnail:
return self.listings_thumbnail
if self.thumbnail:
return helpers.url_from_path(self.thumbnail.path)
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,
)
unique_together = ('user', 'media')
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
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})
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
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}"
def validate_rating(value):
if -1 >= value or value > 5:
raise ValidationError("score has to be between 0 and 5")
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}"
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"]
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()
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})"
return f"{self.user.username} - {self.media.title} ({self.permission})"
@receiver(post_save, sender=Media)
@@ -1585,7 +939,7 @@ def media_save(sender, instance, created, **kwargs):
return False
if created:
from .methods import notify_users
from ..methods import notify_users
instance.media_init()
notify_users(friendly_token=instance.friendly_token, action="media_added")
@@ -1616,11 +970,6 @@ def media_file_pre_delete(sender, instance, **kwargs):
tag.update_tag_media()
@receiver(post_delete, sender=VideoChapterData)
def videochapterdata_delete(sender, instance, **kwargs):
helpers.rm_dir(instance.media.video_chapters_folder)
@receiver(post_delete, sender=Media)
def media_file_delete(sender, instance, **kwargs):
"""
@@ -1661,166 +1010,3 @@ def media_m2m(sender, instance, **kwargs):
if instance.tags.all():
for tag in instance.tags.all():
tag.update_tag_media()
@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

97
files/models/playlist.py Normal file
View 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
View 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
View 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
View 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")

View 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)

View File

@@ -820,7 +820,7 @@ def update_listings_thumbnails():
# Categories
used_media = []
saved = 0
qs = Category.objects.filter().order_by("-media_count")
qs = Category.objects.filter()
for object in qs:
media = Media.objects.exclude(friendly_token__in=used_media).filter(category=object, state="public", is_reviewed=True).order_by("-views").first()
if media:
@@ -833,7 +833,7 @@ def update_listings_thumbnails():
# Tags
used_media = []
saved = 0
qs = Tag.objects.filter().order_by("-media_count")
qs = Tag.objects.filter()
for object in qs:
media = Media.objects.exclude(friendly_token__in=used_media).filter(tags=object, state="public", is_reviewed=True).order_by("-views").first()
if media:

View File

@@ -48,6 +48,8 @@ urlpatterns = [
re_path(r"^view", views.view_media, name="get_media"),
re_path(r"^upload", views.upload_media, name="upload_media"),
# API VIEWS
re_path(r"^api/v1/media/user/bulk_actions$", views.MediaBulkUserActions.as_view()),
re_path(r"^api/v1/media/user/bulk_actions/$", views.MediaBulkUserActions.as_view()),
re_path(r"^api/v1/media$", views.MediaList.as_view()),
re_path(r"^api/v1/media/$", views.MediaList.as_view()),
re_path(

File diff suppressed because it is too large Load Diff

43
files/views/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
# Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
from .media import MediaActions # noqa: F401
from .media import MediaBulkUserActions # noqa: F401
from .media import MediaDetail # noqa: F401
from .media import MediaList # noqa: F401
from .media import MediaSearch # noqa: F401
from .pages import about # noqa: F401
from .pages import add_subtitle # noqa: F401
from .pages import categories # noqa: F401
from .pages import contact # noqa: F401
from .pages import edit_chapters # noqa: F401
from .pages import edit_media # noqa: F401
from .pages import edit_subtitle # noqa: F401
from .pages import edit_video # noqa: F401
from .pages import embed_media # noqa: F401
from .pages import featured_media # noqa: F401
from .pages import history # noqa: F401
from .pages import index # noqa: F401
from .pages import latest_media # noqa: F401
from .pages import liked_media # noqa: F401
from .pages import manage_comments # noqa: F401
from .pages import manage_media # noqa: F401
from .pages import manage_users # noqa: F401
from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401
from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401
from .pages import tags # noqa: F401
from .pages import tos # noqa: F401
from .pages import trim_video # noqa: F401
from .pages import upload_media # noqa: F401
from .pages import video_chapters # noqa: F401
from .pages import view_media # noqa: F401
from .pages import view_playlist # noqa: F401
from .playlists import PlaylistDetail, PlaylistList # noqa: F401
from .tasks import TaskDetail, TasksList # noqa: F401
from .user import UserActions # noqa: F401

42
files/views/auth.py Normal file
View File

@@ -0,0 +1,42 @@
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.http import Http404, HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from identity_providers.models import LoginOption
def saml_metadata(request):
if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
raise Http404
xml_parts = ['<?xml version="1.0"?>']
saml_social_apps = SocialApp.objects.filter(provider='saml')
entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
xml_parts.append(f'<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" Name="{entity_id}">') # noqa
xml_parts.append(f' <md:EntityDescriptor entityID="{entity_id}">') # noqa
xml_parts.append(' <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">') # noqa
# Add multiple AssertionConsumerService elements with different indices
for index, app in enumerate(saml_social_apps, start=1):
xml_parts.append(
f' <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" ' # noqa
f'Location="{settings.FRONTEND_HOST}/accounts/saml/{app.client_id}/acs/" index="{index}"/>' # noqa
)
xml_parts.append(' </md:SPSSODescriptor>') # noqa
xml_parts.append(' </md:EntityDescriptor>') # noqa
xml_parts.append('</md:EntitiesDescriptor>') # noqa
metadata_xml = '\n'.join(xml_parts)
return HttpResponse(metadata_xml, content_type='application/xml')
def custom_login_view(request):
if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS):
return redirect(reverse('login_system'))
login_options = []
for option in LoginOption.objects.filter(active=True):
login_options.append({'url': option.url, 'title': option.title})
return render(request, 'account/custom_login_selector.html', {'login_options': login_options})

66
files/views/categories.py Normal file
View File

@@ -0,0 +1,66 @@
from django.conf import settings
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from ..methods import is_mediacms_editor
from ..models import Category, Tag
from ..serializers import CategorySerializer, TagSerializer
class CategoryList(APIView):
"""List categories"""
@swagger_auto_schema(
manual_parameters=[],
tags=['Categories'],
operation_summary='Lists Categories',
operation_description='Lists all categories',
responses={
200: openapi.Response('response description', CategorySerializer),
},
)
def get(self, request, format=None):
base_filters = {}
if not is_mediacms_editor(request.user):
base_filters = {"is_rbac_category": False}
base_queryset = Category.objects.prefetch_related("user")
categories = base_queryset.filter(**base_filters)
if not is_mediacms_editor(request.user):
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
rbac_categories = request.user.get_rbac_categories_as_member()
categories = categories.union(rbac_categories)
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
ret = serializer.data
return Response(ret)
class TagList(APIView):
"""List tags"""
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
],
tags=['Tags'],
operation_summary='Lists Tags',
operation_description='Paginated listing of all tags',
responses={
200: openapi.Response('response description', TagSerializer),
},
)
def get(self, request, format=None):
tags = Tag.objects.filter().order_by("-media_count")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(tags, request)
serializer = TagSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)

159
files/views/comments.py Normal file
View File

@@ -0,0 +1,159 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from cms.permissions import IsAuthorizedToAdd, IsAuthorizedToAddComment
from users.models import User
from ..methods import (
check_comment_for_mention,
is_mediacms_editor,
notify_user_on_comment,
)
from ..models import Comment, Media
from ..serializers import CommentSerializer
class CommentList(APIView):
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'),
],
tags=['Comments'],
operation_summary='Lists Comments',
operation_description='Paginated listing of all comments',
responses={
200: openapi.Response('response description', CommentSerializer(many=True)),
},
)
def get(self, request, format=None):
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
comments = Comment.objects.filter(media__state="public").order_by("-add_date")
comments = comments.prefetch_related("user")
comments = comments.prefetch_related("media")
params = self.request.query_params
if "author" in params:
author_param = params["author"].strip()
user_queryset = User.objects.all()
user = get_object_or_404(user_queryset, username=author_param)
comments = comments.filter(user=user)
page = paginator.paginate_queryset(comments, request)
serializer = CommentSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
class CommentDetail(APIView):
"""Comments related views
Listings of comments for a media (GET)
Create comment (POST)
Delete comment (DELETE)
"""
permission_classes = (IsAuthorizedToAddComment,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").get(friendly_token=friendly_token)
self.check_object_permissions(self.request, media)
if media.state == "private" and self.request.user != media.user:
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token):
# list comments for a media
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
comments = media.comments.filter().prefetch_related("user")
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(comments, request)
serializer = CommentSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, uid=None):
"""Delete a comment
Administrators, MediaCMS editors and managers,
media owner, and comment owners, can delete a comment
"""
if uid:
try:
comment = Comment.objects.get(uid=uid)
except BaseException:
return Response(
{"detail": "comment does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
if (comment.user == self.request.user) or comment.media.user == self.request.user or is_mediacms_editor(self.request.user):
comment.delete()
else:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token):
"""Create a comment"""
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not media.enable_comments:
return Response(
{"detail": "comments not allowed here"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user, media=media)
if request.user != media.user:
notify_user_on_comment(friendly_token=media.friendly_token)
# here forward the comment to check if a user was mentioned
if settings.ALLOW_MENTION_IN_COMMENTS:
check_comment_for_mention(friendly_token=media.friendly_token, comment_text=serializer.data['text'])
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

179
files/views/encoding.py Normal file
View File

@@ -0,0 +1,179 @@
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.views import APIView
from ..helpers import produce_ffmpeg_commands
from ..models import EncodeProfile, Encoding
from ..serializers import EncodeProfileSerializer
class EncodingDetail(APIView):
"""Experimental. This View is used by remote workers
Needs heavy testing and documentation.
"""
permission_classes = (permissions.IsAdminUser,)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(auto_schema=None)
def post(self, request, encoding_id):
ret = {}
force = request.data.get("force", False)
task_id = request.data.get("task_id", False)
action = request.data.get("action", "")
chunk = request.data.get("chunk", False)
chunk_file_path = request.data.get("chunk_file_path", "")
encoding_status = request.data.get("status", "")
progress = request.data.get("progress", "")
commands = request.data.get("commands", "")
logs = request.data.get("logs", "")
retries = request.data.get("retries", "")
worker = request.data.get("worker", "")
temp_file = request.data.get("temp_file", "")
total_run_time = request.data.get("total_run_time", "")
if action == "start":
try:
encoding = Encoding.objects.get(id=encoding_id)
media = encoding.media
profile = encoding.profile
except BaseException:
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
# TODO: break chunk True/False logic here
if (
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).count()
> 1 # noqa
and force is False # noqa
):
Encoding.objects.filter(id=encoding_id).delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
else:
Encoding.objects.filter(
media=media,
profile=profile,
chunk=chunk,
chunk_file_path=chunk_file_path,
).exclude(id=encoding.id).delete()
encoding.status = "running"
if task_id:
encoding.task_id = task_id
encoding.save()
if chunk:
original_media_path = chunk_file_path
original_media_md5sum = encoding.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + encoding.media_chunk_url
else:
original_media_path = media.media_file.path
original_media_md5sum = media.md5sum
original_media_url = settings.SSL_FRONTEND_HOST + media.original_media_url
ret["original_media_url"] = original_media_url
ret["original_media_path"] = original_media_path
ret["original_media_md5sum"] = original_media_md5sum
# generating the commands here, and will replace these with temporary
# files created on the remote server
tf = "TEMP_FILE_REPLACE"
tfpass = "TEMP_FPASS_FILE_REPLACE"
ffmpeg_commands = produce_ffmpeg_commands(
original_media_path,
media.media_info,
resolution=profile.resolution,
codec=profile.codec,
output_filename=tf,
pass_file=tfpass,
chunk=chunk,
)
if not ffmpeg_commands:
encoding.delete()
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
ret["duration"] = media.duration
ret["ffmpeg_commands"] = ffmpeg_commands
ret["profile_extension"] = profile.extension
return Response(ret, status=status.HTTP_201_CREATED)
elif action == "update_fields":
try:
encoding = Encoding.objects.get(id=encoding_id)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
to_update = ["size", "update_date"]
if encoding_status:
encoding.status = encoding_status
to_update.append("status")
if progress:
encoding.progress = progress
to_update.append("progress")
if logs:
encoding.logs = logs
to_update.append("logs")
if commands:
encoding.commands = commands
to_update.append("commands")
if task_id:
encoding.task_id = task_id
to_update.append("task_id")
if total_run_time:
encoding.total_run_time = total_run_time
to_update.append("total_run_time")
if worker:
encoding.worker = worker
to_update.append("worker")
if temp_file:
encoding.temp_file = temp_file
to_update.append("temp_file")
if retries:
encoding.retries = retries
to_update.append("retries")
try:
encoding.save(update_fields=to_update)
except BaseException:
return Response({"status": "fail"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "success"}, status=status.HTTP_201_CREATED)
@swagger_auto_schema(auto_schema=None)
def put(self, request, encoding_id, format=None):
encoding_file = request.data["file"]
encoding = Encoding.objects.filter(id=encoding_id).first()
if not encoding:
return Response(
{"detail": "encoding does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
encoding.media_file = encoding_file
encoding.save()
return Response({"detail": "ok"}, status=status.HTTP_201_CREATED)
class EncodeProfileList(APIView):
"""List encode profiles"""
@swagger_auto_schema(
manual_parameters=[],
tags=['Encoding Profiles'],
operation_summary='List Encoding Profiles',
operation_description='Lists all encoding profiles for videos',
responses={200: EncodeProfileSerializer(many=True)},
)
def get(self, request, format=None):
profiles = EncodeProfile.objects.all()
serializer = EncodeProfileSerializer(profiles, many=True, context={"request": request})
return Response(serializer.data)

763
files/views/media.py Normal file
View File

@@ -0,0 +1,763 @@
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.postgres.search import SearchQuery
from django.db.models import Q
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from actions.models import MediaAction
from cms.custom_pagination import FastPaginationWithoutCount
from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor
from users.models import User
from .. import helpers
from ..methods import (
change_media_owner,
copy_media,
get_user_or_session,
is_mediacms_editor,
show_recommended_media,
show_related_media,
update_user_ratings,
)
from ..models import EncodeProfile, Media, MediaPermission, Playlist, PlaylistMedia
from ..serializers import MediaSearchSerializer, MediaSerializer, SingleMediaSerializer
from ..stop_words import STOP_WORDS
from ..tasks import save_user_action
class MediaList(APIView):
"""Media listings views"""
permission_classes = (IsAuthorizedToAdd,)
parser_classes = (MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='page', type=openapi.TYPE_INTEGER, in_=openapi.IN_QUERY, description='Page number'),
openapi.Parameter(name='author', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='username'),
openapi.Parameter(name='show', type=openapi.TYPE_STRING, in_=openapi.IN_QUERY, description='show', enum=['recommended', 'featured', 'latest']),
],
tags=['Media'],
operation_summary='List Media',
operation_description='Lists all media',
responses={200: MediaSerializer(many=True)},
)
def _get_media_queryset(self, request, user=None):
base_filters = Q(listable=True)
if user:
base_filters &= Q(user=user)
base_queryset = Media.objects.prefetch_related("user")
if not request.user.is_authenticated:
return base_queryset.filter(base_filters).order_by("-add_date")
# Build OR conditions for authenticated users
conditions = base_filters # Start with listable media
# Add user permissions
permission_filter = {'user': request.user}
if user:
permission_filter['owner_user'] = user
if MediaPermission.objects.filter(**permission_filter).exists():
perm_conditions = Q(permissions__user=request.user)
if user:
perm_conditions &= Q(user=user)
conditions |= perm_conditions
# Add RBAC conditions
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_conditions = Q(category__in=rbac_categories)
if user:
rbac_conditions &= Q(user=user)
conditions |= rbac_conditions
return base_queryset.filter(conditions).distinct().order_by("-add_date")[:1000]
def get(self, request, format=None):
# Show media
# authenticated users can see:
# All listable media (public access)
# Non-listable media they have RBAC access to
# Non-listable media they have direct permissions for
params = self.request.query_params
show_param = params.get("show", "")
author_param = params.get("author", "").strip()
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
if show_param == "recommended":
pagination_class = FastPaginationWithoutCount
media = show_recommended_media(request, limit=50)
elif show_param == "featured":
media = Media.objects.filter(listable=True, featured=True).prefetch_related("user").order_by("-add_date")
elif show_param == "shared_by_me":
if not self.request.user.is_authenticated:
media = Media.objects.none()
else:
media = Media.objects.filter(permissions__owner_user=self.request.user).prefetch_related("user")
elif show_param == "shared_with_me":
if not self.request.user.is_authenticated:
media = Media.objects.none()
else:
base_queryset = Media.objects.prefetch_related("user")
user_media_filters = {'permissions__user': request.user}
media = base_queryset.filter(**user_media_filters)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
rbac_filters = {'category__in': rbac_categories}
rbac_media = base_queryset.filter(**rbac_filters)
media = media.union(rbac_media)
media = media.order_by("-add_date")[:1000] # limit to 1000 results
elif author_param:
user_queryset = User.objects.all()
user = get_object_or_404(user_queryset, username=author_param)
if self.request.user == user:
media = Media.objects.filter(user=user).prefetch_related("user").order_by("-add_date")
else:
media = self._get_media_queryset(request, user)
else:
media = self._get_media_queryset(request)
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=True, description="media_file"),
openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"),
openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"),
],
tags=['Media'],
operation_summary='Add new Media',
operation_description='Adds a new media, for authenticated users',
responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'},
)
def post(self, request, format=None):
# Add new media
serializer = MediaSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
media_file = request.data["media_file"]
serializer.save(user=request.user, media_file=media_file)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class MediaBulkUserActions(APIView):
"""Bulk actions on media items"""
permission_classes = (permissions.IsAuthenticated,)
parser_classes = (JSONParser,)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='media_ids', in_=openapi.IN_FORM, type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_STRING), required=True, description="List of media IDs"),
openapi.Parameter(
name='action',
in_=openapi.IN_FORM,
type=openapi.TYPE_STRING,
required=True,
description="Action to perform",
enum=[
"enable_comments",
"disable_comments",
"delete_media",
"enable_download",
"disable_download",
"add_to_playlist",
"remove_from_playlist",
"set_state",
"change_owner",
"copy_media",
],
),
openapi.Parameter(
name='playlist_ids',
in_=openapi.IN_FORM,
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
required=False,
description="List of playlist IDs (required for add_to_playlist and remove_from_playlist actions)",
),
openapi.Parameter(
name='state', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="State to set (required for set_state action)", enum=["private", "public", "unlisted"]
),
openapi.Parameter(name='owner', in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="New owner username (required for change_owner action)"),
],
tags=['Media'],
operation_summary='Perform bulk actions on media',
operation_description='Perform various bulk actions on multiple media items at once',
responses={
200: openapi.Response('Action performed successfully'),
400: 'Bad request',
401: 'Not authenticated',
},
)
def post(self, request, format=None):
# Check if user is authenticated
if not request.user.is_authenticated:
return Response({"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED)
# Get required parameters
media_ids = request.data.get('media_ids', [])
action = request.data.get('action')
# Validate required parameters
if not media_ids:
return Response({"detail": "media_ids is required"}, status=status.HTTP_400_BAD_REQUEST)
if not action:
return Response({"detail": "action is required"}, status=status.HTTP_400_BAD_REQUEST)
# Get media objects owned by the user
media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)
if not media:
return Response({"detail": "No matching media found"}, status=status.HTTP_400_BAD_REQUEST)
# Process based on action
if action == "enable_comments":
media.update(enable_comments=True)
return Response({"detail": f"Comments enabled for {media.count()} media items"})
elif action == "disable_comments":
media.update(enable_comments=False)
return Response({"detail": f"Comments disabled for {media.count()} media items"})
elif action == "delete_media":
count = media.count()
media.delete()
return Response({"detail": f"{count} media items deleted"})
elif action == "enable_download":
media.update(allow_download=True)
return Response({"detail": f"Download enabled for {media.count()} media items"})
elif action == "disable_download":
media.update(allow_download=False)
return Response({"detail": f"Download disabled for {media.count()} media items"})
elif action == "add_to_playlist":
playlist_ids = request.data.get('playlist_ids', [])
if not playlist_ids:
return Response({"detail": "playlist_ids is required for add_to_playlist action"}, status=status.HTTP_400_BAD_REQUEST)
playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids)
if not playlists:
return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST)
added_count = 0
for playlist in playlists:
for m in media:
media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count()
if media_in_playlist < settings.MAX_MEDIA_PER_PLAYLIST:
obj, created = PlaylistMedia.objects.get_or_create(
playlist=playlist,
media=m,
ordering=media_in_playlist + 1,
)
if created:
added_count += 1
return Response({"detail": f"Added {added_count} media items to {playlists.count()} playlists"})
elif action == "remove_from_playlist":
playlist_ids = request.data.get('playlist_ids', [])
if not playlist_ids:
return Response({"detail": "playlist_ids is required for remove_from_playlist action"}, status=status.HTTP_400_BAD_REQUEST)
playlists = Playlist.objects.filter(user=request.user, id__in=playlist_ids)
if not playlists:
return Response({"detail": "No matching playlists found"}, status=status.HTTP_400_BAD_REQUEST)
removed_count = 0
for playlist in playlists:
removed = PlaylistMedia.objects.filter(playlist=playlist, media__in=media).delete()[0]
removed_count += removed
return Response({"detail": f"Removed {removed_count} media items from {playlists.count()} playlists"})
elif action == "set_state":
state = request.data.get('state')
if not state:
return Response({"detail": "state is required for set_state action"}, status=status.HTTP_400_BAD_REQUEST)
valid_states = ["private", "public", "unlisted"]
if state not in valid_states:
return Response({"detail": f"state must be one of {valid_states}"}, status=status.HTTP_400_BAD_REQUEST)
# Check if user can set public state
if not is_mediacms_editor(request.user) and settings.PORTAL_WORKFLOW != "public":
if state == "public":
return Response({"detail": "You are not allowed to set media to public state"}, status=status.HTTP_400_BAD_REQUEST)
# Update media state
for m in media:
m.state = state
if m.state == "public" and m.encoding_status == "success" and m.is_reviewed is True:
m.listable = True
else:
m.listable = False
m.save(update_fields=["state", "listable"])
return Response({"detail": f"State updated to {state} for {media.count()} media items"})
elif action == "change_owner":
owner = request.data.get('owner')
if not owner:
return Response({"detail": "owner is required for change_owner action"}, status=status.HTTP_400_BAD_REQUEST)
new_user = User.objects.filter(username=owner).first()
if not new_user:
return Response({"detail": "User not found"}, status=status.HTTP_400_BAD_REQUEST)
changed_count = 0
for m in media:
result = change_media_owner(m.id, new_user)
if result:
changed_count += 1
return Response({"detail": f"Owner changed for {changed_count} media items"})
elif action == "copy_media":
for m in media:
copy_media(m.id)
return Response({"detail": f"{media.count()} media items copied"})
else:
return Response({"detail": f"Unknown action: {action}"}, status=status.HTTP_400_BAD_REQUEST)
class MediaDetail(APIView):
"""
Retrieve, update or delete a media instance.
"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor)
parser_classes = (MultiPartParser, FormParser, FileUploadParser)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token)
# this need be explicitly called, and will call
# has_object_permission() after has_permission has succeeded
self.check_object_permissions(self.request, media)
if media.state == "private":
if self.request.user.has_member_access_to_media(media) or is_mediacms_editor(self.request.user):
pass
else:
return Response(
{"detail": "media is private"},
status=status.HTTP_401_UNAUTHORIZED,
)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_401_UNAUTHORIZED)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
],
tags=['Media'],
operation_summary='Get information for Media',
operation_description='Get information for a media',
responses={200: SingleMediaSerializer(), 400: 'bad request'},
)
def get(self, request, friendly_token, format=None):
# Get media details
# password = request.GET.get("password")
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
serializer = SingleMediaSerializer(media, context={"request": request})
if media.state == "private":
related_media = []
else:
related_media = show_related_media(media, request=request, limit=100)
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
related_media = related_media_serializer.data
ret = serializer.data
# update rattings info with user specific ratings
# eg user has already rated for this media
# this only affects user rating and only if enabled
if settings.ALLOW_RATINGS and ret.get("ratings_info") and not request.user.is_anonymous:
ret["ratings_info"] = update_user_ratings(request.user, media, ret.get("ratings_info"))
ret["related_media"] = related_media
return Response(ret)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
openapi.Parameter(name='type', type=openapi.TYPE_STRING, in_=openapi.IN_FORM, description='action to perform', enum=['encode', 'review']),
openapi.Parameter(
name='encoding_profiles',
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_STRING),
in_=openapi.IN_FORM,
description='if action to perform is encode, need to specify list of ids of encoding profiles',
),
openapi.Parameter(name='result', type=openapi.TYPE_BOOLEAN, in_=openapi.IN_FORM, description='if action is review, this is the result (True for reviewed, False for not reviewed)'),
],
tags=['Media'],
operation_summary='Run action on Media',
operation_description='Actions for a media, for MediaCMS editors and managers',
responses={201: 'action created', 400: 'bad request'},
operation_id='media_manager_actions',
)
def post(self, request, friendly_token, format=None):
"""superuser actions
Available only to MediaCMS editors and managers
Action is a POST variable, review and encode are implemented
"""
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not is_mediacms_editor(request.user):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("type")
profiles_list = request.data.get("encoding_profiles")
result = request.data.get("result", True)
if action == "encode":
# Create encoding tasks for specific profiles
valid_profiles = []
if profiles_list:
if isinstance(profiles_list, list):
for p in profiles_list:
p = EncodeProfile.objects.filter(id=p).first()
if p:
valid_profiles.append(p)
elif isinstance(profiles_list, str):
try:
p = EncodeProfile.objects.filter(id=int(profiles_list)).first()
valid_profiles.append(p)
except ValueError:
return Response(
{"detail": "encoding_profiles must be int or list of ints of valid encode profiles"},
status=status.HTTP_400_BAD_REQUEST,
)
media.encode(profiles=valid_profiles)
return Response({"detail": "media will be encoded"}, status=status.HTTP_201_CREATED)
elif action == "review":
if result:
media.is_reviewed = True
elif result is False:
media.is_reviewed = False
media.save(update_fields=["is_reviewed"])
return Response({"detail": "media reviewed set"}, status=status.HTTP_201_CREATED)
return Response(
{"detail": "not valid action or no action specified"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name="description", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="description"),
openapi.Parameter(name="title", in_=openapi.IN_FORM, type=openapi.TYPE_STRING, required=False, description="title"),
openapi.Parameter(name="media_file", in_=openapi.IN_FORM, type=openapi.TYPE_FILE, required=False, description="media_file"),
],
tags=['Media'],
operation_summary='Update Media',
operation_description='Update a Media, for Media uploader',
responses={201: openapi.Response('response description', MediaSerializer), 401: 'bad request'},
)
def put(self, request, friendly_token, format=None):
# Update a media object
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'):
# media_file = request.data["media_file"]
# serializer.save(user=request.user, media_file=media_file)
# else:
# serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='friendly_token', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='unique identifier', required=True),
],
tags=['Media'],
operation_summary='Delete Media',
operation_description='Delete a Media, for MediaCMS editors and managers',
responses={
204: 'no content',
},
)
def delete(self, request, friendly_token, format=None):
# Delete a media object
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
media.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class MediaActions(APIView):
"""
Retrieve, update or delete a media action instance.
"""
permission_classes = (permissions.AllowAny,)
parser_classes = (JSONParser,)
def get_object(self, friendly_token):
try:
media = Media.objects.select_related("user").prefetch_related("encodings__profile").get(friendly_token=friendly_token)
if media.state == "private" and self.request.user != media.user:
return Response({"detail": "media is private"}, status=status.HTTP_400_BAD_REQUEST)
return media
except PermissionDenied:
return Response({"detail": "bad permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "media file does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token, format=None):
# show date and reason for each time media was reported
media = self.get_object(friendly_token)
if not (request.user == media.user or is_mediacms_editor(request.user)):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
if isinstance(media, Response):
return media
ret = {}
reported = MediaAction.objects.filter(media=media, action="report")
ret["reported"] = []
for rep in reported:
item = {"reported_date": rep.action_date, "reason": rep.extra_info}
ret["reported"].append(item)
return Response(ret, status=status.HTTP_200_OK)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token, format=None):
# perform like/dislike/report actions
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
action = request.data.get("type")
extra = request.data.get("extra_info")
if request.user.is_anonymous:
# there is a list of allowed actions for
# anonymous users, specified in settings
if action not in settings.ALLOW_ANONYMOUS_ACTIONS:
return Response(
{"detail": "action allowed on logged in users only"},
status=status.HTTP_400_BAD_REQUEST,
)
if action:
user_or_session = get_user_or_session(request)
save_user_action.delay(
user_or_session,
friendly_token=media.friendly_token,
action=action,
extra_info=extra,
)
return Response({"detail": "action received"}, status=status.HTTP_201_CREATED)
else:
return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
tags=['Media'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, format=None):
media = self.get_object(friendly_token)
if isinstance(media, Response):
return media
if not request.user.is_superuser:
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("type")
if action:
if action == "report": # delete reported actions
MediaAction.objects.filter(media=media, action="report").delete()
media.reported_times = 0
media.save(update_fields=["reported_times"])
return Response(
{"detail": "reset reported times counter"},
status=status.HTTP_201_CREATED,
)
else:
return Response({"detail": "no action specified"}, status=status.HTTP_400_BAD_REQUEST)
class MediaSearch(APIView):
"""
Retrieve results for search
Only GET is implemented here
"""
parser_classes = (JSONParser,)
@swagger_auto_schema(
manual_parameters=[],
tags=['Search'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, format=None):
params = self.request.query_params
query = params.get("q", "").strip().lower()
category = params.get("c", "").strip()
tag = params.get("t", "").strip()
ordering = params.get("ordering", "").strip()
sort_by = params.get("sort_by", "").strip()
media_type = params.get("media_type", "").strip()
author = params.get("author", "").strip()
upload_date = params.get('upload_date', '').strip()
sort_by_options = ["title", "add_date", "edit_date", "views", "likes"]
if sort_by not in sort_by_options:
sort_by = "add_date"
if ordering == "asc":
ordering = ""
else:
ordering = "-"
if media_type not in ["video", "image", "audio", "pdf"]:
media_type = None
if not (query or category or tag):
ret = {}
return Response(ret, status=status.HTTP_200_OK)
if request.user.is_authenticated:
basic_query = Q(listable=True) | Q(permissions__user=request.user)
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_member()
basic_query |= Q(category__in=rbac_categories)
else:
basic_query = Q(listable=True)
media = Media.objects.filter(basic_query).distinct()
if query:
# move this processing to a prepare_query function
query = helpers.clean_query(query)
q_parts = [q_part.rstrip("y") for q_part in query.split() if q_part not in STOP_WORDS]
if q_parts:
query = SearchQuery(q_parts[0] + ":*", search_type="raw")
for part in q_parts[1:]:
query &= SearchQuery(part + ":*", search_type="raw")
else:
query = None
if query:
media = media.filter(search=query)
if tag:
media = media.filter(tags__title=tag)
if category:
media = media.filter(category__title__contains=category)
if media_type:
media = media.filter(media_type=media_type)
if author:
media = media.filter(user__username=author)
if upload_date:
gte = None
if upload_date == 'today':
gte = datetime.now().date()
if upload_date == 'this_week':
gte = datetime.now() - timedelta(days=7)
if upload_date == 'this_month':
year = datetime.now().date().year
month = datetime.now().date().month
gte = datetime(year, month, 1)
if upload_date == 'this_year':
year = datetime.now().date().year
gte = datetime(year, 1, 1)
if gte:
media = media.filter(add_date__gte=gte)
media = media.order_by(f"{ordering}{sort_by}")
if self.request.query_params.get("show", "").strip() == "titles":
media = media.values("title")[:40]
return Response(media, status=status.HTTP_200_OK)
else:
media = media.prefetch_related("user")[:1000] # limit to 1000 results
if category or tag:
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
else:
# pagination_class = FastPaginationWithoutCount
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSearchSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)

593
files/views/pages.py Normal file
View File

@@ -0,0 +1,593 @@
import json
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.mail import EmailMessage
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from cms.permissions import user_allowed_to_upload
from cms.version import VERSION
from users.models import User
from .. import helpers
from ..forms import (
ContactForm,
EditSubtitleForm,
MediaMetadataForm,
MediaPublishForm,
SubtitleForm,
)
from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_only
from ..methods import (
create_video_trim_request,
get_user_or_session,
handle_video_chapters,
is_mediacms_editor,
)
from ..models import Category, Media, Playlist, Subtitle, Tag, VideoTrimRequest
from ..tasks import save_user_action, video_trim_task
def about(request):
"""About view"""
context = {"VERSION": VERSION}
return render(request, "cms/about.html", context)
def setlanguage(request):
"""Set Language view"""
context = {}
return render(request, "cms/set_language.html", context)
@login_required
def add_subtitle(request):
"""Add subtitle view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
form = SubtitleForm(media, request.POST, request.FILES)
if form.is_valid():
subtitle = form.save()
new_subtitle = Subtitle.objects.filter(id=subtitle.id).first()
try:
new_subtitle.convert_to_srt()
messages.add_message(request, messages.INFO, "Subtitle was added!")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
except: # noqa: E722
new_subtitle.delete()
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
form.add_error("subtitle_file", error_msg)
else:
form = SubtitleForm(media_item=media)
subtitles = media.subtitles.all()
context = {"media": media, "form": form, "subtitles": subtitles}
return render(request, "cms/add_subtitle.html", context)
@login_required
def edit_subtitle(request):
subtitle_id = request.GET.get("id", "").strip()
action = request.GET.get("action", "").strip()
if not subtitle_id:
return HttpResponseRedirect("/")
subtitle = Subtitle.objects.filter(id=subtitle_id).first()
if not subtitle:
return HttpResponseRedirect("/")
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
context = {"subtitle": subtitle, "action": action}
if action == "download":
response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt")
filename = subtitle.subtitle_file.name.split("/")[-1]
if not filename.endswith(".vtt"):
filename = f"{filename}.vtt"
response["Content-Disposition"] = f"attachment; filename={filename}" # noqa
return response
if request.method == "GET":
form = EditSubtitleForm(subtitle)
context["form"] = form
elif request.method == "POST":
confirm = request.GET.get("confirm", "").strip()
if confirm == "true":
messages.add_message(request, messages.INFO, "Subtitle was deleted")
redirect_url = subtitle.media.get_absolute_url()
subtitle.delete()
return HttpResponseRedirect(redirect_url)
form = EditSubtitleForm(subtitle, request.POST)
subtitle_text = form.data["subtitle"]
with open(subtitle.subtitle_file.path, "w") as ff:
ff.write(subtitle_text)
messages.add_message(request, messages.INFO, "Subtitle was edited")
return HttpResponseRedirect(subtitle.media.get_absolute_url())
return render(request, "cms/edit_subtitle.html", context)
def categories(request):
"""List categories view"""
context = {}
return render(request, "cms/categories.html", context)
def contact(request):
"""Contact view"""
context = {}
if request.method == "GET":
form = ContactForm(request.user)
context["form"] = form
else:
form = ContactForm(request.user, request.POST)
if form.is_valid():
if request.user.is_authenticated:
from_email = request.user.email
name = request.user.name
else:
from_email = request.POST.get("from_email")
name = request.POST.get("name")
message = request.POST.get("message")
title = f"[{settings.PORTAL_NAME}] - Contact form message received"
msg = """
You have received a message through the contact form\n
Sender name: %s
Sender email: %s\n
\n %s
""" % (
name,
from_email,
message,
)
email = EmailMessage(
title,
msg,
settings.DEFAULT_FROM_EMAIL,
settings.ADMIN_EMAIL_LIST,
reply_to=[from_email],
)
email.send(fail_silently=True)
success_msg = "Message was sent! Thanks for contacting"
context["success_msg"] = success_msg
return render(request, "cms/contact.html", context)
def history(request):
"""Show personal history view"""
context = {}
return render(request, "cms/history.html", context)
@csrf_exempt
@login_required
def video_chapters(request, friendly_token):
# this is not ready...
return False
if not request.method == "POST":
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
try:
data = json.loads(request.body)["chapters"]
chapters = []
for _, chapter_data in enumerate(data):
start_time = chapter_data.get('start')
title = chapter_data.get('title')
if start_time and title:
chapters.append(
{
'start': start_time,
'title': title,
}
)
except Exception as e: # noqa
return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400)
ret = handle_video_chapters(media, chapters)
return JsonResponse(ret, safe=False)
@login_required
def edit_media(request):
"""Edit a media view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
media = form.save()
for tag in media.tags.all():
media.tags.remove(tag)
if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_only(tag)
tag = tag[:99]
if tag:
try:
tag = Tag.objects.get(title=tag)
except Tag.DoesNotExist:
tag = Tag.objects.create(title=tag, user=request.user)
if tag not in media.tags.all():
media.tags.add(tag)
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaMetadataForm(request.user, instance=media)
return render(
request,
"cms/edit_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required
def publish_media(request):
"""Publish media"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
media = form.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaPublishForm(request.user, instance=media)
return render(
request,
"cms/publish_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required
def edit_chapters(request):
"""Edit chapters"""
# not implemented yet
return False
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
return render(
request,
"cms/edit_chapters.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token},
)
@csrf_exempt
@login_required
def trim_video(request, friendly_token):
if not settings.ALLOW_VIDEO_TRIMMER:
return JsonResponse({"success": False, "error": "Video trimming is not allowed"}, status=400)
if not request.method == "POST":
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists()
if existing_requests:
return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400)
try:
data = json.loads(request.body)
video_trim_request = create_video_trim_request(media, data)
video_trim_task.delay(video_trim_request.id)
ret = {"success": True, "request_id": video_trim_request.id}
return JsonResponse(ret, safe=False, status=200)
except Exception as e: # noqa
ret = {"success": False, "error": "Incorrect request data"}
return JsonResponse(ret, safe=False, status=400)
@login_required
def edit_video(request):
"""Edit video"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not media.media_type == "video":
messages.add_message(request, messages.INFO, "Media is not video")
return HttpResponseRedirect(media.get_absolute_url())
if not settings.ALLOW_VIDEO_TRIMMER:
messages.add_message(request, messages.INFO, "Video Trimmer is not enabled")
return HttpResponseRedirect(media.get_absolute_url())
# Check if there's a running trim request
running_trim_request = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists()
if running_trim_request:
messages.add_message(request, messages.INFO, "Video trim request is already running")
return HttpResponseRedirect(media.get_absolute_url())
media_file_path = media.trim_video_url
if not media_file_path:
messages.add_message(request, messages.INFO, "Media processing has not finished yet")
return HttpResponseRedirect(media.get_absolute_url())
if media.encoding_status in ["pending", "running"]:
video_msg = "Media encoding hasn't finished yet. Attempting to show the original video file"
messages.add_message(request, messages.INFO, video_msg)
return render(
request,
"cms/edit_video.html",
{"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path},
)
def embed_media(request):
"""Embed media view"""
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.values("title").filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
context = {}
context["media"] = friendly_token
return render(request, "cms/embed.html", context)
def featured_media(request):
"""List featured media view"""
context = {}
return render(request, "cms/featured-media.html", context)
def index(request):
"""Index view"""
context = {}
return render(request, "cms/index.html", context)
def latest_media(request):
"""List latest media view"""
context = {}
return render(request, "cms/latest-media.html", context)
def liked_media(request):
"""List user's liked media view"""
context = {}
return render(request, "cms/liked_media.html", context)
@login_required
def manage_users(request):
"""List users management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/manage_users.html", context)
@login_required
def manage_media(request):
"""List media management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
categories = Category.objects.all().order_by('title').values_list('title', flat=True)
context = {'categories': list(categories)}
return render(request, "cms/manage_media.html", context)
@login_required
def manage_comments(request):
"""List comments management view"""
if not is_mediacms_editor(request.user):
return HttpResponseRedirect("/")
context = {}
return render(request, "cms/manage_comments.html", context)
def members(request):
"""List members view"""
context = {}
return render(request, "cms/members.html", context)
def recommended_media(request):
"""List recommended media view"""
context = {}
return render(request, "cms/recommended-media.html", context)
def search(request):
"""Search view"""
context = {}
RSS_URL = f"/rss{request.environ.get('REQUEST_URI')}"
context["RSS_URL"] = RSS_URL
return render(request, "cms/search.html", context)
def sitemap(request):
"""Sitemap"""
context = {}
context["media"] = list(Media.objects.filter(listable=True).order_by("-add_date"))
context["playlists"] = list(Playlist.objects.filter().order_by("-add_date"))
context["users"] = list(User.objects.filter())
return render(request, "sitemap.xml", context, content_type="application/xml")
def tags(request):
"""List tags view"""
context = {}
return render(request, "cms/tags.html", context)
def tos(request):
"""Terms of service view"""
context = {}
return render(request, "cms/tos.html", context)
@login_required
def upload_media(request):
"""Upload media view"""
from allauth.account.forms import LoginForm
form = LoginForm()
context = {}
context["form"] = form
context["can_add"] = user_allowed_to_upload(request)
can_upload_exp = settings.CANNOT_ADD_MEDIA_MESSAGE
context["can_upload_exp"] = can_upload_exp
return render(request, "cms/add-media.html", context)
def view_media(request):
"""View media view"""
friendly_token = request.GET.get("m", "").strip()
context = {}
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
context["media"] = None
return render(request, "cms/media.html", context)
user_or_session = get_user_or_session(request)
save_user_action.delay(user_or_session, friendly_token=friendly_token, action="watch")
context = {}
context["media"] = friendly_token
context["media_object"] = media
context["CAN_DELETE_MEDIA"] = False
context["CAN_EDIT_MEDIA"] = False
context["CAN_DELETE_COMMENTS"] = False
if request.user.is_authenticated:
if request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user):
context["CAN_DELETE_MEDIA"] = True
context["CAN_EDIT_MEDIA"] = True
context["CAN_DELETE_COMMENTS"] = True
# in case media is video and is processing (eg the case a video was just uploaded)
# attempt to show it (rather than showing a blank video player)
if media.media_type == 'video':
video_msg = None
if media.encoding_status == "pending":
video_msg = "Media encoding hasn't started yet. Attempting to show the original video file"
if media.encoding_status == "running":
video_msg = "Media encoding is under processing. Attempting to show the original video file"
if video_msg:
messages.add_message(request, messages.INFO, video_msg)
return render(request, "cms/media.html", context)
def view_playlist(request, friendly_token):
"""View playlist view"""
try:
playlist = Playlist.objects.get(friendly_token=friendly_token)
except BaseException:
playlist = None
context = {}
context["playlist"] = playlist
return render(request, "cms/playlist.html", context)

195
files/views/playlists.py Normal file
View File

@@ -0,0 +1,195 @@
from django.conf import settings
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied
from rest_framework.parsers import (
FileUploadParser,
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from cms.permissions import IsAuthorizedToAdd, IsUserOrEditor
from ..models import Media, Playlist, PlaylistMedia
from ..serializers import MediaSerializer, PlaylistDetailSerializer, PlaylistSerializer
class PlaylistList(APIView):
"""Playlists listings and creation views"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsAuthorizedToAdd)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
responses={
200: openapi.Response('response description', PlaylistSerializer(many=True)),
},
)
def get(self, request, format=None):
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
playlists = Playlist.objects.filter().prefetch_related("user")
if "author" in self.request.query_params:
author = self.request.query_params["author"].strip()
playlists = playlists.filter(user__username=author)
page = paginator.paginate_queryset(playlists, request)
serializer = PlaylistSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, format=None):
serializer = PlaylistSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class PlaylistDetail(APIView):
"""Playlist related views"""
permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserOrEditor)
parser_classes = (JSONParser, MultiPartParser, FormParser, FileUploadParser)
def get_playlist(self, friendly_token):
try:
playlist = Playlist.objects.get(friendly_token=friendly_token)
self.check_object_permissions(self.request, playlist)
return playlist
except PermissionDenied:
return Response({"detail": "not enough permissions"}, status=status.HTTP_400_BAD_REQUEST)
except BaseException:
return Response(
{"detail": "Playlist does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def get(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
serializer = PlaylistDetailSerializer(playlist, context={"request": request})
playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user")
playlist_media = [c.media for c in playlist_media]
playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request})
ret = serializer.data
ret["playlist_media"] = playlist_media_serializer.data
return Response(ret)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def post(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
serializer = PlaylistDetailSerializer(playlist, data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def put(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
action = request.data.get("type")
media_friendly_token = request.data.get("media_friendly_token")
ordering = 0
if request.data.get("ordering"):
try:
ordering = int(request.data.get("ordering"))
except ValueError:
pass
if action in ["add", "remove", "ordering"]:
media = Media.objects.filter(friendly_token=media_friendly_token).first()
if media:
if action == "add":
media_in_playlist = PlaylistMedia.objects.filter(playlist=playlist).count()
if media_in_playlist >= settings.MAX_MEDIA_PER_PLAYLIST:
return Response(
{"detail": "max number of media for a Playlist reached"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
obj, created = PlaylistMedia.objects.get_or_create(
playlist=playlist,
media=media,
ordering=media_in_playlist + 1,
)
obj.save()
return Response(
{"detail": "media added to Playlist"},
status=status.HTTP_201_CREATED,
)
elif action == "remove":
PlaylistMedia.objects.filter(playlist=playlist, media=media).delete()
return Response(
{"detail": "media removed from Playlist"},
status=status.HTTP_201_CREATED,
)
elif action == "ordering":
if ordering:
playlist.set_ordering(media, ordering)
return Response(
{"detail": "new ordering set"},
status=status.HTTP_201_CREATED,
)
else:
return Response({"detail": "media is not valid"}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{"detail": "invalid or not specified action"},
status=status.HTTP_400_BAD_REQUEST,
)
@swagger_auto_schema(
manual_parameters=[],
tags=['Playlists'],
operation_summary='to_be_written',
operation_description='to_be_written',
)
def delete(self, request, friendly_token, format=None):
playlist = self.get_playlist(friendly_token)
if isinstance(playlist, Response):
return playlist
playlist.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

30
files/views/tasks.py Normal file
View File

@@ -0,0 +1,30 @@
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from ..methods import list_tasks
class TasksList(APIView):
"""List tasks"""
swagger_schema = None
permission_classes = (permissions.IsAdminUser,)
def get(self, request, format=None):
ret = list_tasks()
return Response(ret)
class TaskDetail(APIView):
"""Cancel a task"""
swagger_schema = None
permission_classes = (permissions.IsAdminUser,)
def delete(self, request, uid, format=None):
# This is not imported!
# revoke(uid, terminate=True)
return Response(status=status.HTTP_204_NO_CONTENT)

45
files/views/user.py Normal file
View File

@@ -0,0 +1,45 @@
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.parsers import JSONParser
from rest_framework.settings import api_settings
from rest_framework.views import APIView
from actions.models import USER_MEDIA_ACTIONS
from ..models import Media
from ..serializers import MediaSerializer
VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
class UserActions(APIView):
parser_classes = (JSONParser,)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name='action', type=openapi.TYPE_STRING, in_=openapi.IN_PATH, description='action', required=True, enum=VALID_USER_ACTIONS),
],
tags=['Users'],
operation_summary='List user actions',
operation_description='Lists user actions',
)
def get(self, request, action):
media = []
if action in VALID_USER_ACTIONS:
if request.user.is_authenticated:
media = Media.objects.select_related("user").filter(mediaactions__user=request.user, mediaactions__action=action).order_by("-mediaactions__action_date")
elif request.session.session_key:
media = (
Media.objects.select_related("user")
.filter(
mediaactions__session_key=request.session.session_key,
mediaactions__action=action,
)
.order_by("-mediaactions__action_date")
)
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
paginator = pagination_class()
page = paginator.paginate_queryset(media, request)
serializer = MediaSerializer(page, many=True, context={"request": request})
return paginator.get_paginated_response(serializer.data)