feat: Video Trimmer and more

This commit is contained in:
Markos Gogoulos
2025-06-11 14:48:30 +03:00
committed by GitHub
parent d34fc328bf
commit b28c2d8271
124 changed files with 15696 additions and 586 deletions

View File

@@ -15,6 +15,7 @@ from .models import (
Media,
Subtitle,
Tag,
VideoTrimRequest,
)
@@ -199,6 +200,10 @@ class SubtitleAdmin(admin.ModelAdmin):
pass
class VideoTrimRequestAdmin(admin.ModelAdmin):
pass
class EncodingAdmin(admin.ModelAdmin):
list_display = ["get_title", "chunk", "profile", "progress", "status", "has_file"]
list_filter = ["chunk", "profile", "status"]
@@ -222,5 +227,6 @@ admin.site.register(Category, CategoryAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(Subtitle, SubtitleAdmin)
admin.site.register(Language, LanguageAdmin)
admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin)
Media._meta.app_config.verbose_name = "Media"

View File

@@ -35,6 +35,9 @@ def stuff(request):
ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE)
ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE)
ret["USE_SAML"] = settings.USE_SAML
ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL

View File

@@ -1,49 +1,128 @@
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field, Layout, Submit
from django import forms
from django.conf import settings
from .methods import get_next_state, is_mediacms_editor
from .models import Category, Media, Subtitle
from .models import MEDIA_STATES, Category, Media, Subtitle
class CustomField(Field):
template = 'cms/crispy_custom_field.html'
class MultipleSelect(forms.CheckboxSelectMultiple):
input_type = "checkbox"
class MediaForm(forms.ModelForm):
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of new tags.", required=False)
class MediaMetadataForm(forms.ModelForm):
new_tags = forms.CharField(label="Tags", help_text="a comma separated list of tags.", required=False)
class Meta:
model = Media
fields = (
"title",
"category",
"new_tags",
"add_date",
"uploaded_poster",
"description",
"state",
"enable_comments",
"featured",
"thumbnail_time",
"reported_times",
"is_reviewed",
"allow_download",
)
widgets = {
"tags": MultipleSelect(),
"new_tags": MultipleSelect(),
"description": forms.Textarea(attrs={'rows': 4}),
"add_date": forms.DateInput(attrs={'type': 'date'}),
"thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}),
}
labels = {
"uploaded_poster": "Poster Image",
"thumbnail_time": "Thumbnail Time (seconds)",
}
help_texts = {
"title": "",
"thumbnail_time": "Select the time in seconds for the video thumbnail",
"uploaded_poster": "Maximum file size: 5MB",
}
def __init__(self, user, *args, **kwargs):
self.user = user
super(MediaForm, self).__init__(*args, **kwargs)
super(MediaMetadataForm, self).__init__(*args, **kwargs)
if self.instance.media_type != "video":
self.fields.pop("thumbnail_time")
if self.instance.media_type == "image":
self.fields.pop("uploaded_poster")
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('title'),
CustomField('new_tags'),
CustomField('add_date'),
CustomField('description'),
CustomField('uploaded_poster'),
CustomField('enable_comments'),
)
if self.instance.media_type == "video":
self.helper.layout.append(CustomField('thumbnail_time'))
self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction')))
def clean_uploaded_poster(self):
image = self.cleaned_data.get("uploaded_poster", False)
if image:
if image.size > 5 * 1024 * 1024:
raise forms.ValidationError("Image file too large ( > 5mb )")
return image
def save(self, *args, **kwargs):
data = self.cleaned_data # noqa
media = super(MediaMetadataForm, self).save(*args, **kwargs)
return media
class MediaPublishForm(forms.ModelForm):
confirm_state = forms.BooleanField(required=False, initial=False, label="Acknowledge sharing status", help_text="")
class Meta:
model = Media
fields = (
"category",
"state",
"featured",
"reported_times",
"is_reviewed",
"allow_download",
)
widgets = {
"category": MultipleSelect(),
}
def __init__(self, user, *args, **kwargs):
self.user = user
super(MediaPublishForm, self).__init__(*args, **kwargs)
if not is_mediacms_editor(user):
self.fields.pop("featured")
self.fields.pop("reported_times")
self.fields.pop("is_reviewed")
# if settings.PORTAL_WORKFLOW == 'private':
# self.fields.pop("state")
for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True
self.fields[field].widget.attrs['class'] = 'read-only-field'
self.fields[field].widget.attrs['title'] = "This field can only be modified by MediaCMS admins or editors"
if settings.PORTAL_WORKFLOW not in ["public"]:
valid_states = ["unlisted", "private"]
if self.instance.state and self.instance.state not in valid_states:
valid_states.append(self.instance.state)
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user):
@@ -61,14 +140,52 @@ class MediaForm(forms.ModelForm):
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()])
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('category'),
CustomField('state'),
CustomField('featured'),
CustomField('reported_times'),
CustomField('is_reviewed'),
CustomField('allow_download'),
)
def clean_uploaded_poster(self):
image = self.cleaned_data.get("uploaded_poster", False)
if image:
if image.size > 5 * 1024 * 1024:
raise forms.ValidationError("Image file too large ( > 5mb )")
return image
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
def clean(self):
cleaned_data = super().clean()
state = cleaned_data.get("state")
categories = cleaned_data.get("category")
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories and state in ['private', 'unlisted']:
# Make the confirm_state field visible and add it to the layout
self.fields['confirm_state'].widget = forms.CheckboxInput()
# add it after the state field
state_index = None
for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i
break
if state_index:
layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'):
error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}"
self.add_error('confirm_state', error_message)
return cleaned_data
def save(self, *args, **kwargs):
data = self.cleaned_data
@@ -76,7 +193,8 @@ class MediaForm(forms.ModelForm):
if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
media = super(MediaForm, self).save(*args, **kwargs)
media = super(MediaPublishForm, self).save(*args, **kwargs)
return media

View File

@@ -3,6 +3,7 @@
import hashlib
import json
import logging
import os
import random
import shutil
@@ -15,6 +16,9 @@ from django.conf import settings
CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
logger = logging.getLogger(__name__)
CRF_ENCODING_NUM_SECONDS = 2 # 0 * 60 # videos with greater duration will get
# CRF encoding and not two-pass
# Encoding individual chunks may yield quality variations if you use a
@@ -787,6 +791,179 @@ def clean_query(query):
return query.lower()
def timestamp_to_seconds(timestamp):
"""Convert a timestamp in format HH:MM:SS.mmm to seconds
Args:
timestamp (str): Timestamp in format HH:MM:SS.mmm
Returns:
float: Timestamp in seconds
"""
h, m, s = timestamp.split(':')
s, ms = s.split('.')
return int(h) * 3600 + int(m) * 60 + int(s) + float('0.' + ms)
def seconds_to_timestamp(seconds):
"""Convert seconds to timestamp in format HH:MM:SS.mmm
Args:
seconds (float): Time in seconds
Returns:
str: Timestamp in format HH:MM:SS.mmm
"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
seconds_remainder = seconds % 60
seconds_int = int(seconds_remainder)
milliseconds = int((seconds_remainder - seconds_int) * 1000)
return f"{hours:02d}:{minutes:02d}:{seconds_int:02d}.{milliseconds:03d}" # noqa
def get_trim_timestamps(media_file_path, timestamps_list, run_ffprobe=False):
"""Process a list of timestamps to align start times with I-frames for better video trimming
Args:
media_file_path (str): Path to the media file
timestamps_list (list): List of dictionaries with startTime and endTime
Returns:
list: Processed timestamps with adjusted startTime values
"""
if not isinstance(timestamps_list, list):
return []
timestamps_results = []
timestamps_to_process = []
for item in timestamps_list:
if isinstance(item, dict) and 'startTime' in item and 'endTime' in item:
timestamps_to_process.append(item)
if not timestamps_to_process:
return []
# just a single timestamp with no startTime, no need to process
if len(timestamps_to_process) == 1 and timestamps_to_process[0]['startTime'] == "00:00:00.000":
return timestamps_list
# Process each timestamp
for item in timestamps_to_process:
startTime = item['startTime']
endTime = item['endTime']
# with ffmpeg -ss -i that is getting run, there is no need to call ffprobe to find the I-frame,
# as ffmpeg will do that. Keeping this for now in case it is needed
i_frames = []
if run_ffprobe:
SEC_TO_SUBTRACT = 10
start_seconds = timestamp_to_seconds(startTime)
search_start = max(0, start_seconds - SEC_TO_SUBTRACT)
# Create ffprobe command to find nearest I-frame
cmd = [
settings.FFPROBE_COMMAND,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"frame=pts_time,pict_type",
"-of",
"csv=p=0",
"-read_intervals",
f"{search_start}%{startTime}",
media_file_path,
]
cmd = [str(s) for s in cmd]
logger.info(f"trim cmd: {cmd}")
stdout = run_command(cmd).get("out")
if stdout:
for line in stdout.strip().split('\n'):
if line and line.endswith(',I'):
i_frames.append(line.replace(',I', ''))
if i_frames:
adjusted_startTime = seconds_to_timestamp(float(i_frames[-1]))
if not i_frames:
adjusted_startTime = startTime
timestamps_results.append({'startTime': adjusted_startTime, 'endTime': endTime})
return timestamps_results
def trim_video_method(media_file_path, timestamps_list):
"""Trim a video file based on a list of timestamps
Args:
media_file_path (str): Path to the media file
timestamps_list (list): List of dictionaries with startTime and endTime
Returns:
bool: True if successful, False otherwise
"""
if not isinstance(timestamps_list, list) or not timestamps_list:
return False
if not os.path.exists(media_file_path):
return False
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir:
output_file = os.path.join(temp_dir, "output.mp4")
segment_files = []
for i, item in enumerate(timestamps_list):
start_time = timestamp_to_seconds(item['startTime'])
end_time = timestamp_to_seconds(item['endTime'])
duration = end_time - start_time
# For single timestamp, we can use the output file directly
# For multiple timestamps, we need to create segment files
segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4")
cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file]
result = run_command(cmd) # noqa
if os.path.exists(segment_file) and os.path.getsize(segment_file) > 0:
if len(timestamps_list) > 1:
segment_files.append(segment_file)
else:
return False
if len(timestamps_list) > 1:
if not segment_files:
return False
concat_list_path = os.path.join(temp_dir, "concat_list.txt")
with open(concat_list_path, "w") as f:
for segment in segment_files:
f.write(f"file '{segment}'\n")
concat_cmd = [settings.FFMPEG_COMMAND, "-y", "-f", "concat", "-safe", "0", "-i", concat_list_path, "-c", "copy", output_file]
concat_result = run_command(concat_cmd) # noqa
if not os.path.exists(output_file) or os.path.getsize(output_file) == 0:
return False
# Replace the original file with the trimmed version
try:
rm_file(media_file_path)
shutil.copy2(output_file, media_file_path)
return True
except Exception as e:
logger.info(f"Failed to replace original file: {str(e)}")
return False
def get_alphanumeric_only(string):
"""Returns a query that contains only alphanumeric characters
This include characters other than the English alphabet too

View File

@@ -5,16 +5,19 @@ import itertools
import logging
import random
import re
import subprocess
from datetime import datetime
from django.conf import settings
from django.core.cache import cache
from django.core.files import File
from django.core.mail import EmailMessage
from django.db.models import Q
from django.utils import timezone
from cms import celery_app
from . import models
from . import helpers, models
from .helpers import mask_ip
logger = logging.getLogger(__name__)
@@ -262,7 +265,7 @@ def show_related_media_content(media, request, limit):
"user_featured",
"-user_featured",
]
# TODO: MAke this mess more readable, and add TAGS support - aka related
# TODO: Make this mess more readable, and add TAGS support - aka related
# tags rather than random media
if len(m) < limit:
category = media.category.first()
@@ -398,6 +401,111 @@ def clean_comment(raw_comment):
return cleaned_comment
def kill_ffmpeg_process(filepath):
"""Kill ffmpeg process that is processing a specific file
Args:
filepath: Path to the file being processed by ffmpeg
Returns:
subprocess.CompletedProcess: Result of the kill command
"""
if not filepath:
return False
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
pid = result.stdout.decode("utf-8").strip()
if pid:
cmd = "kill -9 %s" % pid
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
return result
def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"):
"""Create a copy of a media object
Args:
original_media: Original Media object to copy
copy_encodings: Whether to copy the encodings too
Returns:
New Media object
"""
with open(original_media.media_file.path, "rb") as f:
myfile = File(f)
new_media = models.Media(
media_file=myfile,
title=f"{original_media.title} {title_suffix}",
description=original_media.description,
user=original_media.user,
media_type="video",
enable_comments=original_media.enable_comments,
allow_download=original_media.allow_download,
state=original_media.state,
is_reviewed=original_media.is_reviewed,
encoding_status=original_media.encoding_status,
listable=original_media.listable,
add_date=timezone.now(),
video_height=original_media.video_height,
media_info=original_media.media_info,
)
models.Media.objects.bulk_create([new_media])
# avoids calling signals since signals will call media_init and we don't want that
if copy_encodings:
for encoding in original_media.encodings.filter(chunk=False, status="success"):
if encoding.media_file:
with open(encoding.media_file.path, "rb") as f:
myfile = File(f)
new_encoding = models.Encoding(
media_file=myfile, media=new_media, profile=encoding.profile, status="success", progress=100, chunk=False, logs=f"Copied from encoding {encoding.id}"
)
models.Encoding.objects.bulk_create([new_encoding])
# avoids calling signals as this is still not ready
# Copy categories and tags
for category in original_media.category.all():
new_media.category.add(category)
for tag in original_media.tags.all():
new_media.tags.add(tag)
if original_media.thumbnail:
with open(original_media.thumbnail.path, 'rb') as f:
thumbnail_name = helpers.get_file_name(original_media.thumbnail.path)
new_media.thumbnail.save(thumbnail_name, File(f))
if original_media.poster:
with open(original_media.poster.path, 'rb') as f:
poster_name = helpers.get_file_name(original_media.poster.path)
new_media.poster.save(poster_name, File(f))
return new_media
def create_video_trim_request(media, data):
"""Create a video trim request for a media
Args:
media: Media object
data: Dictionary with trim request data
Returns:
VideoTrimRequest object
"""
video_action = "replace"
if data.get('saveIndividualSegments'):
video_action = "create_segments"
elif data.get('saveAsCopy'):
video_action = "save_new"
video_trim_request = models.VideoTrimRequest.objects.create(media=media, status="initial", video_action=video_action, media_trim_style='no_encoding', timestamps=data.get('segments', {}))
return video_trim_request
def list_tasks():
"""Lists celery tasks
To be used in an admin dashboard
@@ -448,3 +556,14 @@ def list_tasks():
ret["task_ids"] = task_ids
ret["media_profile_pairs"] = media_profile_pairs
return ret
def handle_video_chapters(media, chapters):
video_chapter = models.VideoChapterData.objects.filter(media=media).first()
if video_chapter:
video_chapter.data = chapters
video_chapter.save()
else:
video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters)
return media.chapter_data

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-04-15 07:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0006_alter_category_title'),
]
operations = [
migrations.CreateModel(
name='VideoChapterData',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.JSONField(help_text='Chapter data')),
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='files.media')),
],
options={
'unique_together': {('media',)},
},
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.1.6 on 2025-05-02 14:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0007_alter_media_state_videochapterdata'),
]
operations = [
migrations.AlterField(
model_name='media',
name='state',
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='public', help_text='state of Media', max_length=20),
),
migrations.CreateModel(
name='VideoTrimRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('initial', 'Initial'), ('running', 'Running'), ('success', 'Success'), ('fail', 'Fail')], default='initial', max_length=20)),
('add_date', models.DateTimeField(auto_now_add=True)),
('video_action', models.CharField(choices=[('replace', 'Replace Original'), ('save_new', 'Save as New'), ('create_segments', 'Create Segments')], max_length=20)),
('media_trim_style', models.CharField(choices=[('no_encoding', 'No Encoding'), ('precise', 'Precise')], default='no_encoding', max_length=20)),
('timestamps', models.JSONField(help_text='Timestamps for trimming')),
('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trim_requests', to='files.media')),
],
),
]

View File

@@ -387,6 +387,7 @@ class Media(models.Model):
Update SearchVector field of SearchModel using raw SQL
search field is used to store SearchVector
"""
db_table = self._meta.db_table
# first get anything interesting out of the media
@@ -524,8 +525,12 @@ class Media(models.Model):
with open(self.media_file.path, "rb") as f:
myfile = File(f)
thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg"
self.thumbnail.save(content=myfile, name=thumbnail_name)
self.poster.save(content=myfile, name=thumbnail_name)
# avoid saving the whole object, because something might have been changed
# on the meanwhile
self.thumbnail.save(content=myfile, name=thumbnail_name, save=False)
self.poster.save(content=myfile, name=thumbnail_name, save=False)
self.save(update_fields=["thumbnail", "poster"])
return True
def produce_thumbnails_from_video(self):
@@ -559,8 +564,11 @@ class Media(models.Model):
with open(tf, "rb") as f:
myfile = File(f)
thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg"
self.thumbnail.save(content=myfile, name=thumbnail_name)
self.poster.save(content=myfile, name=thumbnail_name)
# avoid saving the whole object, because something might have been changed
# on the meanwhile
self.thumbnail.save(content=myfile, name=thumbnail_name, save=False)
self.poster.save(content=myfile, name=thumbnail_name, save=False)
self.save(update_fields=["thumbnail", "poster"])
helpers.rm_file(tf)
return True
@@ -637,15 +645,20 @@ class Media(models.Model):
self.preview_file_path = ""
else:
self.preview_file_path = encoding.media_file.path
self.save(update_fields=["listable", "preview_file_path"])
self.save(update_fields=["encoding_status", "listable"])
self.save(update_fields=["encoding_status", "listable", "preview_file_path"])
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add":
if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk:
from . import tasks
tasks.create_hls(self.friendly_token)
tasks.create_hls.delay(self.friendly_token)
# TODO: ideally would ensure this is run only at the end when the last encoding is done...
vt_request = VideoTrimRequest.objects.filter(media=self, status="running").first()
if vt_request:
tasks.post_trim_action.delay(self.friendly_token)
vt_request.status = "success"
vt_request.save(update_fields=["status"])
return True
def set_encoding_status(self):
@@ -667,6 +680,29 @@ class Media(models.Model):
return True
@property
def trim_video_url(self):
if self.media_type not in ["video"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
if ret:
return helpers.url_from_path(ret.media_file.path)
# showing the original file
return helpers.url_from_path(self.media_file.path)
@property
def trim_video_path(self):
if self.media_type not in ["video"]:
return None
ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first()
if ret:
return ret.media_file.path
return None
@property
def encodings_info(self, full=False):
"""Property used on serializers"""
@@ -678,12 +714,17 @@ class Media(models.Model):
for key in ENCODE_RESOLUTIONS_KEYS:
ret[key] = {}
# if this is enabled, return original file on a way
# that video.js can consume
# if DO_NOT_TRANSCODE_VIDEO enabled, return original file on a way
# that video.js can consume. Or also if encoding_status is running, do the
# same so that the video appears on the player
if settings.DO_NOT_TRANSCODE_VIDEO:
ret['0-original'] = {"h264": {"url": helpers.url_from_path(self.media_file.path), "status": "success", "progress": 100}}
return ret
if self.encoding_status in ["running", "pending"]:
ret['0-original'] = {"h264": {"url": helpers.url_from_path(self.media_file.path), "status": "success", "progress": 100}}
return ret
for encoding in self.encodings.select_related("profile").filter(chunk=False):
if encoding.profile.extension == "gif":
continue
@@ -948,6 +989,19 @@ class Media(models.Model):
)
return ret
@property
def video_chapters_folder(self):
custom_folder = f"{settings.THUMBNAIL_UPLOAD_DIR}{self.user.username}/{self.friendly_token}_chapters"
return os.path.join(settings.MEDIA_ROOT, custom_folder)
@property
def chapter_data(self):
data = []
chapter_data = self.chapters.first()
if chapter_data:
return chapter_data.chapter_data
return data
class License(models.Model):
"""A Base license model to be used in Media"""
@@ -1184,11 +1238,25 @@ class Encoding(models.Model):
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
self.save(update_fields=["progress"])
# save object with filter update
# to avoid calling signals
Encoding.objects.filter(pk=self.pk).update(progress=progress)
return True
return False
@@ -1440,6 +1508,82 @@ class Comment(MPTTModel):
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})"
@receiver(post_save, sender=Media)
def media_save(sender, instance, created, **kwargs):
# media_file path is not set correctly until mode is saved
@@ -1447,6 +1591,9 @@ def media_save(sender, instance, created, **kwargs):
# once model is saved
# SOS: do not put anything here, as if more logic is added,
# we have to disconnect signal to avoid infinite recursion
if not instance.friendly_token:
return False
if created:
from .methods import notify_users
@@ -1479,13 +1626,17 @@ 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):
"""
Deletes file from filesystem
when corresponding `Media` object is deleted.
"""
if instance.media_file:
helpers.rm_file(instance.media_file.path)
if instance.thumbnail:
@@ -1501,6 +1652,7 @@ def media_file_delete(sender, instance, **kwargs):
if instance.hls_file:
p = os.path.dirname(instance.hls_file)
helpers.rm_dir(p)
instance.user.update_user_media()
# remove extra zombie thumbnails

View File

@@ -161,6 +161,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"hls_info",
"license",
"subtitles_info",
"chapter_data",
"ratings_info",
"add_subtitle_url",
"allow_download",

View File

@@ -2,13 +2,11 @@ import json
import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime, timedelta
from celery import Task
from celery import shared_task as task
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import task_revoked
# from celery.task.control import revoke
@@ -16,6 +14,7 @@ from celery.utils.log import get_task_logger
from django.conf import settings
from django.core.cache import cache
from django.core.files import File
from django.db import DatabaseError
from django.db.models import Q
from actions.models import USER_MEDIA_ACTIONS, MediaAction
@@ -28,14 +27,31 @@ from .helpers import (
create_temp_file,
get_file_name,
get_file_type,
get_trim_timestamps,
media_file_info,
produce_ffmpeg_commands,
produce_friendly_token,
rm_file,
run_command,
trim_video_method,
)
from .methods import (
copy_video,
kill_ffmpeg_process,
list_tasks,
notify_users,
pre_save_action,
)
from .models import (
Category,
EncodeProfile,
Encoding,
Media,
Rating,
Tag,
VideoChapterData,
VideoTrimRequest,
)
from .methods import list_tasks, notify_users, pre_save_action
from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag
logger = get_task_logger(__name__)
@@ -48,6 +64,69 @@ ERRORS_LIST = [
]
def handle_pending_running_encodings(media):
"""Handle pending and running encodings for a media object.
we are trimming the original file. If there are encodings in success state, this means that the encoding has run
and has succeeded, so we can keep them (they will be trimmed) or if we dont keep them we dont have to delete them
here
However for encodings that are in pending or running phase, just delete them
Args:
media: The media object to handle encodings for
Returns:
bool: True if any encodings were deleted, False otherwise
"""
encodings = media.encodings.exclude(status="success")
deleted = False
for encoding in encodings:
if encoding.temp_file:
kill_ffmpeg_process(encoding.temp_file)
if encoding.chunk_file_path:
kill_ffmpeg_process(encoding.chunk_file_path)
deleted = True
encoding.delete()
return deleted
def pre_trim_video_actions(media):
# the reason for this function is to perform tasks before trimming a video
# avoid re-running unnecessary encodings (or chunkize_media, which is the first step for them)
# if the video is already completed
# however if it is a new video (user uploded the video and starts trimming
# before the video is processed), this is necessary, so encode has to be called to give it a chance to encode
# if a video is fully processed (all encodings are success), or if a video is new, then things are clear
# HOWEVER there is a race condition and this is that some encodings are success and some are pending/running
# Since we are making speed cutting, we will perform an ffmpeg -c copy on all of them and the result will be
# that they will end up differently cut, because ffmpeg checks for I-frames
# The result is fine if playing the video but is bad in case of HLS
# So we need to delete all encodings inevitably to produce same results, if there are some that are success and some that
# are still not finished.
profiles = EncodeProfile.objects.filter(active=True, extension='mp4', resolution__lte=media.video_height)
media_encodings = EncodeProfile.objects.filter(encoding__in=media.encodings.filter(status="success", chunk=False), extension='mp4').distinct()
picked = []
for profile in profiles:
if profile in media_encodings:
continue
else:
picked.append(profile)
if picked:
# by calling encode will re-encode all. The logic is explained above...
logger.info(f"Encoding media {media.friendly_token} will have to be performed for all profiles")
media.encode()
return True
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30 * 4)
def chunkize_media(self, friendly_token, profiles, force=True):
"""Break media in chunks and start encoding tasks"""
@@ -145,6 +224,7 @@ class EncodingTask(Task):
self.encoding.status = "fail"
self.encoding.save(update_fields=["status"])
kill_ffmpeg_process(self.encoding.temp_file)
kill_ffmpeg_process(self.encoding.chunk_file_path)
if hasattr(self.encoding, "media"):
self.encoding.media.post_encode_actions()
except BaseException:
@@ -171,7 +251,13 @@ def encode_media(
):
"""Encode a media to given profile, using ffmpeg, storing progress"""
logger.info("Encode Media started, friendly token {0}, profile id {1}, force {2}".format(friendly_token, profile_id, force))
logger.info(f"encode_media for {friendly_token}/{profile_id}/{encoding_id}/{force}/{chunk}")
# TODO: this is new behavior, check whether it performs well. Before that check it would end up saving the Encoding
# at some point below. Now it exits the task. Could it be that before it would give it a chance to re-run? Or it was
# not being used at all?
if not Encoding.objects.filter(id=encoding_id).exists():
logger.info(f"Exiting for {friendly_token}/{profile_id}/{encoding_id}/{force} since encoding id not found")
return False
if self.request.id:
task_id = self.request.id
@@ -311,28 +397,37 @@ def encode_media(
percent = duration * 100 / media.duration
if n_times % 60 == 0:
encoding.progress = percent
try:
encoding.save(update_fields=["progress", "update_date"])
logger.info("Saved {0}".format(round(percent, 2)))
except BaseException:
pass
encoding.save(update_fields=["progress", "update_date"])
logger.info("Saved {0}".format(round(percent, 2)))
n_times += 1
except DatabaseError:
# primary reason for this is that the encoding has been deleted, because
# the media file was deleted, or also that there was a trim video request
# so it would be redundant to let it complete the encoding
kill_ffmpeg_process(encoding.temp_file)
kill_ffmpeg_process(encoding.chunk_file_path)
return False
except StopIteration:
break
except VideoEncodingError:
# ffmpeg error, or ffmpeg was killed
raise
except Exception as e:
try:
# output is empty, fail message is on the exception
output = e.message
except AttributeError:
output = ""
if isinstance(e, SoftTimeLimitExceeded):
kill_ffmpeg_process(encoding.temp_file)
kill_ffmpeg_process(encoding.temp_file)
kill_ffmpeg_process(encoding.chunk_file_path)
encoding.logs = output
encoding.status = "fail"
encoding.save(update_fields=["status", "logs"])
try:
encoding.save(update_fields=["status", "logs"])
except DatabaseError:
return False
raise_exception = True
# if this is an ffmpeg's valid error
# no need for the task to be re-run
@@ -397,10 +492,10 @@ def produce_sprite_from_video(friendly_token):
if os.path.exists(output_name) and get_file_type(output_name) == "image":
with open(output_name, "rb") as f:
myfile = File(f)
media.sprites.save(
content=myfile,
name=get_file_name(media.media_file.path) + "sprites.jpg",
)
# SOS: avoid race condition, since this runs for a long time and will replace any other media changes on the meanwhile!!!
media.sprites.save(content=myfile, name=get_file_name(media.media_file.path) + "sprites.jpg", save=False)
media.save(update_fields=["sprites"])
except Exception as e:
print(e)
return True
@@ -452,8 +547,7 @@ def create_hls(friendly_token):
pp = os.path.join(output_dir, "master.m3u8")
if os.path.exists(pp):
if media.hls_file != pp:
media.hls_file = pp
media.save(update_fields=["hls_file"])
Media.objects.filter(pk=media.pk).update(hls_file=pp)
return True
@@ -776,23 +870,189 @@ def task_sent_handler(sender=None, headers=None, body=None, **kwargs):
return True
def kill_ffmpeg_process(filepath):
# this is not ideal, ffmpeg pid could be linked to the Encoding object
cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
pid = result.stdout.decode("utf-8").strip()
if pid:
cmd = "kill -9 %s" % pid
result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
return result
@task(name="remove_media_file", base=Task, queue="long_tasks")
def remove_media_file(media_file=None):
rm_file(media_file)
return True
@task(name="update_encoding_size", queue="short_tasks")
def update_encoding_size(encoding_id):
"""Update the size of an encoding without saving to avoid calling signals"""
encoding = Encoding.objects.filter(id=encoding_id).first()
if encoding:
encoding.update_size_without_save()
return True
return False
@task(name="produce_video_chapters", queue="short_tasks")
def produce_video_chapters(chapter_id):
# this is not used
return False
chapter_object = VideoChapterData.objects.filter(id=chapter_id).first()
if not chapter_object:
return False
media = chapter_object.media
video_path = media.media_file.path
output_folder = media.video_chapters_folder
chapters = chapter_object.data
width = 336
height = 188
if not os.path.exists(output_folder):
os.makedirs(output_folder)
results = []
for i, chapter in enumerate(chapters):
timestamp = chapter["start"]
title = chapter["title"]
output_filename = f"thumbnail_{i:02d}.jpg" # noqa
output_path = os.path.join(output_folder, output_filename)
command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path]
ret = run_command(command) # noqa
if os.path.exists(output_path) and get_file_type(output_path) == "image":
results.append({"start": timestamp, "title": title, "thumbnail": output_path})
chapter_object.data = results
chapter_object.save(update_fields=["data"])
return True
@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600)
def post_trim_action(friendly_token):
"""Perform post-processing actions after video trimming
Args:
friendly_token: The friendly token of the media
Returns:
bool: True if successful, False otherwise
"""
logger.info(f"Post trim action for {friendly_token}")
try:
media = Media.objects.get(friendly_token=friendly_token)
except Media.DoesNotExist:
logger.info(f"Media with friendly token {friendly_token} not found")
return False
media.set_media_type()
encodings = media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
# if they are still not encoded, when the first one will be encoded, it will have the chance to
# call post_trim_action again
if encodings:
for encoding in encodings:
# update encoding size, in case they don't have one, due to the
# way the copy_video took place
update_encoding_size(encoding.id)
media.produce_thumbnails_from_video()
produce_sprite_from_video.delay(friendly_token)
create_hls.delay(friendly_token)
vt_request = VideoTrimRequest.objects.filter(media=media, status="running").first()
if vt_request:
vt_request.status = "success"
vt_request.save(update_fields=["status"])
return True
@task(name="video_trim_task", bind=True, queue="short_tasks", soft_time_limit=600)
def video_trim_task(self, trim_request_id):
# SOS: if at some point we move from ffmpeg copy, then this need be changed
# to long_tasks
try:
trim_request = VideoTrimRequest.objects.get(id=trim_request_id)
except VideoTrimRequest.DoesNotExist:
logger.info(f"VideoTrimRequest with ID {trim_request_id} not found")
return False
trim_request.status = "running"
trim_request.save(update_fields=["status"])
timestamps_encodings = get_trim_timestamps(trim_request.media.trim_video_path, trim_request.timestamps)
timestamps_original = get_trim_timestamps(trim_request.media.media_file.path, trim_request.timestamps)
if not timestamps_encodings:
trim_request.status = "fail"
trim_request.save(update_fields=["status"])
return False
target_media = trim_request.media
original_media = trim_request.media
# splitting the logic for single file and multiple files
if trim_request.video_action in ["save_new", "replace"]:
proceed_with_single_file = True
if trim_request.video_action == "create_segments":
if len(timestamps_encodings) == 1:
proceed_with_single_file = True
else:
proceed_with_single_file = False
if proceed_with_single_file:
if trim_request.video_action == "save_new" or trim_request.video_action == "create_segments" and len(timestamps_encodings) == 1:
new_media = copy_video(original_media, copy_encodings=True)
target_media = new_media
trim_request.media = new_media
trim_request.save(update_fields=["media"])
# processing timestamps differently on encodings and original file, in case we do accuracy trimming (currently not)
# these have different I-frames and the cut is made based on the I-frames
original_trim_result = trim_video_method(target_media.media_file.path, timestamps_original)
if not original_trim_result:
logger.info(f"Failed to trim original file for media {target_media.friendly_token}")
deleted_encodings = handle_pending_running_encodings(target_media)
# the following could be un-necessary, read commend in pre_trim_video_actions to see why
encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
for encoding in encodings:
trim_result = trim_video_method(encoding.media_file.path, timestamps_encodings)
if not trim_result:
logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}")
encoding.delete()
pre_trim_video_actions(target_media)
post_trim_action.delay(target_media.friendly_token)
else:
for i, timestamp in enumerate(timestamps_encodings, start=1):
# copy the original file for each of the segments. This could be optimized to avoid the overhead but
# for now is necessary because the ffmpeg trim command will be run towards the original
# file on different times.
target_media = copy_video(original_media, title_suffix=f"(Trimmed) {i}", copy_encodings=True)
video_trim_request = VideoTrimRequest.objects.create(media=target_media, status="running", video_action="create_segments", media_trim_style='no_encoding', timestamps=[timestamp]) # noqa
original_trim_result = trim_video_method(target_media.media_file.path, [timestamp])
deleted_encodings = handle_pending_running_encodings(target_media) # noqa
# the following could be un-necessary, read commend in pre_trim_video_actions to see why
encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False)
for encoding in encodings:
trim_result = trim_video_method(encoding.media_file.path, [timestamp])
if not trim_result:
logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}")
encoding.delete()
pre_trim_video_actions(target_media)
post_trim_action.delay(target_media.friendly_token)
# set as completed the initial trim_request
trim_request.status = "success"
trim_request.save(update_fields=["status"])
return True
# TODO LIST
# 1 chunks are deleted from original server when file is fully encoded.
# however need to enter this logic in cases of fail as well

View File

@@ -16,6 +16,9 @@ urlpatterns = [
re_path(r"^edit_subtitle", views.edit_subtitle, name="edit_subtitle"),
re_path(r"^categories$", views.categories, name="categories"),
re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^publish", views.publish_media, name="publish_media"),
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
re_path(r"^edit_video", views.edit_video, name="edit_video"),
re_path(r"^edit", views.edit_media, name="edit_media"),
re_path(r"^embed", views.embed_media, name="get_embed"),
re_path(r"^featured$", views.featured_media),
@@ -62,6 +65,14 @@ urlpatterns = [
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$",
views.MediaActions.as_view(),
),
re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$",
views.video_chapters,
),
re_path(
r"^api/v1/media/(?P<friendly_token>[\w]*)/trim_video$",
views.trim_video,
),
re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
re_path(r"^api/v1/tags$", views.TagList.as_view()),
re_path(r"^api/v1/comments$", views.CommentList.as_view()),

View File

@@ -1,3 +1,4 @@
import json
from datetime import datetime, timedelta
from allauth.socialaccount.models import SocialApp
@@ -7,9 +8,10 @@ from django.contrib.auth.decorators import login_required
from django.contrib.postgres.search import SearchQuery
from django.core.mail import EmailMessage
from django.db.models import Q
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from drf_yasg import openapi as openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import permissions, status
@@ -36,14 +38,22 @@ from cms.version import VERSION
from identity_providers.models import LoginOption
from users.models import User
from .forms import ContactForm, EditSubtitleForm, MediaForm, SubtitleForm
from . import helpers
from .forms import (
ContactForm,
EditSubtitleForm,
MediaMetadataForm,
MediaPublishForm,
SubtitleForm,
)
from .frontend_translations import translate_string
from .helpers import clean_query, get_alphanumeric_only, produce_ffmpeg_commands
from .methods import (
check_comment_for_mention,
create_video_trim_request,
get_user_or_session,
handle_video_chapters,
is_mediacms_editor,
is_mediacms_manager,
list_tasks,
notify_user_on_comment,
show_recommended_media,
@@ -60,6 +70,7 @@ from .models import (
PlaylistMedia,
Subtitle,
Tag,
VideoTrimRequest,
)
from .serializers import (
CategorySerializer,
@@ -73,7 +84,7 @@ from .serializers import (
TagSerializer,
)
from .stop_words import STOP_WORDS
from .tasks import save_user_action
from .tasks import save_user_action, video_trim_task
VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
@@ -103,7 +114,7 @@ def add_subtitle(request):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
@@ -138,7 +149,7 @@ def edit_subtitle(request):
if not subtitle:
return HttpResponseRedirect("/")
if not (request.user == subtitle.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
context = {"subtitle": subtitle, "action": action}
@@ -233,6 +244,43 @@ def history(request):
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"""
@@ -245,10 +293,10 @@ def edit_media(request):
if not media:
return HttpResponseRedirect("/")
if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
if not (request.user == media.user or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if request.method == "POST":
form = MediaForm(request.user, request.POST, request.FILES, instance=media)
form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media)
if form.is_valid():
media = form.save()
for tag in media.tags.all():
@@ -267,11 +315,145 @@ def edit_media(request):
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = MediaForm(request.user, instance=media)
form = MediaMetadataForm(request.user, instance=media)
return render(
request,
"cms/edit_media.html",
{"form": form, "add_subtitle_url": media.add_subtitle_url},
{"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 == media.user 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},
)
@@ -428,10 +610,22 @@ def view_media(request):
context["CAN_DELETE_COMMENTS"] = False
if request.user.is_authenticated:
if (media.user.id == request.user.id) or is_mediacms_editor(request.user) or is_mediacms_manager(request.user):
if media.user.id == request.user.id 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)
@@ -621,7 +815,7 @@ class MediaDetail(APIView):
if isinstance(media, Response):
return media
if not (is_mediacms_editor(request.user) or is_mediacms_manager(request.user)):
if not is_mediacms_editor(request.user):
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
action = request.data.get("type")
@@ -738,7 +932,7 @@ class MediaActions(APIView):
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) or is_mediacms_manager(request.user)):
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):