From b7b5c0d288e32fb4e8401364485565ba105e6dac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:09:26 +0100 Subject: [PATCH 01/15] Bump urllib3 from 2.6.0 to 2.6.3 (#1391) * Bump project version to 4.1.0 * Bump urllib3 from 2.6.0 to 2.6.3 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.0 to 2.6.3. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.6.0...2.6.3) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pod/settings.py | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pod/settings.py b/pod/settings.py index a74ac00bd4..06df8509ea 100644 --- a/pod/settings.py +++ b/pod/settings.py @@ -19,7 +19,7 @@ ## # Version of the project # -VERSION = "4.0.3" +VERSION = "4.1.0" ## # Installed applications list diff --git a/requirements.txt b/requirements.txt index 278f170f0c..05eca9d35f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-modeltranslation==0.19.11 django-cas-ng==5.0.1 ldap3==2.9.1 django-simple-captcha==0.6.0 -urllib3==2.6.0 +urllib3==2.6.3 elasticsearch==8.17.2 djangorestframework==3.15.2 django-filter==24.3 From a6c69bfd98a7ce18fd19fd4cc0cfc1404cd55275 Mon Sep 17 00:00:00 2001 From: Celine Didier Date: Tue, 27 Jan 2026 09:27:00 +0100 Subject: [PATCH 02/15] Fixup. Format code with Black (#1396) Co-authored-by: github-actions --- pod/ai_enhancement/forms.py | 6 ++-- .../ai_enhancement_template_tags.py | 1 - pod/authentication/signals.py | 7 ++--- pod/authentication/tests/test_views.py | 6 ++-- pod/enrichment/tests/test_views.py | 1 + pod/import_video/views.py | 1 - pod/main/admin.py | 1 - pod/main/models.py | 1 - pod/main/views.py | 1 - pod/meeting/forms.py | 1 - pod/meeting/models.py | 10 ++----- pod/meeting/tests/test_views.py | 1 - pod/meeting/views.py | 28 ++++++------------- pod/playlist/apps.py | 6 ++-- pod/playlist/models.py | 1 - .../templatetags/favorites_playlist.py | 1 - pod/playlist/tests/test_forms.py | 1 - pod/playlist/views.py | 1 - pod/podfile/forms.py | 1 - pod/podfile/views.py | 1 - pod/progressive_web_app/utils.py | 1 - pod/quiz/admin.py | 1 - pod/quiz/templatetags/video_quiz.py | 1 - pod/quiz/views.py | 2 +- pod/recorder/forms.py | 1 - pod/recorder/tests/test_models.py | 12 +++----- pod/recorder/views.py | 1 + .../templatetags/speaker_template_tags.py | 1 - pod/video/feeds.py | 1 - pod/video/forms.py | 4 +-- .../commands/check_obsolete_videos.py | 1 - .../commands/create_archive_package.py | 1 - .../commands/import_encoded_recording.py | 1 - .../commands/import_transcripted_video.py | 1 - pod/video/models.py | 7 ++--- pod/video/tests/test_models.py | 12 +++----- pod/video/tests/test_obsolescence.py | 6 ++-- pod/video/views.py | 12 +++----- pod/video_encode_transcript/Encoding_video.py | 4 +-- 39 files changed, 40 insertions(+), 107 deletions(-) diff --git a/pod/ai_enhancement/forms.py b/pod/ai_enhancement/forms.py index b90981ec3a..c0f3f5df5c 100644 --- a/pod/ai_enhancement/forms.py +++ b/pod/ai_enhancement/forms.py @@ -85,12 +85,10 @@ class Meta: ) tags = TagField( - help_text=_( - """ + help_text=_(""" Please choose tags for your video. Separate tags with spaces, enclose the tags consist of several words in quotation marks. - """ - ), + """), verbose_name=_("Tags"), ) diff --git a/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py b/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py index 743e064e75..eeea78f381 100644 --- a/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py +++ b/pod/ai_enhancement/templatetags/ai_enhancement_template_tags.py @@ -10,7 +10,6 @@ ) from pod.video.models import Video - USE_AI_ENHANCEMENT = getattr(settings, "USE_AI_ENHANCEMENT", False) AI_ENHANCEMENT_TO_STAFF_ONLY = getattr(settings, "AI_ENHANCEMENT_TO_STAFF_ONLY", True) diff --git a/pod/authentication/signals.py b/pod/authentication/signals.py index abae16a571..74a08d3e7b 100644 --- a/pod/authentication/signals.py +++ b/pod/authentication/signals.py @@ -37,11 +37,8 @@ def cas_user_logout_callback(sender, **kwargs) -> None: """Callback for CAS user logout signal.""" args = {} args.update(kwargs) - print( - """cas_user_logout_callback: + print("""cas_user_logout_callback: user: %s session: %s ticket: %s - """ - % (args.get("user"), args.get("session"), args.get("ticket")) - ) + """ % (args.get("user"), args.get("session"), args.get("ticket"))) diff --git a/pod/authentication/tests/test_views.py b/pod/authentication/tests/test_views.py index 816e2caa95..5b3b64f70e 100644 --- a/pod/authentication/tests/test_views.py +++ b/pod/authentication/tests/test_views.py @@ -49,10 +49,8 @@ def test_authentication_logout(self) -> None: response = self.client.post("/authentication_logout/") self.assertRedirects(response, "/accounts/logout/?next=/", target_status_code=302) - print( - " ---> test_authentication_logout \ - of authenticationViewsTestCase: OK!" - ) + print(" ---> test_authentication_logout \ + of authenticationViewsTestCase: OK!") def test_userpicture(self) -> None: self.client = Client() diff --git a/pod/enrichment/tests/test_views.py b/pod/enrichment/tests/test_views.py index f2761bb927..466833217d 100644 --- a/pod/enrichment/tests/test_views.py +++ b/pod/enrichment/tests/test_views.py @@ -1,6 +1,7 @@ # gitguardian:ignore """Esup-Pod unit tests for enrichment views.""" + from django.test import TestCase from django.contrib.auth import authenticate from django.contrib.auth.models import User diff --git a/pod/import_video/views.py b/pod/import_video/views.py index 986697ccb5..05b20aa037 100644 --- a/pod/import_video/views.py +++ b/pod/import_video/views.py @@ -50,7 +50,6 @@ from pod.main.tasks import task_start_bbb_presentation_encode_and_upload_to_pod from pod.main.tasks import task_start_bbb_presentation_encode_and_move_to_destination - RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY", True ) diff --git a/pod/main/admin.py b/pod/main/admin.py index 9d1ac78ce8..72f8085010 100644 --- a/pod/main/admin.py +++ b/pod/main/admin.py @@ -14,7 +14,6 @@ from pod.main.models import AdditionalChannelTab from pod.main.models import Block - SITE_ID = getattr(settings, "SITE_ID", 1) content_widget = {} for key, value in settings.LANGUAGES: diff --git a/pod/main/models.py b/pod/main/models.py index 55584299fe..9b203daadf 100644 --- a/pod/main/models.py +++ b/pod/main/models.py @@ -14,7 +14,6 @@ import mimetypes from tinymce.models import HTMLField - FILES_DIR = getattr(settings, "FILES_DIR", "files") diff --git a/pod/main/views.py b/pod/main/views.py index 17c06c34ee..6e7e5cbaa2 100644 --- a/pod/main/views.py +++ b/pod/main/views.py @@ -45,7 +45,6 @@ from .utils import is_ajax from honeypot.decorators import check_honeypot - ## # Settings exposed in templates # diff --git a/pod/meeting/forms.py b/pod/meeting/forms.py index 74edf69b8a..1a6bb569f8 100644 --- a/pod/meeting/forms.py +++ b/pod/meeting/forms.py @@ -20,7 +20,6 @@ from pod.main.forms_utils import OwnerWidget, AddOwnerWidget from pod.meeting.webinar import start_webinar, stop_webinar, toggle_rtmp_gateway - __FILEPICKER__ = False if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True diff --git a/pod/meeting/models.py b/pod/meeting/models.py index de3bb3def1..b06543f549 100644 --- a/pod/meeting/models.py +++ b/pod/meeting/models.py @@ -277,14 +277,12 @@ class Meeting(models.Model): null=True, blank=True, verbose_name=_("Slides"), - help_text=_( - """ + help_text=_(""" BigBlueButton will accept Office documents (.doc .docx .pptx), text documents(.txt), images (.png ,.jpg) and Adobe Acrobat documents (.pdf); we recommend converting documents to .pdf prior to uploading for best results. Maximum size is 30 MB or 150 pages per document. - """ - ), + """), on_delete=models.CASCADE, ) site = models.ForeignKey(Site, verbose_name=_("Site"), on_delete=models.CASCADE) @@ -787,9 +785,7 @@ def get_join_url(self, fullname, role, userID=""): """ if role not in ["MODERATOR", "VIEWER"]: msg = {} - msg[ - "error" - ] = """ + msg["error"] = """ Define user role for the meeting. Valid values are MODERATOR or VIEWER """ msg["returncode"] = "" diff --git a/pod/meeting/tests/test_views.py b/pod/meeting/tests/test_views.py index dca40d9141..364bf215ff 100644 --- a/pod/meeting/tests/test_views.py +++ b/pod/meeting/tests/test_views.py @@ -20,7 +20,6 @@ from pod.meeting.models import LiveGateway from pod.video.models import Type - VIDEO_TEST = getattr(settings, "VIDEO_TEST", "pod/main/static/video_test/pod.mp4") ROOT_URLCONF = getattr(settings, "ROOT_URLCONF", "http://testserver") diff --git a/pod/meeting/views.py b/pod/meeting/views.py index f6b8fea0c3..cf797b6744 100644 --- a/pod/meeting/views.py +++ b/pod/meeting/views.py @@ -49,7 +49,6 @@ from pod.live.models import Event from pod.live.views import can_manage_event - RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY", False ) @@ -1037,8 +1036,7 @@ def get_html_content(request: WSGIRequest, meeting: Meeting) -> str: ) if meeting.recurrence: html_content = ( - _( - """ + _("""

Hello,

%(owner)s invites you to a recurring meeting %(meeting_title)s.

Start date: %(start_date_time)s

@@ -1047,8 +1045,7 @@ def get_html_content(request: WSGIRequest, meeting: Meeting) -> str:

Here is the link to join the meeting: %(join_link)s

You need this password to enter: %(password)s

Regards

- """ - ) + """) % { "owner": full_name, "meeting_title": meeting.name, @@ -1063,16 +1060,14 @@ def get_html_content(request: WSGIRequest, meeting: Meeting) -> str: else: if meeting.is_personal: html_content = ( - _( - """ + _("""

Hello,

%(owner)s invites you to the meeting %(meeting_title)s.

here the link to join the meeting: %(join_link)s

You need this password to enter: %(password)s

Regards

- """ - ) + """) % { "owner": full_name, "meeting_title": meeting.name, @@ -1086,8 +1081,7 @@ def get_html_content(request: WSGIRequest, meeting: Meeting) -> str: ) else: html_content = ( - _( - """ + _("""

Hello,

%(owner)s invites you to the meeting %(meeting_title)s.

Start date: %(start_date_time)s

@@ -1096,8 +1090,7 @@ def get_html_content(request: WSGIRequest, meeting: Meeting) -> str: %(join_link)s

You need this password to enter: %(password)s

Regards

- """ - ) + """) % { "owner": full_name, "meeting_title": meeting.name, @@ -1121,15 +1114,10 @@ def create_ics(request: WSGIRequest, meeting: Meeting) -> str: "owner": meeting.owner.get_full_name(), "meeting_title": meeting.name, } - description = ( - _( - """ + description = _(""" Here is the link to join the meeting: %(join_link)s You need this password to enter: %(password)s - """ - ) - % {"join_link": join_link, "password": meeting.attendee_password} - ) + """) % {"join_link": join_link, "password": meeting.attendee_password} event_description = "\\n".join( line for line in description.replace(" ", "").split("\n") ) diff --git a/pod/playlist/apps.py b/pod/playlist/apps.py index 8d9732dc18..5fcfe08617 100644 --- a/pod/playlist/apps.py +++ b/pod/playlist/apps.py @@ -46,13 +46,11 @@ def save_favorites(self) -> None: """Save previous data from favorites table.""" try: with connection.cursor() as c: - c.execute( - """ + c.execute(""" SELECT owner_id, date_added, rank, video_id FROM favorite_favorite ORDER BY owner_id - """ - ) + """) results = c.fetchall() for res in results: owner_id = res[0] diff --git a/pod/playlist/models.py b/pod/playlist/models.py index 9c5c5256d1..ad177dc88b 100644 --- a/pod/playlist/models.py +++ b/pod/playlist/models.py @@ -14,7 +14,6 @@ from pod.video.models import Video from pod.video.utils import sort_videos_list - SITE_ID = getattr(settings, "SITE_ID") __MAX_LENGTH_FOR_PLAYLIST_NAME__ = 200 diff --git a/pod/playlist/templatetags/favorites_playlist.py b/pod/playlist/templatetags/favorites_playlist.py index 76878017ce..c1f569805c 100644 --- a/pod/playlist/templatetags/favorites_playlist.py +++ b/pod/playlist/templatetags/favorites_playlist.py @@ -13,7 +13,6 @@ get_favorite_playlist_for_user, ) - register = Library() diff --git a/pod/playlist/tests/test_forms.py b/pod/playlist/tests/test_forms.py index 9a285af7d6..566625c4ff 100644 --- a/pod/playlist/tests/test_forms.py +++ b/pod/playlist/tests/test_forms.py @@ -6,7 +6,6 @@ from ...playlist.forms import PlaylistForm, PlaylistRemoveForm, PlaylistPasswordForm from ...playlist.apps import FAVORITE_PLAYLIST_NAME - FIELD_REQUIRED_ERROR_MESSAGE = _("This field is required.") # ggignore-start diff --git a/pod/playlist/views.py b/pod/playlist/views.py index f6245f8f0f..e4252e7275 100644 --- a/pod/playlist/views.py +++ b/pod/playlist/views.py @@ -50,7 +50,6 @@ import json import hashlib - TEMPLATE_VISIBLE_SETTINGS = getattr( settings, "TEMPLATE_VISIBLE_SETTINGS", diff --git a/pod/podfile/forms.py b/pod/podfile/forms.py index c130e8e194..cf3cc308d2 100644 --- a/pod/podfile/forms.py +++ b/pod/podfile/forms.py @@ -12,7 +12,6 @@ from .models import CustomFileModel from .models import CustomImageModel - FILE_ALLOWED_EXTENSIONS = getattr( settings, "FILE_ALLOWED_EXTENSIONS", diff --git a/pod/podfile/views.py b/pod/podfile/views.py index 29f68147e9..6c1f13926d 100644 --- a/pod/podfile/views.py +++ b/pod/podfile/views.py @@ -30,7 +30,6 @@ from pod.main.utils import is_ajax from .utils import update_shared_user - __FOLDER_FILE_TYPE__ = ["image", "file"] diff --git a/pod/progressive_web_app/utils.py b/pod/progressive_web_app/utils.py index e0ff6cb516..6f1b00dcb2 100644 --- a/pod/progressive_web_app/utils.py +++ b/pod/progressive_web_app/utils.py @@ -3,7 +3,6 @@ from webpush import send_user_notification from django.templatetags.static import static - DEFAULT_ICON = static("img/icon_x1024.png") diff --git a/pod/quiz/admin.py b/pod/quiz/admin.py index fe763dc69e..173b5bc8aa 100644 --- a/pod/quiz/admin.py +++ b/pod/quiz/admin.py @@ -9,7 +9,6 @@ SingleChoiceQuestion, ) - # Questions types diff --git a/pod/quiz/templatetags/video_quiz.py b/pod/quiz/templatetags/video_quiz.py index e67e2815d0..e2d05d6831 100644 --- a/pod/quiz/templatetags/video_quiz.py +++ b/pod/quiz/templatetags/video_quiz.py @@ -6,7 +6,6 @@ from pod.video.models import Video - register = Library() diff --git a/pod/quiz/views.py b/pod/quiz/views.py index ef9476004b..695d4ff8f6 100644 --- a/pod/quiz/views.py +++ b/pod/quiz/views.py @@ -396,7 +396,7 @@ def video_quiz(request: WSGIRequest, video_slug: str) -> HttpResponse: return redirect("%s?referrer=%s" % (settings.LOGIN_URL, request.get_full_path())) if request.method == "POST": - (percentage_score, questions_stats, questions_answers, questions_form_errors) = ( + percentage_score, questions_stats, questions_answers, questions_form_errors = ( process_quiz_submission(request, quiz) ) form_submitted = True diff --git a/pod/recorder/forms.py b/pod/recorder/forms.py index b9e538315f..242843a380 100644 --- a/pod/recorder/forms.py +++ b/pod/recorder/forms.py @@ -7,7 +7,6 @@ from pod.main.forms_utils import add_placeholder_and_asterisk from django_select2 import forms as s2forms - DEFAULT_RECORDER_PATH = getattr(settings, "DEFAULT_RECORDER_PATH", "/data/ftp-pod/ftp/") ALLOW_RECORDER_MANAGER_CHOICE_VID_OWNER = getattr( settings, "ALLOW_RECORDER_MANAGER_CHOICE_VID_OWNER", True diff --git a/pod/recorder/tests/test_models.py b/pod/recorder/tests/test_models.py index 9bb134bcbb..1f1a43663b 100644 --- a/pod/recorder/tests/test_models.py +++ b/pod/recorder/tests/test_models.py @@ -156,10 +156,8 @@ def test_verifying_attributs_fst_cases(self): recording.source_file = "" recording.save() self.assertEqual(2, len(recording.verify_attributs())) - print( - " ---> test_verifying_attributs_fst_cases \ - of RecordingTestCase: OK!" - ) + print(" ---> test_verifying_attributs_fst_cases \ + of RecordingTestCase: OK!") # Testing the two elif cases of verify_attibuts method def test_verifying_attributs_snd_cases(self): @@ -169,10 +167,8 @@ def test_verifying_attributs_snd_cases(self): recording.source_file = "/home/pod/files/somefile.mp4" recording.save() self.assertEqual(2, len(recording.verify_attributs())) - print( - " ---> test_verifying_attributs_snd_cases \ - of RecordingTestCase: OK!" - ) + print(" ---> test_verifying_attributs_snd_cases \ + of RecordingTestCase: OK!") def test_clean_raise_exception(self): """Test method clean().""" diff --git a/pod/recorder/views.py b/pod/recorder/views.py index afe574da24..2013391e8b 100644 --- a/pod/recorder/views.py +++ b/pod/recorder/views.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Esup-pod recorder views.""" + import hashlib import logging import os diff --git a/pod/speaker/templatetags/speaker_template_tags.py b/pod/speaker/templatetags/speaker_template_tags.py index 079a294660..c12d146ce7 100644 --- a/pod/speaker/templatetags/speaker_template_tags.py +++ b/pod/speaker/templatetags/speaker_template_tags.py @@ -6,7 +6,6 @@ from pod.speaker.utils import get_video_speakers, get_video_speakers_grouped from pod.video.models import Video - register = template.Library() diff --git a/pod/video/feeds.py b/pod/video/feeds.py index 4a1a7a099f..5993eeee63 100644 --- a/pod/video/feeds.py +++ b/pod/video/feeds.py @@ -19,7 +19,6 @@ import re import os - ## # Settings exposed in templates # diff --git a/pod/video/forms.py b/pod/video/forms.py index 3c1f9e54c2..a2385283a9 100644 --- a/pod/video/forms.py +++ b/pod/video/forms.py @@ -1384,9 +1384,7 @@ def __init__(self, *args, **kwargs) -> None: super(NoteCommentsForm, self).__init__(*args, **kwargs) # self.fields["user"].widget = forms.HiddenInput() # self.fields["note"].widget = forms.HiddenInput() - self.fields["comment"].widget.attrs[ - "class" - ] = "form-control \ + self.fields["comment"].widget.attrs["class"] = "form-control \ input_comment" self.fields["comment"].widget.attrs["autocomplete"] = "off" self.fields["comment"].widget.attrs["rows"] = 3 diff --git a/pod/video/management/commands/check_obsolete_videos.py b/pod/video/management/commands/check_obsolete_videos.py index 22e093f6b0..02936fef56 100644 --- a/pod/video/management/commands/check_obsolete_videos.py +++ b/pod/video/management/commands/check_obsolete_videos.py @@ -21,7 +21,6 @@ from datetime import date, timedelta - USE_OBSOLESCENCE = getattr(settings, "USE_OBSOLESCENCE", False) USE_ESTABLISHMENT = getattr(settings, "USE_ESTABLISHMENT_FIELD", False) MANAGERS = getattr(settings, "MANAGERS", []) diff --git a/pod/video/management/commands/create_archive_package.py b/pod/video/management/commands/create_archive_package.py index 42ca028165..3cd08fc10a 100644 --- a/pod/video/management/commands/create_archive_package.py +++ b/pod/video/management/commands/create_archive_package.py @@ -24,7 +24,6 @@ from pod.enrichment.models import Enrichment from pod.main.utils import sizeof_fmt - """CUSTOM PARAMETERS.""" LANGUAGE_CODE = getattr(settings, "LANGUAGE_CODE", "fr") ARCHIVE_ROOT = getattr(settings, "ARCHIVE_ROOT", "/video_archiving") diff --git a/pod/video/management/commands/import_encoded_recording.py b/pod/video/management/commands/import_encoded_recording.py index bd5430a0ee..f9d563e535 100644 --- a/pod/video/management/commands/import_encoded_recording.py +++ b/pod/video/management/commands/import_encoded_recording.py @@ -9,7 +9,6 @@ from pod.recorder.models import Recording from pod.video.remote_encode import store_remote_encoding_studio - LANGUAGE_CODE = getattr(settings, "LANGUAGE_CODE", "fr") diff --git a/pod/video/management/commands/import_transcripted_video.py b/pod/video/management/commands/import_transcripted_video.py index a5a33ede2d..edb1beb350 100755 --- a/pod/video/management/commands/import_transcripted_video.py +++ b/pod/video/management/commands/import_transcripted_video.py @@ -9,7 +9,6 @@ from pod.video.models import Video from pod.video.remote_transcript import store_remote_transcripting_video - LANGUAGE_CODE = getattr(settings, "LANGUAGE_CODE", "fr") diff --git a/pod/video/models.py b/pod/video/models.py index 21a093a775..370ecc6c78 100644 --- a/pod/video/models.py +++ b/pod/video/models.py @@ -1083,11 +1083,8 @@ def get_thumbnail_card(self) -> str: # height="{{ im.height }}" loading="lazy"> else: thumbnail_url = static(DEFAULT_THUMBNAIL) - return ( - '%s' - % (thumbnail_url, self.title) - ) + return '%s' % (thumbnail_url, self.title) @property def duration_in_time(self) -> str: diff --git a/pod/video/tests/test_models.py b/pod/video/tests/test_models.py index c50a9387f4..62937e3f59 100644 --- a/pod/video/tests/test_models.py +++ b/pod/video/tests/test_models.py @@ -523,10 +523,8 @@ def test_VideoRendition_creation_by_default(self) -> None: "VideoRendition num %s with resolution %s" % ("%04d" % vr.id, vr.resolution), ) vr.clean() - print( - " ---> test_VideoRendition_creation_by_default of \ - VideoRenditionTestCase: OK!" - ) + print(" ---> test_VideoRendition_creation_by_default of \ + VideoRenditionTestCase: OK!") def test_VideoRendition_creation_with_values(self) -> None: # print("check resolution error") @@ -574,10 +572,8 @@ def test_VideoRendition_creation_with_values(self) -> None: "VideoRendition num %s with resolution %s" % ("%04d" % vr.id, vr.resolution), ) self.assertRaises(ValidationError, vr.clean) - print( - " ---> test_VideoRendition_creation_with_values of \ - VideoRenditionTestCase: OK!" - ) + print(" ---> test_VideoRendition_creation_with_values of \ + VideoRenditionTestCase: OK!") def test_delete_object(self) -> None: self.create_video_rendition(resolution="640x365") diff --git a/pod/video/tests/test_obsolescence.py b/pod/video/tests/test_obsolescence.py index 236795f09c..74a4fc336e 100644 --- a/pod/video/tests/test_obsolescence.py +++ b/pod/video/tests/test_obsolescence.py @@ -150,10 +150,8 @@ def test_notify_user_obsolete_video(self): video60 = Video.objects.get(id=3) mail = cmd.notify_user(video60, 60) self.assertEqual(mail, 1) - print( - "---> test_notify_user_obsolete_video of \ - ObsolescenceTestCase: OK" - ) + print("---> test_notify_user_obsolete_video of \ + ObsolescenceTestCase: OK") def test_obsolete_video(self): """Check that videos with deletion date in 7,30 and 60 days will be notified.""" diff --git a/pod/video/views.py b/pod/video/views.py index 1088ae6155..5b12e11075 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -31,7 +31,6 @@ from django.utils import timezone from django.db.models import Sum, Min - # from django.contrib.auth.hashers import check_password from dateutil.parser import parse @@ -755,7 +754,7 @@ def bulk_update(request): if update_action == "fields": # Bulk update fields update_fields = json.loads(request.POST.get("update_fields")) - (result["updated_videos"], fields_errors, status) = bulk_update_fields( + result["updated_videos"], fields_errors, status = bulk_update_fields( request, videos_list, update_fields ) result["fields_errors"] = fields_errors @@ -1892,7 +1891,7 @@ def video_note_form(request, slug): def video_note_form_case(request, params): """Editing/creating a note.""" - (idNote, idCom, note, com) = params + idNote, idCom, note, com = params noteToDisplay, comToDisplay = None, None listNotesCom, dictComments = None, None comToEdit, noteToEdit = None, None @@ -2307,11 +2306,8 @@ def rec_expl_coms(idNote, lComs) -> None: str(_("Content")), ] response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = ( - "attachment; \ - filename=%s_notes_and_comments.csv" - % slug - ) + response["Content-Disposition"] = "attachment; \ + filename=%s_notes_and_comments.csv" % slug df.to_csv( path_or_buf=response, sep="|", diff --git a/pod/video_encode_transcript/Encoding_video.py b/pod/video_encode_transcript/Encoding_video.py index 95ffcfe516..738b646c55 100644 --- a/pod/video_encode_transcript/Encoding_video.py +++ b/pod/video_encode_transcript/Encoding_video.py @@ -265,9 +265,7 @@ def fix_duration(self, input_file) -> None: msg = "--> get_info_video\n" probe_cmd = 'ffprobe -v quiet -show_entries format=duration -hide_banner \ -of default=noprint_wrappers=1:nokey=1 -print_format json -i \ - "{}"'.format( - input_file - ) + "{}"'.format(input_file) info, return_msg = get_info_from_video(probe_cmd) msg += json.dumps(info, indent=2) msg += " \n" From 57d23fa9e7f822a7c5e2863e3bfadde9ee166715 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:16:56 +0100 Subject: [PATCH 03/15] Bump django from 4.2.27 to 4.2.28 (#1399) * Bump django from 4.2.27 to 4.2.28 --- updated-dependencies: - dependency-name: django dependency-version: 4.2.28 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 05eca9d35f..70085aa566 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -r requirements-encode.txt -Django==4.2.27 +Django==4.2.28 Pillow==10.3.0 django-tinymce==4.1.0 django-tagulous==2.1.0 From f7c3f78109bc7df2b5f16d619b3ea3edabeb99ec Mon Sep 17 00:00:00 2001 From: Celine Didier Date: Mon, 9 Feb 2026 14:33:27 +0100 Subject: [PATCH 04/15] Forbid AI enhencement when video isn't encodedFix ai enhancement availability button (#1398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixup. Format code with Black * Forbid Ai enrichment when vidéo isn't encoded --------- Co-authored-by: Céline Didier --- pod/video/templates/videos/link_video_dropdown_menu.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pod/video/templates/videos/link_video_dropdown_menu.html b/pod/video/templates/videos/link_video_dropdown_menu.html index ec462afccf..8911ff226f 100644 --- a/pod/video/templates/videos/link_video_dropdown_menu.html +++ b/pod/video/templates/videos/link_video_dropdown_menu.html @@ -21,7 +21,7 @@ {% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video %} {% user_can_enhance_video video as can_enhance_video_with_ai %} {% enhancement_is_already_asked video as enr_is_already_asked %} - {% if can_enhance_video_with_ai and not enr_is_already_asked %} + {% if can_enhance_video_with_ai and not enr_is_already_asked and video.encoded and video.encoding_in_progress is False %}
  • Date: Thu, 12 Feb 2026 10:37:50 +0100 Subject: [PATCH 05/15] Bump pillow from 10.3.0 to 12.1.1 (#1402) * Bump pillow from 10.3.0 to 12.1.1 * Bump minimal Python version to 3.10 Pillow 12 requires python >=3.10 * Remove Python 3.9 from supported versions. --- updated-dependencies: - dependency-name: pillow dependency-version: 12.1.1 dependency-type: direct:production --------- Co-authored-by: Olivier Bado-Faustin <12731381+Badatos@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .env.dev-exemple | 2 +- .github/workflows/pod_dev.yml | 4 +-- CONFIGURATION_FR.md | 30 ++++++++++--------- dockerfile-dev-with-volumes/README.adoc | 2 +- .../pod-back/Dockerfile | 2 +- .../pod-encode/Dockerfile | 2 +- .../pod-transcript/Dockerfile | 2 +- .../pod-xapi/Dockerfile | 2 +- dockerfile-dev-with-volumes/pod/Dockerfile | 2 +- pod/main/configuration.json | 4 ++- requirements.txt | 2 +- 11 files changed, 29 insertions(+), 25 deletions(-) diff --git a/.env.dev-exemple b/.env.dev-exemple index e2d88c1b02..cb7b19c3f5 100644 --- a/.env.dev-exemple +++ b/.env.dev-exemple @@ -4,7 +4,7 @@ DJANGO_SUPERUSER_EMAIL= ### You can use internal registry ELASTICSEARCH_TAG=elasticsearch:8.17.3 NODE_TAG=node:23 -PYTHON_TAG=python:3.9-bookworm +PYTHON_TAG=python:3.12-bookworm REDIS_TAG=redis:alpine3.21 ### DOCKER_ENV: You can specify light or full. ### In case of value changing, you have to rebuild and restart your container. diff --git a/.github/workflows/pod_dev.yml b/.github/workflows/pod_dev.yml index 77ff1a5c0f..6f2e66c56b 100644 --- a/.github/workflows/pod_dev.yml +++ b/.github/workflows/pod_dev.yml @@ -14,14 +14,14 @@ on: workflow_dispatch: env: - PYTHON_VERSION: '3.9' + PYTHON_VERSION: '3.10' DJANGO_SUPERUSER_USERNAME: "admin" DJANGO_SUPERUSER_PASSWORD: "passwd" DJANGO_SUPERUSER_EMAIL: "noreply@uni.fr" ELASTICSEARCH_TAG: "elasticsearch:8.17.3" NODE_VERSION: "23" NODE_TAG: "node:23" - PYTHON_TAG: "python:3.9-bullseye" + PYTHON_TAG: "python:3.10-bookworm" REDIS_TAG: "redis:alpine3.21" DOCKER_ENV: "full-test" diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index de48f3d6ee..3965982da4 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -3,7 +3,9 @@ ## Informations générales La plateforme Esup-Pod se base sur le framework Django écrit en Python.
    -Elle est compatible avec les versions 3.9, 3.10 et 3.12 de Python.
    +Elle est compatible avec les versions 3.10 et 3.12 de Python.
    + +> Attention : à partir d’Esup-Pod version 4.2, la version 3.9 de Python n’est plus supportée. **Django Version : 4.2 LTS**
    @@ -629,32 +631,32 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> TEMPLATE_VISIBLE_SETTINGS = { >> # Titre du site. >> 'TITLE_SITE': 'Pod', - >> + >> >> # Description du site. >> 'DESC_SITE': 'L’objectif d’Esup-Pod est de faciliter la mise à disposition >> de vidéos et ainsi d’encourager son utilisation dans l’enseignement et la recherche.', - >> + >> >> # Titre de l’établissement. >> 'TITLE_ETB': 'University name', - >> + >> >> # Logo affiché en haut à gauche sur toutes les pages. >> # Doit se situer dans le répertoire static >> 'LOGO_SITE': 'img/logoPod.svg', - >> + >> >> # Logo affiché dans le footer sur toutes les pages. >> # Doit se situer dans le répertoire static >> 'LOGO_ETB': 'img/esup-pod.svg', - >> + >> >> # Logo affiché sur le player video. >> # Doit se situer dans le répertoire static >> 'LOGO_PLAYER': 'img/pod_favicon.svg', - >> + >> >> # Lien de destination du logo affiché sur le player. >> 'LINK_PLAYER': '', - >> + >> >> # Intitulé de la page de redirection du logo affiché sur le player. >> 'LINK_PLAYER_NAME': _('Home'), - >> + >> >> # Texte affiché dans le footer. Une ligne par entrée, accepte du code html. >> # Par exemple : >> # ( '42, rue Paul Duez', @@ -662,15 +664,15 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> # ('
    > # ' target="_blank">Google maps') ) >> 'FOOTER_TEXT': ('',), - >> + >> >> # Icone affichée dans la barre d'adresse du navigateur >> 'FAVICON': 'img/pod_favicon.svg', - >> + >> >> # Si souhaitée, à créer et sauvegarder >> # dans le répertoire static de l’application custom et >> # préciser le chemin d’accès. Par exemple : "custom/etab.css" >> 'CSS_OVERRIDE': '', - >> + >> >> # Vous pouvez créer un template dans votre application custom et >> # indiquer son chemin dans cette variable pour que ce code html, >> # ce template soit affiché en haut de votre page, le code est ajouté @@ -679,11 +681,11 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> # '/opt/django_projects/podv4/pod/custom/templates/custom/preheader.html' >> # alors la variable doit prendre la valeur 'custom/preheader.html' >> 'PRE_HEADER_TEMPLATE': '', - >> + >> >> # Idem que pre-header, le code contenu dans le template >> # sera affiché juste avant la fermeture du body. (Or iframe) >> 'POST_FOOTER_TEMPLATE': '', - >> + >> >> # vous pouvez créer un template dans votre application custom >> # pour y intégrer votre code Piwik ou Google analytics. >> # Ce template est inséré dans toutes les pages de la plateforme, diff --git a/dockerfile-dev-with-volumes/README.adoc b/dockerfile-dev-with-volumes/README.adoc index 69c0cdaab7..37ff735344 100755 --- a/dockerfile-dev-with-volumes/README.adoc +++ b/dockerfile-dev-with-volumes/README.adoc @@ -52,7 +52,7 @@ DJANGO_SUPERUSER_PASSWORD= DJANGO_SUPERUSER_EMAIL= ELASTICSEARCH_TAG=elasticsearch:8.17.3 NODE_TAG=node:23 -PYTHON_TAG=python:3.9-bookworm +PYTHON_TAG=python:3.12-bookworm REDIS_TAG=redis:alpine3.21 DOCKER_ENV=light ---- diff --git a/dockerfile-dev-with-volumes/pod-back/Dockerfile b/dockerfile-dev-with-volumes/pod-back/Dockerfile index 5309c95fa1..2364b10281 100755 --- a/dockerfile-dev-with-volumes/pod-back/Dockerfile +++ b/dockerfile-dev-with-volumes/pod-back/Dockerfile @@ -5,7 +5,7 @@ #------------------------------------------------------------------------------------------------------------------------------ # Conteneur node ARG NODE_VERSION=23 -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.12 FROM $NODE_VERSION AS source-build-js WORKDIR /tmp/pod diff --git a/dockerfile-dev-with-volumes/pod-encode/Dockerfile b/dockerfile-dev-with-volumes/pod-encode/Dockerfile index b280e5a173..f6365fec5a 100755 --- a/dockerfile-dev-with-volumes/pod-encode/Dockerfile +++ b/dockerfile-dev-with-volumes/pod-encode/Dockerfile @@ -4,7 +4,7 @@ # (")_(") #------------------------------------------------------------------------------------------------------------------------------ -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.12 #------------------------------------------------------------------------------------------------------------------------------ # Conteneur python diff --git a/dockerfile-dev-with-volumes/pod-transcript/Dockerfile b/dockerfile-dev-with-volumes/pod-transcript/Dockerfile index 355be67e21..defef2725e 100755 --- a/dockerfile-dev-with-volumes/pod-transcript/Dockerfile +++ b/dockerfile-dev-with-volumes/pod-transcript/Dockerfile @@ -4,7 +4,7 @@ # (")_(") #------------------------------------------------------------------------------------------------------------------------------ # Conteneur node -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.12 #------------------------------------------------------------------------------------------------------------------------------ # Conteneur python diff --git a/dockerfile-dev-with-volumes/pod-xapi/Dockerfile b/dockerfile-dev-with-volumes/pod-xapi/Dockerfile index f8b0f5a4b5..87285e9c1f 100755 --- a/dockerfile-dev-with-volumes/pod-xapi/Dockerfile +++ b/dockerfile-dev-with-volumes/pod-xapi/Dockerfile @@ -4,7 +4,7 @@ # (")_(") #------------------------------------------------------------------------------------------------------------------------------ # Conteneur node -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.12 #------------------------------------------------------------------------------------------------------------------------------ # Conteneur python diff --git a/dockerfile-dev-with-volumes/pod/Dockerfile b/dockerfile-dev-with-volumes/pod/Dockerfile index b997956c2d..0db4832ffd 100755 --- a/dockerfile-dev-with-volumes/pod/Dockerfile +++ b/dockerfile-dev-with-volumes/pod/Dockerfile @@ -5,7 +5,7 @@ #------------------------------------------------------------------------------------------------------------------------------ # Conteneur node ARG NODE_VERSION=23 -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.12 FROM $NODE_VERSION AS source-build-js WORKDIR /tmp/pod diff --git a/pod/main/configuration.json b/pod/main/configuration.json index d3d23548ea..be0c5b8938 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -5765,7 +5765,9 @@ "fr": [ "", "La plateforme Esup-Pod se base sur le framework Django écrit en Python.", - "Elle est compatible avec les versions 3.9, 3.10 et 3.12 de Python.", + "Elle est compatible avec les versions 3.10 et 3.12 de Python.", + "> Attention : à partir d’Esup-Pod version 4.2,", + " la version 3.9 de Python n’est plus supportée.", "", "**Django Version : 4.2 LTS**", "", diff --git a/requirements.txt b/requirements.txt index 70085aa566..67cbb5ad73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -r requirements-encode.txt Django==4.2.28 -Pillow==10.3.0 +Pillow==12.1.1 django-tinymce==4.1.0 django-tagulous==2.1.0 django-modeltranslation==0.19.11 From fe69b2ede561ac5be158a1273a5e21d7f33c59cf Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 12 Feb 2026 09:39:14 +0000 Subject: [PATCH 06/15] Auto-update configuration files --- CONFIGURATION_FR.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index 3965982da4..6080ad2e12 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -4,8 +4,8 @@ La plateforme Esup-Pod se base sur le framework Django écrit en Python.
    Elle est compatible avec les versions 3.10 et 3.12 de Python.
    - -> Attention : à partir d’Esup-Pod version 4.2, la version 3.9 de Python n’est plus supportée. +> Attention : à partir d’Esup-Pod version 4.2,
    + la version 3.9 de Python n’est plus supportée.
    **Django Version : 4.2 LTS**
    @@ -631,32 +631,32 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> TEMPLATE_VISIBLE_SETTINGS = { >> # Titre du site. >> 'TITLE_SITE': 'Pod', - >> + >> >> # Description du site. >> 'DESC_SITE': 'L’objectif d’Esup-Pod est de faciliter la mise à disposition >> de vidéos et ainsi d’encourager son utilisation dans l’enseignement et la recherche.', - >> + >> >> # Titre de l’établissement. >> 'TITLE_ETB': 'University name', - >> + >> >> # Logo affiché en haut à gauche sur toutes les pages. >> # Doit se situer dans le répertoire static >> 'LOGO_SITE': 'img/logoPod.svg', - >> + >> >> # Logo affiché dans le footer sur toutes les pages. >> # Doit se situer dans le répertoire static >> 'LOGO_ETB': 'img/esup-pod.svg', - >> + >> >> # Logo affiché sur le player video. >> # Doit se situer dans le répertoire static >> 'LOGO_PLAYER': 'img/pod_favicon.svg', - >> + >> >> # Lien de destination du logo affiché sur le player. >> 'LINK_PLAYER': '', - >> + >> >> # Intitulé de la page de redirection du logo affiché sur le player. >> 'LINK_PLAYER_NAME': _('Home'), - >> + >> >> # Texte affiché dans le footer. Une ligne par entrée, accepte du code html. >> # Par exemple : >> # ( '42, rue Paul Duez', @@ -664,15 +664,15 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> # ('> # ' target="_blank">Google maps') ) >> 'FOOTER_TEXT': ('',), - >> + >> >> # Icone affichée dans la barre d'adresse du navigateur >> 'FAVICON': 'img/pod_favicon.svg', - >> + >> >> # Si souhaitée, à créer et sauvegarder >> # dans le répertoire static de l’application custom et >> # préciser le chemin d’accès. Par exemple : "custom/etab.css" >> 'CSS_OVERRIDE': '', - >> + >> >> # Vous pouvez créer un template dans votre application custom et >> # indiquer son chemin dans cette variable pour que ce code html, >> # ce template soit affiché en haut de votre page, le code est ajouté @@ -681,11 +681,11 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> # '/opt/django_projects/podv4/pod/custom/templates/custom/preheader.html' >> # alors la variable doit prendre la valeur 'custom/preheader.html' >> 'PRE_HEADER_TEMPLATE': '', - >> + >> >> # Idem que pre-header, le code contenu dans le template >> # sera affiché juste avant la fermeture du body. (Or iframe) >> 'POST_FOOTER_TEMPLATE': '', - >> + >> >> # vous pouvez créer un template dans votre application custom >> # pour y intégrer votre code Piwik ou Google analytics. >> # Ce template est inséré dans toutes les pages de la plateforme, From 7506dd15c091154504c96046ec1ed39aabaa8ecd Mon Sep 17 00:00:00 2001 From: fanfounet Date: Fri, 13 Feb 2026 14:59:30 +0100 Subject: [PATCH 07/15] bypass mp4 service worker (#1401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lorsque que l'on souhaite prévisualiser une vidéo dans la liste "revendiquer un enregistrement", et si la vidéo est un peu volumineuse, la prévisualisation ne démarre pas. Le service worker empêche de démarrer la lecture avec un rechargement partiel. => Solution : empêcher le cache du service worker sur les fichier mp4 --------- Co-authored-by: Charneau Franck --- pod/progressive_web_app/static/js/serviceworker.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pod/progressive_web_app/static/js/serviceworker.js b/pod/progressive_web_app/static/js/serviceworker.js index f48e338144..2ac241f241 100644 --- a/pod/progressive_web_app/static/js/serviceworker.js +++ b/pod/progressive_web_app/static/js/serviceworker.js @@ -32,6 +32,12 @@ self.addEventListener("activate", (event) => { // Serve from Cache self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + // In the context of using the recordings to be claimed, it allows bypassing mp4 video files + if (event.request.destination === "video" || url.pathname.endsWith(".mp4")) { + return; + } + event.respondWith( caches .match(event.request) From 2b6d4315dd06f0a9a5405955ee490a2a81505f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20B=2E?= <56730254+LoicBonavent@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:57:25 +0100 Subject: [PATCH 08/15] Runner Manager support (#1403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR introduces Runner Manager support for video encoding/transcription workflows, with queue management, webhook result ingestion, admin tooling, and related UI/API/documentation updates. ## Motivation The goal is to support a robust outsourced processing pipeline ([Esup-Runner](https://github.com/EsupPortail/esup-runner)) while keeping Pod task state synchronized, observable, and recoverable. ## Main Changes - Added new configuration support and documentation for: - `USE_RUNNER_MANAGER` - `RM_TASKS_DELETED_AFTER_DAYS` - Added Runner Manager domain models: - `RunnerManager` - `Task` - Added Runner Manager orchestration modules: - task dispatch and fallback between managers - round-robin behavior inside same-priority groups - pending task ranking utilities - remote artifact import/finalization helpers - Added periodic management command: - `process_tasks` (designed to run via cron) - checks stalled tasks, submits pending tasks, refreshes ranks, cleans old completed tasks - Added callback endpoint and integration: - `/runner/notify_task_end/` - secure bearer-token validation - result download/import for encoding, studio, and transcription outputs - Updated encoding/transcription entry points to delegate to Runner Manager when enabled. - Updated cut flow to queue encoding (`launch_encode`) instead of forcing immediate start. - Added admin features: - Runner Manager “Test connection” action - Task relaunch admin action - Recorder admin action to encode selected studio recordings - Added queue visibility in video templates (rank and total pending). - Added/updated tests for webhook auth, admin connectivity, round-robin ordering, queue ranking, studio integration, and helper utilities. - Added minor maintenance updates (typing/import cleanup). - Updated video API filtering to include `additional_owners`. ## Operational Notes - No functional change unless `USE_RUNNER_MANAGER=True`. - `process_tasks` must be scheduled periodically for queue processing. - Runner Manager URL/token must be configured for each site. --- pod/cut/views.py | 23 +- pod/dressing/utils.py | 4 +- pod/locale/fr/LC_MESSAGES/django.mo | Bin 224897 -> 0 bytes pod/locale/fr/LC_MESSAGES/django.po | 286 +++++- pod/locale/fr/LC_MESSAGES/djangojs.mo | Bin 23001 -> 0 bytes pod/locale/nl/LC_MESSAGES/django.po | 243 ++++- pod/main/configuration.json | 45 +- pod/package.json | 3 +- pod/recorder/admin.py | 72 +- pod/recorder/plugins/type_video.py | 7 +- pod/urls.py | 33 +- pod/video/rest_views.py | 23 +- pod/video/templates/videos/video_edit.html | 3 + .../templates/videos/video_page_content.html | 3 + .../videos/video_queue_runner_manager.html | 24 + pod/video/views.py | 333 +++++-- pod/video_encode_transcript/Encoding_video.py | 200 ++-- .../Encoding_video_model.py | 53 +- pod/video_encode_transcript/admin.py | 330 ++++++- pod/video_encode_transcript/encode.py | 96 +- .../encoding_studio.py | 31 +- pod/video_encode_transcript/encoding_tasks.py | 3 +- pod/video_encode_transcript/encoding_utils.py | 11 +- .../commands/import_encode_video.py | 11 +- .../management/commands/process_tasks.py | 929 ++++++++++++++++++ .../commands/test_encode_transcript.py | 25 +- pod/video_encode_transcript/models.py | 211 +++- pod/video_encode_transcript/rest_views.py | 32 +- pod/video_encode_transcript/runner_manager.py | 702 +++++++++++++ .../runner_manager_utils.py | 611 ++++++++++++ pod/video_encode_transcript/task_queue.py | 91 ++ .../templates/admin_test_connection.html | 44 + .../tests/test_encode.py | 54 +- .../tests/test_notify_task_end.py | 88 ++ .../tests/test_remote_encode_transcode.py | 55 +- .../tests/test_runner_manager_admin.py | 123 +++ .../tests/test_runner_manager_round_robin.py | 112 +++ .../tests/test_studio_integration.py | 84 ++ .../tests/test_task_queue.py | 140 +++ .../tests/test_transcription_language.py | 57 ++ .../tests/test_utils.py | 7 +- .../tests/test_views_helpers.py | 335 +++++++ pod/video_encode_transcript/transcript.py | 96 +- .../transcript_model.py | 33 +- .../transcripting_tasks.py | 6 +- pod/video_encode_transcript/urls.py | 11 + pod/video_encode_transcript/utils.py | 20 +- pod/video_encode_transcript/views.py | 894 +++++++++++++++++ 48 files changed, 6055 insertions(+), 542 deletions(-) delete mode 100644 pod/locale/fr/LC_MESSAGES/django.mo delete mode 100644 pod/locale/fr/LC_MESSAGES/djangojs.mo create mode 100644 pod/video/templates/videos/video_queue_runner_manager.html create mode 100644 pod/video_encode_transcript/management/commands/process_tasks.py create mode 100644 pod/video_encode_transcript/runner_manager.py create mode 100644 pod/video_encode_transcript/runner_manager_utils.py create mode 100644 pod/video_encode_transcript/task_queue.py create mode 100644 pod/video_encode_transcript/templates/admin_test_connection.html create mode 100644 pod/video_encode_transcript/tests/test_notify_task_end.py create mode 100644 pod/video_encode_transcript/tests/test_runner_manager_admin.py create mode 100644 pod/video_encode_transcript/tests/test_runner_manager_round_robin.py create mode 100644 pod/video_encode_transcript/tests/test_studio_integration.py create mode 100644 pod/video_encode_transcript/tests/test_task_queue.py create mode 100644 pod/video_encode_transcript/tests/test_transcription_language.py create mode 100644 pod/video_encode_transcript/tests/test_views_helpers.py create mode 100644 pod/video_encode_transcript/urls.py create mode 100644 pod/video_encode_transcript/views.py diff --git a/pod/cut/views.py b/pod/cut/views.py index 5ddb985d45..00f5b8380d 100644 --- a/pod/cut/views.py +++ b/pod/cut/views.py @@ -1,27 +1,23 @@ """Esup-Pod video cutting app views.""" -from django.shortcuts import render -from pod.main.views import in_maintenance -from django.shortcuts import redirect -from django.urls import reverse -from django.shortcuts import get_object_or_404 -from django.contrib.sites.shortcuts import get_current_site -from django.contrib.auth.decorators import login_required -from django.utils.translation import gettext as _ +from django.conf import settings from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import PermissionDenied from django.http import QueryDict +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_protect -from pod.video_encode_transcript.encode import start_encode +from pod.main.views import in_maintenance from pod.video.models import Video -from .models import CutVideo from .forms import CutVideoForm +from .models import CutVideo from .utils import clean_database -from django.conf import settings - RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY", False ) @@ -77,7 +73,8 @@ def cut_video(request, slug): clean_database(video.id) - start_encode(video.id) + video.launch_encode = True + video.save() messages.add_message(request, messages.SUCCESS, _("The cut was made.")) return redirect(reverse("video:dashboard")) diff --git a/pod/dressing/utils.py b/pod/dressing/utils.py index c5340e0014..757e053e7c 100644 --- a/pod/dressing/utils.py +++ b/pod/dressing/utils.py @@ -3,7 +3,7 @@ import os from .models import Dressing from django.conf import settings -from django.db.models import Q +from django.db.models import Q, QuerySet from django.core.handlers.wsgi import WSGIRequest @@ -34,7 +34,7 @@ def get_dressing_input(dressing: Dressing, ffmpeg_dressing_input: str) -> str: return command -def get_dressings(user, accessgroup_set) -> list: +def get_dressings(user, accessgroup_set) -> QuerySet[Dressing]: """ Return the list of dressings that the user can use. diff --git a/pod/locale/fr/LC_MESSAGES/django.mo b/pod/locale/fr/LC_MESSAGES/django.mo deleted file mode 100644 index 64599674ebcea67811f98da179de6c871fc10ed0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224897 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 rD7Q*e_v;RH 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 eG3y\n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -404,7 +404,8 @@ msgstr "" #: pod/main/views.py pod/playlist/views.py pod/video/feeds.py #: pod/video/management/commands/check_obsolete_videos.py pod/video/models.py #: pod/video/templates/videos/video-script.html pod/video/utils.py -#: pod/video/views.py pod/video_encode_transcript/utils.py +#: pod/video/views.py pod/video_encode_transcript/runner_manager.py +#: pod/video_encode_transcript/utils.py msgid "Home" msgstr "Accueil" @@ -432,9 +433,9 @@ msgid "" "Something went wrong with AI improvement on “%(content_title)s”. Suggestions " "for improvement can’t be available on %(site_title)s." msgstr "" -"Une erreur s’est produite sur le traitement par l’IA de " -"« %(content_title)s ». Les suggestions d’amélioration ne peuvent pas être " -"disponibles sur %(site_title)s." +"Une erreur s’est produite sur le traitement par l’IA de « %(content_title)s " +"». Les suggestions d’amélioration ne peuvent pas être disponibles sur " +"%(site_title)s." #: pod/ai_enhancement/utils.py #, python-format @@ -456,8 +457,8 @@ msgid "" "Something went wrong with AI improvement on “%(content_title)s” on " "%(site_title)s." msgstr "" -"Une erreur s’est produite sur l’amélioration par l’IA de " -"« %(content_title)s » sur %(site_title)s." +"Une erreur s’est produite sur l’amélioration par l’IA de « %(content_title)s " +"» sur %(site_title)s." #: pod/ai_enhancement/utils.py pod/live/utils.py pod/meeting/utils.py #: pod/video_encode_transcript/utils.py @@ -1351,6 +1352,11 @@ msgstr "" "Il y a un chevauchement avec la superposition {0}, veuillez changer son " "temps de début et/ou de fin." +#: pod/completion/permissions/video.py +#, python-brace-format +msgid "{func.__name__}: Permission denied for user {current_user.pk}." +msgstr "{func.__name__} : Permission refusée pour l’utilisateur {current_user.pk}." + #: pod/completion/templates/contributor/form_contributor.html #: pod/completion/templates/document/form_document.html #: pod/completion/templates/overlay/form_overlay.html @@ -1728,8 +1734,8 @@ msgstr "" #: pod/completion/templates/video_completion.html msgid "Subtitle and/or caption file(s) must be in “.vtt” format." msgstr "" -"Les fichiers de sous-titres et/ou de légendes doivent être au format " -"« .vtt »." +"Les fichiers de sous-titres et/ou de légendes doivent être au format « ." +"vtt »." #: pod/completion/templates/video_completion.html msgid "You can use" @@ -2626,7 +2632,7 @@ msgid "User who create this recording" msgstr "Utilisateur qui a créé cet enregistrement" #: pod/import_video/models.py pod/meeting/models.py pod/playlist/models.py -#: pod/video/models.py +#: pod/video/models.py pod/video_encode_transcript/models.py msgid "Site" msgstr "Site" @@ -6073,7 +6079,7 @@ msgstr "Exemple de format : %(url)s" #: pod/meeting/models.py msgid "Bearer token for the SIPMediaGW server." -msgstr "Jeton Bearer pour le serveur SIPMediaGW." +msgstr "Jeton porteur pour le serveur SIPMediaGW." #: pod/meeting/models.py msgid "Example format: 1234" @@ -6402,8 +6408,7 @@ msgstr "La réunion n’a pas encore commencé." msgid "" "It is scheduled for %(start_date)s at %(start_time)s." msgstr "" -"Elle est planifiée le %(start_date)s à %(start_time)s." +"Elle est planifiée le %(start_date)s à %(start_time)s." #: pod/meeting/templates/meeting/join.html msgid "You will be redirected right after the meeting starts." @@ -6578,6 +6583,7 @@ msgid "Has user joined?" msgstr "Utilisateurs connectés ?" #: pod/meeting/views.py pod/recorder/models.py +#: pod/video_encode_transcript/models.py msgid "Recording" msgstr "Enregistrement" @@ -6984,6 +6990,15 @@ msgstr "Protégé par un mot de passe" msgid "Private" msgstr "Privé" +#: pod/playlist/models.py +#, python-brace-format +msgid "" +"Please choose a title between 1 and {__MAX_LENGTH_FOR_PLAYLIST_NAME__} " +"characters." +msgstr "" +"Veuillez entrer un titre contenant entre 1 et " +"{__MAX_LENGTH_FOR_PLAYLIST_NAME__} caractères." + #: pod/playlist/models.py msgid "" "Selecting this setting causes your playlist to be promoted on the page " @@ -7382,10 +7397,6 @@ msgstr "Vous ne pouvez pas accéder à cette liste de lecture." msgid "The playlist has been deleted." msgstr "La liste de lecture a été supprimée." -#: pod/playlist/views.py -msgid "Delete the playlist" -msgstr "Supprimer la liste de lecture" - #: pod/playlist/views.py #, python-format msgid "Edit the playlist “%(pname)s”" @@ -7786,7 +7797,7 @@ msgstr "Veuillez choisir si les réponses correctes seront affichées ou non." msgid "Quizzes" msgstr "Quiz" -#: pod/quiz/models.py pod/quiz/tests/test_models.py +#: pod/quiz/models.py msgid "Quiz of video" msgstr "Quiz de la vidéo" @@ -8106,6 +8117,27 @@ msgstr "Vous ne pouvez pas éditer ce quiz." msgid "Quiz edition for the video “%s”" msgstr "Modification du quiz pour la vidéo « %s »" +#: pod/recorder/admin.py +msgid "Encode selected recordings and create new video" +msgstr "Encoder les enregistrements sélectionnés et créer une nouvelle vidéo" + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Studio recording {item.id} encoding started" +msgstr "Encodage de l’enregistrement en studio {item.id} démarré" + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Recording {item.id} is not a studio recording and can’t be encoded" +msgstr "" +"L’enregistrement {item.id} n’est pas un enregistrement studio et ne peut pas " +"être encodé." + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Error for {item}: {e}" +msgstr "Erreur pour {item} : {e}" + #: pod/recorder/admin.py msgid "Delete selected Recording file treatments + source files" msgstr "" @@ -8376,6 +8408,7 @@ msgstr "Taille du fichier" #: pod/video/templates/videos/dashboard.html #: pod/video/templates/videos/video_row_select.html #: pod/video/templates/videos/video_sort_select.html +#: pod/video_encode_transcript/models.py msgid "Date added" msgstr "Date d’ajout" @@ -10592,6 +10625,13 @@ msgstr "La vidéo est actuellement en attente du traitement par l’IA Aristote. msgid "The form does not contain a video." msgstr "La fiche ne contient pas de vidéo." +#: pod/video/templates/videos/video_queue_runner_manager.html +msgid "Queue rank:" +msgstr "Rang dans la file d’attente :" + +msgid "Total number of videos in the queue:" +msgstr "Nombre total de vidéos dans la file d’attente :" + #: pod/video/templates/videos/video_row_select.html msgid "Selected" msgstr "Sélectionné" @@ -10950,14 +10990,73 @@ msgstr "Erreur serveur lors de la récupération des vidéos de l’utilisateur. msgid "Server error while processing filter." msgstr "Erreur serveur lors du traitement du filtre." +#: pod/video_encode_transcript/admin.py pod/video_encode_transcript/models.py +msgid "resolution" +msgstr "résolution" + +#: pod/video_encode_transcript/admin.py +msgid "Runner manager not found." +msgstr "Runner manager non trouvé." + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Unable to reach runner manager '%(name)s' at %(url)s. Check the URL and " +"network access. Error: %(error)s" +msgstr "" +"Impossible de joindre le runner manager « %(name)s » à l’adresse %(url)s. " +"Vérifiez l’URL et l’accès au réseau. Erreur : %(error)s" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' responded but rejected authentication (HTTP " +"%(status)s). Check the bearer token." +msgstr "" +"Le runner manager « %(name)s » a répondu mais a rejeté l’authentification (HTTP " +"%(status)s). Vérifiez le jeton porteur." + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "Connection to runner manager '%(name)s' succeeded (HTTP %(status)s)." +msgstr "Connexion au runner manager « %(name)s » réussie (HTTP %(status)s)." + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' is reachable but endpoint %(url)s was not found " +"(HTTP 404). Check the configured URL." +msgstr "" +"Le runner manager « %(name)s » est accessible, mais le point de terminaison %(url)s est introuvable " +"(HTTP 404). Vérifiez l’URL configurée." + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' is reachable but returned an unexpected response " +"(HTTP %(status)s)." +msgstr "" +"Le runner manager « %(name)s » est accessible, mais a renvoyé une réponse inattendue " +"(HTTP %(status)s)." + +#: pod/video_encode_transcript/admin.py +msgid "Restart selected tasks" +msgstr "Redémarrer les tâches sélectionnées" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "%(count)s task(s) relaunched immediately." +msgstr "%(count)s tâche(s) relancée(s) immédiatement." + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "%(count)s task(s) skipped (duplicate or missing source)." +msgstr "%(count)s tâche(s) ignorée(s) (duplicata ou source manquante)." + #: pod/video_encode_transcript/apps.py msgid "Video encoding and transcription" msgstr "Encodage et transcription de vidéos" -#: pod/video_encode_transcript/models.py -msgid "resolution" -msgstr "résolution" - #: pod/video_encode_transcript/models.py msgid "" "Please use the only format x. i.e.: 640x360 or 1280x720 or " @@ -11058,6 +11157,136 @@ msgstr "Liste de lecture" msgid "Video Playlists" msgstr "Listes de lecture" +#: pod/video_encode_transcript/models.py +msgid "Runner manager name" +msgstr "Nom du runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Priority" +msgstr "Priorité" + +#: pod/video_encode_transcript/models.py +msgid "Priority of the runner manager. Lower values indicate higher priority." +msgstr "" +"Priorité du runner manager. Les valeurs les plus basses indiquent une " +"priorité plus élevée." + +#: pod/video_encode_transcript/models.py +msgid "URL of the runner manager" +msgstr "URL du runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Example format: https://manager.univ.fr:port/" +msgstr "Exemple de format : https://manager.univ.fr:port/" + +#: pod/video_encode_transcript/models.py +msgid "Bearer token for the runner manager." +msgstr "Jeton porteur pour le runner manager." + +#: pod/video_encode_transcript/models.py +msgid "Example format: 6YqG_73xt-9s8v5aBz" +msgstr "Exemple de format : 6YqG_73xt-9s8v5aBz" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager" +msgstr "Runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Runner managers" +msgstr "Runner managers" + +#: pod/video_encode_transcript/models.py +msgid "Encoding task" +msgstr "Tâche d’encodage" + +#: pod/video_encode_transcript/models.py +msgid "Studio task" +msgstr "Tâche Studio" + +#: pod/video_encode_transcript/models.py +msgid "Transcription task" +msgstr "Tâche de transcription" + +#: pod/video_encode_transcript/models.py +msgid "Task type" +msgstr "Type de tâche" + +#: pod/video_encode_transcript/models.py +msgid "Task pending" +msgstr "Tâche en attente" + +#: pod/video_encode_transcript/models.py +msgid "Task in progress" +msgstr "Tâche en cours" + +#: pod/video_encode_transcript/models.py +msgid "Task completed" +msgstr "Tâche terminée" + +#: pod/video_encode_transcript/models.py +msgid "Task failed" +msgstr "Tâche échouée" + +#: pod/video_encode_transcript/models.py +msgid "Task timeouted" +msgstr "Tâche expirée" + +#: pod/video_encode_transcript/models.py +msgid "Task status" +msgstr "Statut de la tâche" + +#: pod/video_encode_transcript/models.py +msgid "Task identifier from runner manager" +msgstr "Identifiant de tâche du runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Identifier of the task provided by the runner manager" +msgstr "Identifiant de la tâche fourni par le runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Video associated to the task" +msgstr "Vidéo associée à la tâche" + +#: pod/video_encode_transcript/models.py +msgid "Studio recording associated to the task" +msgstr "Enregistrement studio associé à la tâche" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager that manages this task" +msgstr "Runner manager qui gère cette tâche" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager that achieves this task" +msgstr "Runner manager qui accomplit cette tâche" + +#: pod/video_encode_transcript/models.py +msgid "Queue rank" +msgstr "Rang dans la file d’attente" + +#: pod/video_encode_transcript/models.py +msgid "Rank of the task in the pending queue" +msgstr "Rang de la tâche dans la file d’attente" + +#: pod/video_encode_transcript/models.py +msgid "Script output" +msgstr "Sortie du script" + +#: pod/video_encode_transcript/models.py +msgid "Output from the runner manager script" +msgstr "Sortie du script du runner manager" + +#: pod/video_encode_transcript/models.py +msgid "Task" +msgstr "Tâche" + +#: pod/video_encode_transcript/models.py +msgid "Tasks" +msgstr "Tâches" + +#: pod/video_encode_transcript/templates/admin_test_connection.html +msgid "Test connection" +msgstr "Tester la connexion" + #: pod/video_encode_transcript/utils.py msgid "Encoding" msgstr "Encodage" @@ -11127,9 +11356,9 @@ msgstr "Résultats de la recherche" msgid "Esup-Pod xAPI" msgstr "xAPI Esup-Pod" -#, fuzzy, python-format -#~| msgid "%(counter)s video found" -#~| msgid_plural "%(counter)s videos found" +#~ msgid "Delete the playlist" +#~ msgstr "Supprimer la liste de lecture" + #~ msgid "" #~ "\n" #~ " %(counter)s video found\n" @@ -11147,13 +11376,6 @@ msgstr "xAPI Esup-Pod" #~ msgid "Search by title" #~ msgstr "Rechercher par titre" -#~ msgid "" -#~ "Please choose a title between 1 and {__MAX_LENGTH_FOR_PLAYLIST_NAME__} " -#~ "characters." -#~ msgstr "" -#~ "Veuillez entrer un titre contenant entre 1 et " -#~ "{__MAX_LENGTH_FOR_PLAYLIST_NAME__} caractères." - #~ msgid "Clear selection" #~ msgstr "Effacer la sélection" diff --git a/pod/locale/fr/LC_MESSAGES/djangojs.mo b/pod/locale/fr/LC_MESSAGES/djangojs.mo deleted file mode 100644 index 04a0ee62a55a9c97893d46605b77cb441d4875f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23001 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 vN%GA(SVvG2RQ3q\n" "Language-Team: \n" @@ -391,7 +391,8 @@ msgstr "" #: pod/main/views.py pod/playlist/views.py pod/video/feeds.py #: pod/video/management/commands/check_obsolete_videos.py pod/video/models.py #: pod/video/templates/videos/video-script.html pod/video/utils.py -#: pod/video/views.py pod/video_encode_transcript/utils.py +#: pod/video/views.py pod/video_encode_transcript/runner_manager.py +#: pod/video_encode_transcript/utils.py msgid "Home" msgstr "" @@ -2514,7 +2515,7 @@ msgid "User who create this recording" msgstr "" #: pod/import_video/models.py pod/meeting/models.py pod/playlist/models.py -#: pod/video/models.py +#: pod/video/models.py pod/video_encode_transcript/models.py msgid "Site" msgstr "" @@ -6212,6 +6213,7 @@ msgid "Has user joined?" msgstr "" #: pod/meeting/views.py pod/recorder/models.py +#: pod/video_encode_transcript/models.py msgid "Recording" msgstr "" @@ -6755,6 +6757,7 @@ msgstr "" #: pod/playlist/templates/playlist/playlist.html #: pod/playlist/templates/playlist/playlist_content.html #: pod/video/templates/channel/channel.html +#: pod/video/templates/videos/dashboard.html #: pod/video/templates/videos/videos.html #: pod/video_search/templates/search/search.html #, python-format @@ -7608,6 +7611,25 @@ msgstr "" msgid "Quiz edition for the video “%s”" msgstr "" +#: pod/recorder/admin.py +msgid "Encode selected recordings and create new video" +msgstr "" + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Studio recording {item.id} encoding started" +msgstr "" + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Recording {item.id} is not a studio recording and can't be encoded" +msgstr "" + +#: pod/recorder/admin.py +#, python-brace-format +msgid "Error for {item}: {e}" +msgstr "" + #: pod/recorder/admin.py msgid "Delete selected Recording file treatments + source files" msgstr "" @@ -7856,6 +7878,7 @@ msgstr "" #: pod/video/templates/videos/dashboard.html #: pod/video/templates/videos/video_row_select.html #: pod/video/templates/videos/video_sort_select.html +#: pod/video_encode_transcript/models.py msgid "Date added" msgstr "" @@ -9195,7 +9218,7 @@ msgstr "" #: pod/video/templates/videos/change_video_owner.html msgid "Filter by title" -msgstr "Filter op titel" +msgstr "" #: pod/video/templates/videos/change_video_owner.html msgid "Select video(s) to edit" @@ -9211,10 +9234,8 @@ msgid "Submit changes" msgstr "" #: pod/video/templates/videos/dashboard.html -#, fuzzy -#| msgid "Filter by title" msgid "Enter video title" -msgstr "Filter op titel" +msgstr "" #: pod/video/templates/videos/dashboard.html msgid "Use the search field to find a video by its title in your dashboard" @@ -9314,19 +9335,6 @@ msgstr "" msgid "Sort videos" msgstr "" -#: pod/video/templates/videos/dashboard.html -#, python-format -msgid "" -"\n" -" %(counter)s video found\n" -" " -msgid_plural "" -"\n" -" %(counter)s videos found\n" -" " -msgstr[0] "" -msgstr[1] "" - #: pod/video/templates/videos/filter_aside.html msgid "" "The videos on the left are automatically sorted according to the filters " @@ -9912,6 +9920,14 @@ msgstr "" msgid "The form does not contain a video." msgstr "" +#: pod/video/templates/videos/video_queue_runner_manager.html +msgid "Queue rank:" +msgstr "" + +#: pod/video/templates/videos/video_queue_runner_manager.html +msgid "Total number of videos in the queue:" +msgstr "" + #: pod/video/templates/videos/video_row_select.html msgid "Selected" msgstr "" @@ -10259,12 +10275,63 @@ msgstr "" msgid "Server error while processing filter." msgstr "" -#: pod/video_encode_transcript/apps.py -msgid "Video encoding and transcription" +#: pod/video_encode_transcript/admin.py pod/video_encode_transcript/models.py +msgid "resolution" msgstr "" -#: pod/video_encode_transcript/models.py -msgid "resolution" +#: pod/video_encode_transcript/admin.py +msgid "Runner manager not found." +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Unable to reach runner manager '%(name)s' at %(url)s. Check the URL and " +"network access. Error: %(error)s" +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' responded but rejected authentication (HTTP " +"%(status)s). Check the bearer token." +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "Connection to runner manager '%(name)s' succeeded (HTTP %(status)s)." +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' is reachable but endpoint %(url)s was not found " +"(HTTP 404). Check the configured URL." +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "" +"Runner manager '%(name)s' is reachable but returned an unexpected response " +"(HTTP %(status)s)." +msgstr "" + +#: pod/video_encode_transcript/admin.py +msgid "Restart selected tasks" +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "%(count)s task(s) relaunched immediately." +msgstr "" + +#: pod/video_encode_transcript/admin.py +#, python-format +msgid "%(count)s task(s) skipped (duplicate or missing source)." +msgstr "" + +#: pod/video_encode_transcript/apps.py +msgid "Video encoding and transcription" msgstr "" #: pod/video_encode_transcript/models.py @@ -10363,6 +10430,134 @@ msgstr "" msgid "Video Playlists" msgstr "" +#: pod/video_encode_transcript/models.py +msgid "Runner manager name" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Priority" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Priority of the runner manager. Lower values indicate higher priority." +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "URL of the runner manager" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Example format: https://manager.univ.fr:port/" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Bearer token for the runner manager." +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Example format: 6YqG_73xt-9s8v5aBz" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Runner managers" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Encoding task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Studio task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Transcription task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task type" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task pending" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task in progress" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task completed" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task failed" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task timeouted" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task status" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task identifier from runner manager" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Identifier of the task provided by the runner manager" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Video associated to the task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Studio recording associated to the task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager that manages this task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Runner manager that achieves this task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Queue rank" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Rank of the task in the pending queue" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Script output" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Output from the runner manager script" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Task" +msgstr "" + +#: pod/video_encode_transcript/models.py +msgid "Tasks" +msgstr "" + +#: pod/video_encode_transcript/templates/admin_test_connection.html +msgid "Test connection" +msgstr "" + #: pod/video_encode_transcript/utils.py msgid "Encoding" msgstr "Videocodering" diff --git a/pod/main/configuration.json b/pod/main/configuration.json index be0c5b8938..abee22c6b6 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -3436,15 +3436,56 @@ "video_encode_transcript": { "description": { "en": [ - "" + "Application for video encoding and transcription.", + "It is possible to encode locally or remotely.", + "For remote encoding, it is preferable to use the encoding and transcription outsourcing system,", + "Esup-Runner, with the use of runner managers. Otherwise, it is also possible to use Celery in this case." ], "fr": [ "Application pour l’encodage et la transcription de vidéo.", "Il est possible d’encoder en local ou en distant.", - "Attention, il faut configurer Celery pour l’envoi des instructions pour l’encodage distant." + "Pour l’encodage distant, il est préférable d’utiliser le système d’externalisation des encodages et transcriptions, ", + "Esup-Runner, avec utilisation de runner managers. Sinon, il est aussi possible d’utiliser Celery dans ce cas là." ] }, "settings": { + "USE_RUNNER_MANAGER": { + "default_value": false, + "description": { + "en": [ + "If True, Pod uses an outsourcing system for encoding and transcription, ", + "Esup-Runner, with the use of runner managers. Encoding and transcription ", + "are entirely delegated to this system. See https://github.com/EsupPortail/esup-runner.", + "In this case, it is no longer necessary to configure Celery for remote encoding and transcription." + + ], + "fr": [ + "Si True, Pod utilise le système d’externalisation des encodages et transcriptions, ", + "Esup-Runner, avec utilisation de runner managers. Les encodages et transcriptions ", + "sont totalement délégués à ce système. Cf. https://github.com/EsupPortail/esup-runner.", + "Dans ce cas là, il n'est plus utile de configurer Celery pour l’encodage et la transcription à distance." + ] + }, + "pod_version_end": "", + "pod_version_init": "4.2.0" + }, + "RM_TASKS_DELETED_AFTER_DAYS": { + "default_value": 0, + "description": { + "en": [ + "Parameter used only when USE_RUNNER_MANAGER = True.", + "It corresponds to the number of days after which completed tasks are deleted ", + "from the database (0 means that completed tasks are kept indefinitely)." + ], + "fr": [ + "Paramètre utilisé seulement quand USE_RUNNER_MANAGER = True.", + "Il correspond au nombre de jours après lesquels les tâches terminées sont supprimées ", + "dans la base de données (0 signifiant que les tâches terminées sont conservées indéfiniment)." + ] + }, + "pod_version_end": "", + "pod_version_init": "4.2.0" + }, "CAPTIONS_STRICT_ACCESSIBILITY": { "default_value": false, "description": { diff --git a/pod/package.json b/pod/package.json index 82bfed699c..e6fbd5a570 100644 --- a/pod/package.json +++ b/pod/package.json @@ -24,5 +24,6 @@ }, "engines": { "yarn": ">= 1.0.0" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/pod/recorder/admin.py b/pod/recorder/admin.py index 33572e34df..864c7d2a2d 100644 --- a/pod/recorder/admin.py +++ b/pod/recorder/admin.py @@ -1,20 +1,25 @@ """Esup-Pod recorder administration.""" import os + from django.conf import settings -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.contrib.sites.shortcuts import get_current_site +from django.db.models.query import QuerySet +from django.http import HttpRequest from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from .models import Recording, Recorder, RecordingFile -from .models import RecordingFileTreatment -from django.contrib.sites.shortcuts import get_current_site -from django.contrib.sites.models import Site -from django.contrib.auth.models import User + from pod.video.models import Type +from .models import Recorder, Recording, RecordingFile, RecordingFileTreatment + # Register your models here. RECORDER_ADDITIONAL_FIELDS = getattr(settings, "RECORDER_ADDITIONAL_FIELDS", ()) +USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) @admin.register(Recording) @@ -23,10 +28,14 @@ class RecordingAdmin(admin.ModelAdmin): list_display_links = ("title",) list_filter = ("type",) autocomplete_fields = ["recorder", "user"] + if USE_RUNNER_MANAGER: + actions = ["encode_recording"] def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) + kwargs["queryset"] = Recorder.objects.filter( + sites=Site.objects.get_current() + ) if (db_field.name) == "user": kwargs["queryset"] = User.objects.filter( owner__sites=Site.objects.get_current() @@ -39,6 +48,47 @@ def get_queryset(self, request): qs = qs.filter(recorder__sites=get_current_site(request)) return qs + @admin.action(description=_("Encode selected recordings and create new video")) + def encode_recording( + self, request: HttpRequest, queryset: QuerySet[Recording] + ) -> None: + """Encode selected studio recordings through Runner Manager. + + When Runner Manager is enabled, this admin action iterates over the + selected recordings and starts encoding only for items with type + ``studio``. It reports success, warnings for unsupported recording + types, and processing errors through Django admin messages. + """ + if USE_RUNNER_MANAGER: + # Import here to avoid circular import + from pod.video_encode_transcript.runner_manager import ( + encode_studio_recording, + ) + + for item in queryset: + try: + if item.type == "studio": + self.message_user( + request, + _(f"Studio recording {item.id} encoding started"), + messages.SUCCESS, + ) + # Encode studio recording via Runner Manager + encode_studio_recording(item.id) + else: + # Display a message to the admin user + self.message_user( + request, + _( + f"Recording {item.id} is not a studio recording and can’t be encoded" + ), + messages.WARNING, + ) + except Exception as e: + self.message_user( + request, _(f"Error for {item}: {e}"), messages.ERROR + ) + @admin.register(RecordingFileTreatment) class RecordingFileTreatmentAdmin(admin.ModelAdmin): @@ -57,7 +107,9 @@ def delete_source(self, request, queryset) -> None: def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) + kwargs["queryset"] = Recorder.objects.filter( + sites=Site.objects.get_current() + ) return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_queryset(self, request): @@ -165,5 +217,7 @@ def get_queryset(self, request): def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) + kwargs["queryset"] = Recorder.objects.filter( + sites=Site.objects.get_current() + ) return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/pod/recorder/plugins/type_video.py b/pod/recorder/plugins/type_video.py index 3ed24476c4..9465eafffb 100644 --- a/pod/recorder/plugins/type_video.py +++ b/pod/recorder/plugins/type_video.py @@ -1,11 +1,12 @@ # video type process -import threading -import logging import datetime +import logging import os import shutil +import threading from django.conf import settings + from pod.video.models import Video, get_storage_path_video from pod.video_encode_transcript import encode @@ -15,7 +16,7 @@ def process(recording): - log.info("START PROCESS OF RECORDING %s" % recording) + log.info("START PROCESS OF RECORDING (type_video) %s" % recording) t = threading.Thread(target=encode_recording, args=[recording]) t.daemon = True t.start() diff --git a/pod/urls.py b/pod/urls.py index 29ff4bb438..7019fc3276 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -1,27 +1,26 @@ """Esup-pod URL configuration.""" +import importlib.util + from django.conf import settings -from django.urls import include -from django.urls import path, re_path from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth import views as auth_views -from django.views.i18n import JavaScriptCatalog +from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ +from django.views.i18n import JavaScriptCatalog -import importlib.util - +from pod.main.rest_router import urlpatterns as rest_urlpatterns from pod.main.views import ( contact_us, download_file, - user_autocomplete, + info_pod, maintenance, robots_txt, - info_pod, - userpicture, set_notifications, + user_autocomplete, + userpicture, ) -from pod.main.rest_router import urlpatterns as rest_urlpatterns USE_CAS = getattr(settings, "USE_CAS", False) USE_SHIB = getattr(settings, "USE_SHIB", False) @@ -41,6 +40,7 @@ WEBTV_MODE = getattr(settings, "WEBTV_MODE", False) USE_DUPLICATE = getattr(settings, "USE_DUPLICATE", False) USE_HYPERLINKS = getattr(settings, "USE_HYPERLINKS", False) +USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) if USE_CAS: from django_cas_ng import views as cas_views @@ -202,7 +202,9 @@ # IMPORT_VIDEO if USE_IMPORT_VIDEO: urlpatterns += [ - path("import_video/", include("pod.import_video.urls", namespace="import_video")), + path( + "import_video/", include("pod.import_video.urls", namespace="import_video") + ), ] if USE_DUPLICATE: @@ -210,6 +212,17 @@ path("duplicate/", include("pod.duplicate.urls", namespace="duplicate")), ] +# RUNNER_MANAGER +if USE_RUNNER_MANAGER: + urlpatterns += [ + path( + "runner/", + include( + "pod.video_encode_transcript.urls", namespace="video_encode_transcript" + ), + ), + ] + if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if importlib.util.find_spec("debug_toolbar") is not None: diff --git a/pod/video/rest_views.py b/pod/video/rest_views.py index 7205ade7bd..a5ba63fcd6 100644 --- a/pod/video/rest_views.py +++ b/pod/video/rest_views.py @@ -1,20 +1,22 @@ """Esup-Pod REST views.""" -from rest_framework import serializers, viewsets, renderers -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.decorators import action +import json +from django.db.models import Q from django.template.loader import render_to_string +from rest_framework import renderers, serializers, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.views import APIView -from .models import Channel, Theme, Type, Discipline, Video, ViewCount -from .context_processors import get_available_videos from pod.main.utils import remove_trailing_spaces +from .context_processors import get_available_videos +from .models import Channel, Discipline, Theme, Type, Video, ViewCount + # commented for v3 # from .remote_encode import start_store_remote_encoding_video -import json # Serializers define the API representation. @@ -210,9 +212,12 @@ class VideoViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["get"]) def user_videos(self, request): + # Manage additional_owners filtering + username = request.GET.get("username") user_videos = self.filter_queryset(self.get_queryset()).filter( - owner__username=request.GET.get("username") - ) + Q(owner__username=username) + | Q(additional_owners__username=username) + ).distinct() if request.GET.get("encoded") and request.GET.get("encoded") == "true": user_videos = user_videos.exclude( pk__in=[vid.id for vid in user_videos if not vid.encoded] diff --git a/pod/video/templates/videos/video_edit.html b/pod/video/templates/videos/video_edit.html index b3de11a552..f3a018c300 100644 --- a/pod/video/templates/videos/video_edit.html +++ b/pod/video/templates/videos/video_edit.html @@ -68,6 +68,9 @@

    {% if form.instance.id and form.instance.get_encoding_step == "" %} {% endif %} diff --git a/pod/video/templates/videos/video_page_content.html b/pod/video/templates/videos/video_page_content.html index b23dde1152..657fb207a3 100644 --- a/pod/video/templates/videos/video_page_content.html +++ b/pod/video/templates/videos/video_page_content.html @@ -101,6 +101,9 @@

    {% endif %} {% if video.encoding_in_progress %} diff --git a/pod/video/templates/videos/video_queue_runner_manager.html b/pod/video/templates/videos/video_queue_runner_manager.html new file mode 100644 index 0000000000..4e9f8ae11a --- /dev/null +++ b/pod/video/templates/videos/video_queue_runner_manager.html @@ -0,0 +1,24 @@ +{% load i18n %} + +
    +
    +
    +
    +

    + + {% trans "Queue rank:" %} +

    +

    {{ video_task_queue_rank|default:"-" }}

    +
    +
    +
    +
    +

    + + {% trans "Total number of videos in the queue:" %} +

    +

    {{ video_task_queue_total|default:"0" }}

    +
    +
    +
    +
    diff --git a/pod/video/views.py b/pod/video/views.py index 5b12e11075..b6f5787c73 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -1,49 +1,74 @@ """Esup-Pod videos views.""" -from concurrent import futures +import json import logging import os +import re +import uuid +from concurrent import futures +from datetime import date +from itertools import chain -from django.core.exceptions import PermissionDenied, SuspiciousOperation -from django.core.handlers.wsgi import WSGIRequest -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Count, F, Q, Case, When, Value, BooleanField -from django.db.models.functions import Concat -from django.shortcuts import get_object_or_404 -from django.shortcuts import render -from django.http import HttpResponse, JsonResponse -from django.http import HttpResponseNotFound -from django.http import HttpResponseForbidden, HttpResponseBadRequest -from django.http import QueryDict, Http404 -from django.views.decorators.csrf import csrf_protect +import pandas +from chunked_upload.models import ChunkedUpload +from chunked_upload.views import ChunkedUploadCompleteView, ChunkedUploadView +from dateutil.parser import parse +from django.conf import settings from django.contrib import messages -from django.utils.html import escape -from django.utils.translation import ngettext -from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.models import User from django.contrib.sites.shortcuts import get_current_site -from django.contrib.auth.decorators import login_required, user_passes_test +from django.core.exceptions import ( + ObjectDoesNotExist, + PermissionDenied, + SuspiciousOperation, +) +from django.core.handlers.wsgi import WSGIRequest +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator +from django.core.serializers.json import DjangoJSONEncoder +from django.db import IntegrityError, transaction +from django.db.models import ( + BooleanField, + Case, + Count, + F, + Min, + Q, + QuerySet, + Sum, + Value, + When, +) +from django.db.models.functions import Concat +from django.http import ( + Http404, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + JsonResponse, + QueryDict, +) +from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string -from django.conf import settings -from django.shortcuts import redirect from django.urls import reverse from django.utils import timezone -from django.db.models import Sum, Min - -# from django.contrib.auth.hashers import check_password +from django.utils.html import escape +from django.utils.timezone import timedelta +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext +from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie -from dateutil.parser import parse -from pod.main.utils import is_ajax, dismiss_stored_messages, get_max_code_lvl_messages +from pod.authentication.utils import get_owners as auth_get_owners from pod.main.context_processors import WEBTV_MODE +from pod.main.decorators import admin_required, ajax_login_required, ajax_required from pod.main.models import AdditionalChannelTab +from pod.main.utils import dismiss_stored_messages, get_max_code_lvl_messages, is_ajax from pod.main.views import ( MENUBAR_HIDE_INACTIVE_OWNERS, MENUBAR_SHOW_STAFF_OWNERS_ONLY, in_maintenance, ) -from pod.main.decorators import ajax_required, ajax_login_required, admin_required -from pod.authentication.utils import get_owners as auth_get_owners from pod.playlist.apps import FAVORITE_PLAYLIST_NAME from pod.playlist.models import Playlist, PlaylistContent from pod.playlist.utils import ( @@ -51,55 +76,53 @@ playlist_can_be_displayed, user_can_see_playlist_video, ) -from pod.video.utils import get_videos as video_get_videos -from pod.video.models import Video -from pod.video.models import Type -from pod.video.models import Channel -from pod.video.models import Theme -from pod.video.models import Discipline -from pod.video.models import AdvancedNotes, NoteComments, NOTES_STATUS -from pod.video.models import ViewCount, VideoVersion -from pod.video.models import Comment, Vote, Category -from pod.video.models import get_transcription_choices -from pod.video.models import UserMarkerTime, VideoAccessToken -from pod.video.forms import VideoForm, VideoVersionForm -from pod.video.forms import ChannelForm -from pod.video.forms import FrontThemeForm -from pod.video.forms import VideoPasswordForm -from pod.video.forms import VideoDeleteForm -from pod.video.forms import AdvancedNotesForm, NoteCommentsForm +from pod.video.forms import ( + AdvancedNotesForm, + ChannelForm, + FrontThemeForm, + NoteCommentsForm, + VideoDeleteForm, + VideoForm, + VideoPasswordForm, + VideoVersionForm, +) +from pod.video.models import ( + NOTES_STATUS, + AdvancedNotes, + Category, + Channel, + Comment, + Discipline, + NoteComments, + Theme, + Type, + UserMarkerTime, + Video, + VideoAccessToken, + VideoVersion, + ViewCount, + Vote, + get_transcription_choices, +) from pod.video.rest_views import ChannelSerializer +from pod.video.utils import get_videos as video_get_videos +from .context_processors import get_available_videos from .utils import ( - pagination_data, - get_headband, change_owner, - get_id_from_request, get_filtered_categories_for_user, - get_filtered_types_for_videos, get_filtered_disciplines_for_videos, - get_filtered_tags_for_videos, get_filtered_owners_for_videos, + get_filtered_tags_for_videos, + get_filtered_types_for_videos, + get_headband, + get_id_from_request, + pagination_data, + sort_videos_list, ) -from .context_processors import get_available_videos -from .utils import sort_videos_list -from django.views.decorators.csrf import ensure_csrf_cookie -from django.core.exceptions import ObjectDoesNotExist -import json -import re -import pandas -import uuid -from datetime import date -from chunked_upload.models import ChunkedUpload -from chunked_upload.views import ChunkedUploadView, ChunkedUploadCompleteView -from itertools import chain +# from django.contrib.auth.hashers import check_password -from django.db import IntegrityError -from django.db.models import QuerySet -from django.db import transaction - -from django.utils.timezone import timedelta RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY", False @@ -187,6 +210,7 @@ HIDE_USER_FILTER = getattr(settings, "HIDE_USER_FILTER", False) USE_TRANSCRIPTION = getattr(settings, "USE_TRANSCRIPTION", False) USE_OBSOLESCENCE = getattr(settings, "USE_OBSOLESCENCE", False) +USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) if USE_TRANSCRIPTION: from ..video_encode_transcript import transcript @@ -440,7 +464,9 @@ def channel_edit(request, slug): if request.user not in channel.owners.all() and not ( request.user.is_superuser or request.user.has_perm("video.change_channel") ): - messages.add_message(request, messages.ERROR, _("You cannot edit this channel.")) + messages.add_message( + request, messages.ERROR, _("You cannot edit this channel.") + ) raise PermissionDenied channel_form = ChannelForm( instance=channel, @@ -487,7 +513,9 @@ def theme_edit(request, slug): if request.user not in channel.owners.all() and not ( request.user.is_superuser or request.user.has_perm("video.change_theme") ): - messages.add_message(request, messages.ERROR, _("You cannot edit this channel.")) + messages.add_message( + request, messages.ERROR, _("You cannot edit this channel.") + ) raise PermissionDenied if is_ajax(request): @@ -598,7 +626,9 @@ def dashboard(request): videos_list = get_videos_for_owner(request) if USER_VIDEO_CATEGORY: - categories = Category.objects.prefetch_related("video").filter(owner=request.user) + categories = Category.objects.prefetch_related("video").filter( + owner=request.user + ) selected_categories = request.GET.getlist("categories") if selected_categories: @@ -1073,7 +1103,9 @@ def get_video_access(request, video, slug_private): # or is_password_protected ) if is_access_protected: - access_granted_for_private = slug_private and slug_private == video.get_hashkey() + access_granted_for_private = ( + slug_private and slug_private == video.get_hashkey() + ) access_granted_for_draft = request.user.is_authenticated and ( request.user == video.owner or request.user.is_superuser @@ -1156,7 +1188,9 @@ def video(request, slug, slug_c=None, slug_t=None, slug_private=None): raise PermissionDenied( _("You cannot access this playlist because it is private.") ) - return render_video(request, id, slug_c, slug_t, slug_private, template_video, params) + return render_video( + request, id, slug_c, slug_t, slug_private, template_video, params + ) def toggle_render_video_user_can_see_video( @@ -1177,7 +1211,8 @@ def toggle_render_video_user_can_see_video( slug_private == video.get_hashkey() or slug_private in [ - str(tok.token) for tok in VideoAccessToken.objects.filter(video=video) + str(tok.token) + for tok in VideoAccessToken.objects.filter(video=video) ] ) ) @@ -1214,6 +1249,27 @@ def toggle_render_video_when_is_playlist_player(request): return Http404() +def _get_video_queue_context(video: Video | None) -> dict: + """Return queue context for a video waiting for encoding.""" + if not USE_RUNNER_MANAGER or not video or not video.id: + return {"video_task_queue_rank": None, "video_task_queue_total": None} + + if not video.video or video.get_encoding_step != "": + return {"video_task_queue_rank": None, "video_task_queue_total": None} + + from pod.video_encode_transcript.task_queue import ( + get_video_pending_encoding_queue_info, + refresh_pending_task_ranks, + ) + + refresh_pending_task_ranks() + rank, total = get_video_pending_encoding_queue_info(video) + return { + "video_task_queue_rank": rank, + "video_task_queue_total": total, + } + + def render_video( request, id, @@ -1271,6 +1327,8 @@ def render_video( "listNotes": listNotes, "owner_filter": owner_filter, "playlist": playlist if request.GET.get("playlist") else None, + "USE_RUNNER_MANAGER": USE_RUNNER_MANAGER, + **_get_video_queue_context(video), **more_data, }, ) @@ -1303,6 +1361,8 @@ def render_video( "form": form, "listNotes": listNotes, "owner_filter": owner_filter, + "USE_RUNNER_MANAGER": USE_RUNNER_MANAGER, + **_get_video_queue_context(video), **more_data, }, ) @@ -1339,7 +1399,9 @@ def video_edit(request, slug=None): video and request.user != video.owner and ( - not (request.user.is_superuser or request.user.has_perm("video.change_video")) + not ( + request.user.is_superuser or request.user.has_perm("video.change_video") + ) ) and (request.user not in video.additional_owners.all()) ): @@ -1385,7 +1447,12 @@ def video_edit(request, slug=None): return render( request, "videos/video_edit.html", - {"form": form, "listTheme": json.dumps(get_list_theme_in_form(form))}, + { + "form": form, + "listTheme": json.dumps(get_list_theme_in_form(form)), + "USE_RUNNER_MANAGER": USE_RUNNER_MANAGER, + **_get_video_queue_context(form.instance if form else None), + }, ) @@ -1475,7 +1542,9 @@ def video_is_deletable(request, video) -> bool: if request.user != video.owner and not ( request.user.is_superuser or request.user.has_perm("video.delete_video") ): - messages.add_message(request, messages.ERROR, _("You cannot delete this media.")) + messages.add_message( + request, messages.ERROR, _("You cannot delete this media.") + ) raise PermissionDenied if WEBTV_MODE: @@ -1507,7 +1576,9 @@ def video_edit_access_tokens(request: WSGIRequest, slug: str = None): video and request.user != video.owner and ( - not (request.user.is_superuser or request.user.has_perm("video.change_video")) + not ( + request.user.is_superuser or request.user.has_perm("video.change_video") + ) ) and (request.user not in video.additional_owners.all()) ): @@ -1554,7 +1625,9 @@ def delete_token(request, video: Video, token: VideoAccessToken): try: uuid.UUID(str(token)) VideoAccessToken.objects.get(video=video, token=token).delete() - messages.add_message(request, messages.SUCCESS, _("The token has been deleted.")) + messages.add_message( + request, messages.SUCCESS, _("The token has been deleted.") + ) except (ValueError, ObjectDoesNotExist): messages.add_message(request, messages.ERROR, _("Token not found.")) @@ -1565,7 +1638,9 @@ def update_token(request, video: Video, token: VideoAccessToken): Token = VideoAccessToken.objects.get(video=video, token=token) Token.name = request.POST.get("name") Token.save() - messages.add_message(request, messages.SUCCESS, _("The token has been updated.")) + messages.add_message( + request, messages.SUCCESS, _("The token has been updated.") + ) except (ValueError, ObjectDoesNotExist): messages.add_message(request, messages.ERROR, _("Token not found.")) @@ -1582,10 +1657,19 @@ def video_transcript(request, slug=None): ) raise PermissionDenied - if request.user != video.owner and not ( - request.user.is_superuser or request.user.has_perm("video.change_video") + if ( + video + and request.user != video.owner + and ( + not ( + request.user.is_superuser or request.user.has_perm("video.change_video") + ) + ) + and (request.user not in video.additional_owners.all()) ): - messages.add_message(request, messages.ERROR, _("You cannot manage this video.")) + messages.add_message( + request, messages.ERROR, _("You cannot manage this video.") + ) raise PermissionDenied if not video.encoded or video.encoding_in_progress is True: @@ -1901,7 +1985,9 @@ def video_note_form_case(request, params): and idNote is not None and ( (request.method == "POST" and request.POST.get("action") == "form_com_edit") - or (request.method == "GET" and request.GET.get("action") == "form_com_edit") + or ( + request.method == "GET" and request.GET.get("action") == "form_com_edit" + ) ) ): can_edit_or_remove_note_or_com(request, com, "edit") @@ -1995,7 +2081,9 @@ def video_note_save(request, slug): q.update({"video": video.id}) form = AdvancedNotesForm(q) noteToEdit = note - elif request.method == "POST" and (request.POST.get("action").startswith("save_com")): + elif request.method == "POST" and ( + request.POST.get("action").startswith("save_com") + ): form = NoteCommentsForm(request.POST) comToEdit = { "save_com": com, @@ -2075,7 +2163,9 @@ def video_note_save_form_valid(request, video, params): com.status = request.POST.get("status") com.modified_on = timezone.now() com.save() - messages.add_message(request, messages.INFO, _("The comment has been modified.")) + messages.add_message( + request, messages.INFO, _("The comment has been modified.") + ) noteToDisplay, comToDisplay = note, get_com_tree(com) listNotesCom = get_adv_note_com_list(request, idNote) dictComments = get_com_coms_dict(request, listNotesCom) @@ -2099,7 +2189,9 @@ def video_note_save_form_valid(request, video, params): dictComments = get_com_coms_dict(request, listNotesCom) # Saving a note after an edit elif ( - idCom is None and idNote is not None and request.POST.get("action") == "save_note" + idCom is None + and idNote is not None + and request.POST.get("action") == "save_note" ): note.note = request.POST.get("note") note.status = request.POST.get("status") @@ -2108,7 +2200,9 @@ def video_note_save_form_valid(request, video, params): messages.add_message(request, messages.INFO, _("Your note has been modified.")) # Saving a new com for a note elif ( - idCom is None and idNote is not None and request.POST.get("action") == "save_com" + idCom is None + and idNote is not None + and request.POST.get("action") == "save_com" ): com = NoteComments.objects.create( user=request.user, @@ -2192,7 +2286,9 @@ def video_note_remove(request, slug): if idNote and request.POST.get("action") == "remove_note": can_edit_or_remove_note_or_com(request, note, "delete") note.delete() - messages.add_message(request, messages.INFO, _("The note has been deleted.")) + messages.add_message( + request, messages.INFO, _("The note has been deleted.") + ) listNotesCom, comToDisplay = None, None elif idNote and idCom and request.POST.get("action") == "remove_com": can_edit_or_remove_note_or_com(request, com, "delete") @@ -2306,8 +2402,11 @@ def rec_expl_coms(idNote, lComs) -> None: str(_("Content")), ] response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = "attachment; \ - filename=%s_notes_and_comments.csv" % slug + response["Content-Disposition"] = ( + "attachment; \ + filename=%s_notes_and_comments.csv" + % slug + ) df.to_csv( path_or_buf=response, sep="|", @@ -2514,7 +2613,9 @@ def get_all_views_count(v_id, date_filter=date.today()): all_views["year"] = count if count else 0 # view count since video was created - count = ViewCount.objects.filter(video_id=v_id).aggregate(Sum("count"))["count__sum"] + count = ViewCount.objects.filter(video_id=v_id).aggregate(Sum("count"))[ + "count__sum" + ] all_views["since_created"] = count if count else 0 # playlist addition in day @@ -2561,7 +2662,9 @@ def get_all_views_count(v_id, date_filter=date.today()): # favorite addition in year count = PlaylistContent.objects.filter( - playlist__in=favorites_playlists, video_id=v_id, date_added__year=date_filter.year + playlist__in=favorites_playlists, + video_id=v_id, + date_added__year=date_filter.year, ).count() all_views["fav_year"] = count if count else 0 @@ -2731,7 +2834,9 @@ def stats_view(request, slug=None, slug_t=None): ) min_date = ( - get_available_videos().aggregate(Min("date_added"))["date_added__min"].date() + get_available_videos() + .aggregate(Min("date_added"))["date_added__min"] + .date() ) data.append({"min_date": min_date}) @@ -2929,7 +3034,9 @@ def get_children_comment(request, comment_id, video_slug): parent_comment = ( Comment.objects.filter(video=v, id=comment_id) .annotate( - author_name=Concat("author__first_name", Value(" "), "author__last_name") + author_name=Concat( + "author__first_name", Value(" "), "author__last_name" + ) ) .annotate(nbr_child=Count("children", distinct=True)) .annotate( @@ -2983,7 +3090,9 @@ def get_comments(request, video_slug): .order_by("added") .annotate(nbr_vote=Count("vote", distinct=True)) .annotate( - author_name=Concat("author__first_name", Value(" "), "author__last_name") + author_name=Concat( + "author__first_name", Value(" "), "author__last_name" + ) ) .annotate(nbr_child=Count("children", distinct=True)) .annotate( @@ -3036,7 +3145,9 @@ def delete_comment(request, video_slug, comment_id): if in_maintenance(): return HttpResponseForbidden( - _("Sorry, you can’t delete a comment while the server is under maintenance.") + _( + "Sorry, you can’t delete a comment while the server is under maintenance." + ) ) if c.author == c_user or v.owner == c_user or c_user.is_superuser: @@ -3246,7 +3357,9 @@ def add_category(request): data_context["videos"] = videos if request.GET.get("page"): - return render(request, "videos/category_modal_video_list.html", data_context) + return render( + request, "videos/category_modal_video_list.html", data_context + ) data_context = { "modal_action": "add", @@ -3429,7 +3542,9 @@ class PodChunkedUploadView(ChunkedUploadView): def check_permissions(self, request): if not request.user.is_authenticated: return False - elif RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: + elif ( + RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False + ): return False pass @@ -3441,7 +3556,9 @@ class PodChunkedUploadCompleteView(ChunkedUploadCompleteView): def check_permissions(self, request): if not request.user.is_authenticated: return False - elif RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: + elif ( + RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False + ): return False pass @@ -3544,7 +3661,9 @@ def filter_owners(request): return auth_get_owners(search, limit, offset) except Exception as err: - return JsonResponse({"success": False, "detail": "Syntax error: {0}".format(err)}) + return JsonResponse( + {"success": False, "detail": "Syntax error: {0}".format(err)} + ) @login_required(redirect_field_name="referrer") @@ -3558,7 +3677,9 @@ def filter_videos(request, user_id): return video_get_videos(title, user_id, search, limit, offset) except Exception as err: - return JsonResponse({"success": False, "detail": "Syntax error: {0}".format(err)}) + return JsonResponse( + {"success": False, "detail": "Syntax error: {0}".format(err)} + ) def get_serialized_channels(request: WSGIRequest, channels: QueryDict) -> dict: @@ -3653,7 +3774,9 @@ def get_channels_for_specific_channel_tab(request: WSGIRequest) -> JsonResponse: return JsonResponse(response, safe=False) -def get_theme_list_for_specific_channel(request: WSGIRequest, slug: str) -> JsonResponse: +def get_theme_list_for_specific_channel( + request: WSGIRequest, slug: str +) -> JsonResponse: """ Get the themes for a specific channel. @@ -3671,7 +3794,9 @@ def get_theme_list_for_specific_channel(request: WSGIRequest, slug: str) -> Json def available_filters(request): """API endpoint to return all available video filters based on user's videos.""" user_videos = get_videos_for_owner(request) - categories_qs = Category.objects.prefetch_related("video").filter(owner=request.user) + categories_qs = Category.objects.prefetch_related("video").filter( + owner=request.user + ) categories = list(categories_qs.values("id", "title")[:20]) types_qs = ( Type.objects.filter(video__in=user_videos) @@ -3740,7 +3865,9 @@ def available_filter_by_type(request, filter_name): uv, term, lim ), "tag": lambda req, uv, term, lim: get_filtered_tags_for_videos(uv, term, lim), - "owner": lambda req, uv, term, lim: get_filtered_owners_for_videos(uv, term, lim), + "owner": lambda req, uv, term, lim: get_filtered_owners_for_videos( + uv, term, lim + ), } try: diff --git a/pod/video_encode_transcript/Encoding_video.py b/pod/video_encode_transcript/Encoding_video.py index 738b646c55..0aa96fe949 100644 --- a/pod/video_encode_transcript/Encoding_video.py +++ b/pod/video_encode_transcript/Encoding_video.py @@ -6,88 +6,59 @@ import os import time import unicodedata -from webvtt import WebVTT, Caption + +from webvtt import Caption, WebVTT if __name__ == "__main__": - from encoding_utils import ( - get_dressing_position_value, - get_info_from_video, - get_list_rendition, - launch_cmd, - check_file, - ) - from encoding_settings import ( - FFMPEG_CMD, - FFPROBE_CMD, - FFPROBE_GET_INFO, - FFMPEG_CRF, - FFMPEG_PRESET, - FFMPEG_PROFILE, - FFMPEG_LEVEL, - FFMPEG_HLS_TIME, - FFMPEG_INPUT, - FFMPEG_LIBX, - FFMPEG_MP4_ENCODE, - FFMPEG_HLS_COMMON_PARAMS, - FFMPEG_HLS_ENCODE_PARAMS, - FFMPEG_MP3_ENCODE, - FFMPEG_M4A_ENCODE, - FFMPEG_NB_THREADS, - FFMPEG_AUDIO_BITRATE, - FFMPEG_EXTRACT_THUMBNAIL, - FFMPEG_NB_THUMBNAIL, - FFMPEG_CREATE_THUMBNAIL, - FFMPEG_CREATE_OVERVIEW, - FFMPEG_EXTRACT_SUBTITLE, - FFMPEG_DRESSING_INPUT, - FFMPEG_DRESSING_OUTPUT, - FFMPEG_DRESSING_WATERMARK, - FFMPEG_DRESSING_FILTER_COMPLEX, - FFMPEG_DRESSING_SCALE, - FFMPEG_DRESSING_CONCAT, - FFMPEG_DRESSING_SILENT, - FFMPEG_DRESSING_AUDIO, - ) + from encoding_settings import (FFMPEG_AUDIO_BITRATE, FFMPEG_CMD, + FFMPEG_CREATE_OVERVIEW, + FFMPEG_CREATE_THUMBNAIL, FFMPEG_CRF, + FFMPEG_DRESSING_AUDIO, + FFMPEG_DRESSING_CONCAT, + FFMPEG_DRESSING_FILTER_COMPLEX, + FFMPEG_DRESSING_INPUT, + FFMPEG_DRESSING_OUTPUT, + FFMPEG_DRESSING_SCALE, + FFMPEG_DRESSING_SILENT, + FFMPEG_DRESSING_WATERMARK, + FFMPEG_EXTRACT_SUBTITLE, + FFMPEG_EXTRACT_THUMBNAIL, + FFMPEG_HLS_COMMON_PARAMS, + FFMPEG_HLS_ENCODE_PARAMS, FFMPEG_HLS_TIME, + FFMPEG_INPUT, FFMPEG_LEVEL, FFMPEG_LIBX, + FFMPEG_M4A_ENCODE, FFMPEG_MP3_ENCODE, + FFMPEG_MP4_ENCODE, FFMPEG_NB_THREADS, + FFMPEG_NB_THUMBNAIL, FFMPEG_PRESET, + FFMPEG_PROFILE, FFPROBE_CMD, + FFPROBE_GET_INFO) + from encoding_utils import (check_file, get_dressing_position_value, + get_info_from_video, get_list_rendition, + launch_cmd) else: - from .encoding_utils import ( - get_dressing_position_value, - get_info_from_video, - get_list_rendition, - launch_cmd, - check_file, - ) - from .encoding_settings import ( - FFMPEG_CMD, - FFPROBE_CMD, - FFPROBE_GET_INFO, - FFMPEG_CRF, - FFMPEG_PRESET, - FFMPEG_PROFILE, - FFMPEG_LEVEL, - FFMPEG_HLS_TIME, - FFMPEG_INPUT, - FFMPEG_LIBX, - FFMPEG_MP4_ENCODE, - FFMPEG_HLS_COMMON_PARAMS, - FFMPEG_HLS_ENCODE_PARAMS, - FFMPEG_MP3_ENCODE, - FFMPEG_M4A_ENCODE, - FFMPEG_NB_THREADS, - FFMPEG_AUDIO_BITRATE, - FFMPEG_EXTRACT_THUMBNAIL, - FFMPEG_NB_THUMBNAIL, - FFMPEG_CREATE_THUMBNAIL, - FFMPEG_CREATE_OVERVIEW, - FFMPEG_EXTRACT_SUBTITLE, - FFMPEG_DRESSING_INPUT, - FFMPEG_DRESSING_OUTPUT, - FFMPEG_DRESSING_WATERMARK, - FFMPEG_DRESSING_FILTER_COMPLEX, - FFMPEG_DRESSING_SCALE, - FFMPEG_DRESSING_CONCAT, - FFMPEG_DRESSING_SILENT, - FFMPEG_DRESSING_AUDIO, - ) + from .encoding_settings import (FFMPEG_AUDIO_BITRATE, FFMPEG_CMD, + FFMPEG_CREATE_OVERVIEW, + FFMPEG_CREATE_THUMBNAIL, FFMPEG_CRF, + FFMPEG_DRESSING_AUDIO, + FFMPEG_DRESSING_CONCAT, + FFMPEG_DRESSING_FILTER_COMPLEX, + FFMPEG_DRESSING_INPUT, + FFMPEG_DRESSING_OUTPUT, + FFMPEG_DRESSING_SCALE, + FFMPEG_DRESSING_SILENT, + FFMPEG_DRESSING_WATERMARK, + FFMPEG_EXTRACT_SUBTITLE, + FFMPEG_EXTRACT_THUMBNAIL, + FFMPEG_HLS_COMMON_PARAMS, + FFMPEG_HLS_ENCODE_PARAMS, FFMPEG_HLS_TIME, + FFMPEG_INPUT, FFMPEG_LEVEL, FFMPEG_LIBX, + FFMPEG_M4A_ENCODE, FFMPEG_MP3_ENCODE, + FFMPEG_MP4_ENCODE, FFMPEG_NB_THREADS, + FFMPEG_NB_THUMBNAIL, FFMPEG_PRESET, + FFMPEG_PROFILE, FFPROBE_CMD, + FFPROBE_GET_INFO) + from .encoding_utils import (check_file, get_dressing_position_value, + get_info_from_video, get_list_rendition, + launch_cmd) __author__ = "Nicolas CAN " __license__ = "LGPL v3" @@ -124,7 +95,9 @@ FFMPEG_MP3_ENCODE = getattr(settings, "FFMPEG_MP3_ENCODE", FFMPEG_MP3_ENCODE) FFMPEG_M4A_ENCODE = getattr(settings, "FFMPEG_M4A_ENCODE", FFMPEG_M4A_ENCODE) FFMPEG_NB_THREADS = getattr(settings, "FFMPEG_NB_THREADS", FFMPEG_NB_THREADS) - FFMPEG_AUDIO_BITRATE = getattr(settings, "FFMPEG_AUDIO_BITRATE", FFMPEG_AUDIO_BITRATE) + FFMPEG_AUDIO_BITRATE = getattr( + settings, "FFMPEG_AUDIO_BITRATE", FFMPEG_AUDIO_BITRATE + ) FFMPEG_EXTRACT_THUMBNAIL = getattr( settings, "FFMPEG_EXTRACT_THUMBNAIL", FFMPEG_EXTRACT_THUMBNAIL ) @@ -197,7 +170,13 @@ class Encoding_video: dressing_input = "" def __init__( - self, id=0, video_file="", start=0, stop=0, json_dressing=None, dressing_input="" + self, + id=0, + video_file="", + start=0, + stop=0, + json_dressing=None, + dressing_input="", ) -> None: """Initialize a new Encoding_video object.""" self.id = id @@ -250,7 +229,11 @@ def get_video_data(self) -> None: msg += return_msg + "\n" self.add_encoding_log("probe_cmd", probe_cmd, True, msg) try: - duration = int(float("%s" % info["format"]["duration"])) + fmt = info.get("format") if info else None + if fmt and fmt.get("duration") is not None: + duration = int(float("%s" % fmt.get("duration"))) + else: + raise KeyError("duration") except (RuntimeError, KeyError, AttributeError, ValueError, TypeError) as err: msg = "\nUnexpected error: {0}".format(err) self.add_encoding_log("duration", "", True, msg) @@ -272,7 +255,11 @@ def fix_duration(self, input_file) -> None: msg += return_msg + "\n" duration = 0 try: - duration = int(float("%s" % info["format"]["duration"])) + fmt = info.get("format") if info else None + if fmt and fmt.get("duration") is not None: + duration = int(float("%s" % fmt.get("duration"))) + else: + raise KeyError("duration") except (RuntimeError, KeyError, AttributeError, ValueError, TypeError) as err: msg += "\nUnexpected error: {0}".format(err) self.add_encoding_log("fix_duration", "", True, msg) @@ -306,7 +293,9 @@ def add_stream(self, stream): language = "" if stream.get("tags"): language = stream.get("tags").get("language", "") - self.list_subtitle_track["%s" % stream.get("index")] = {"language": language} + self.list_subtitle_track["%s" % stream.get("index")] = { + "language": language + } def get_output_dir(self) -> str: dirname = os.path.dirname(self.video_file) @@ -476,7 +465,9 @@ def handle_dressing_credits(self) -> str: duration = self.json_dressing.get(duration_key) try: - duration = int(duration) if duration and int(duration) > 0 else 1 + duration = ( + int(duration) if duration and int(duration) > 0 else 1 + ) except (ValueError, TypeError): duration = 1 @@ -557,9 +548,11 @@ def apply_dressing_watermark(self, height: str) -> str: Returns: A string representing the FFMPEG command for applying the watermark. """ - opacity = self.json_dressing["opacity"] / 100.0 + if not self.json_dressing: + return "" + opacity = self.json_dressing.get("opacity", 100) / 100.0 position = get_dressing_position_value( - self.json_dressing["position_orig"], height + self.json_dressing.get("position_orig", "center"), height ) name_out = ( "[video]" @@ -581,8 +574,8 @@ def add_dressing_credits( height: str, credit_type: str, name: str, - order: str, - interval_silent: str, + order: int, + interval_silent: int, ): """ Add opening or ending credits to the FFmpeg command by updating the filters and parameters. @@ -593,8 +586,8 @@ def add_dressing_credits( height (str): The height of the video used for scaling the credit overlay. credit_type (str): Specifies the type of credits to add, either 'opening_credits' or 'ending_credits'. name (str): The identifier for the credit video or overlay to be used in the FFmpeg filter graph. - order (str): The position identifier for the audio stream, used to sync audio with the credits. - interval_silent (str): Counter indicating the number of silent audio intervals inserted, used when adding silent audio tracks. + order (int): The position identifier for the audio stream, used to sync audio with the credits. + interval_silent (int): Counter indicating the number of silent audio intervals inserted, used when adding silent audio tracks. Returns: tuple: @@ -621,7 +614,8 @@ def add_dressing_credits( params = f"{params}[{name}][{audio_out}]" filters.append( - FFMPEG_DRESSING_SCALE % {"number": str(order), "height": height, "name": name} + FFMPEG_DRESSING_SCALE + % {"number": str(order), "height": height, "name": name} ) return filters, params, interval_silent @@ -679,7 +673,9 @@ def get_mp3_command(self) -> str: "input": self.video_file, "nb_threads": FFMPEG_NB_THREADS, } - output_file = os.path.join(self.output_dir, "audio_%s.mp3" % FFMPEG_AUDIO_BITRATE) + output_file = os.path.join( + self.output_dir, "audio_%s.mp3" % FFMPEG_AUDIO_BITRATE + ) mp3_command += FFMPEG_MP3_ENCODE % { # "audio_bitrate": AUDIO_BITRATE, "cut": self.get_subtime(self.cutting_start, self.cutting_stop), @@ -694,7 +690,9 @@ def get_m4a_command(self) -> str: "input": self.video_file, "nb_threads": FFMPEG_NB_THREADS, } - output_file = os.path.join(self.output_dir, "audio_%s.m4a" % FFMPEG_AUDIO_BITRATE) + output_file = os.path.join( + self.output_dir, "audio_%s.m4a" % FFMPEG_AUDIO_BITRATE + ) m4a_command += FFMPEG_M4A_ENCODE % { "cut": self.get_subtime(self.cutting_start, self.cutting_stop), "audio_bitrate": FFMPEG_AUDIO_BITRATE, @@ -734,6 +732,9 @@ def get_extract_thumbnail_command(self) -> str: def get_create_thumbnail_command(self) -> str: thumbnail_command = "%s " % FFMPEG_CMD first_item = self.get_first_item() + if not first_item or first_item[0] not in self.list_mp4_files: + logger.error("No MP4 rendition available to create thumbnails.") + return "" input_file = self.list_mp4_files[first_item[0]] thumbnail_command += FFMPEG_INPUT % { "input": input_file, @@ -766,6 +767,9 @@ def get_first_item(self): def create_overview(self) -> None: first_item = self.get_first_item() + if not first_item or first_item[0] not in self.list_mp4_files: + logger.error("No MP4 rendition available to create overview.") + return # overview combine for 160x90 in_height = list(self.list_video_track.items())[0][1]["height"] in_width = list(self.list_video_track.items())[0][1]["width"] @@ -944,8 +948,14 @@ def fix_input(input) -> str: args = parser.parse_args() logger.debug(args.start) filename = fix_input(args.input) + dressing_data = None + if args.dressing: + try: + dressing_data = json.loads(args.dressing) + except json.JSONDecodeError: + logger.error("Invalid dressing JSON provided, ignoring it.") encoding_video = Encoding_video( - args.id, filename, args.start, args.stop, args.dressing + args.id, filename, args.start, args.stop, dressing_data ) # error if uncommented # encoding_video.encoding_log += start diff --git a/pod/video_encode_transcript/Encoding_video_model.py b/pod/video_encode_transcript/Encoding_video_model.py index 7e13deed7d..c49a6d9578 100644 --- a/pod/video_encode_transcript/Encoding_video_model.py +++ b/pod/video_encode_transcript/Encoding_video_model.py @@ -1,32 +1,23 @@ """Model for video encoding.""" +import json import logging import os import re +import time + from django.conf import settings -from .models import EncodingVideo -from .models import EncodingAudio -from .models import VideoRendition -from .models import PlaylistVideo -from .models import EncodingLog -from pod.video.models import Video -from pod.completion.models import Track from django.core.files import File -from .Encoding_video import ( - Encoding_video, - FFMPEG_NB_THUMBNAIL, - FFMPEG_CREATE_THUMBNAIL, - FFMPEG_CMD, - FFMPEG_INPUT, - FFMPEG_NB_THREADS, -) -from pod.video.models import LANG_CHOICES -import json -import time -from .encoding_utils import ( - launch_cmd, - check_file, -) + +from pod.completion.models import Track +from pod.video.models import LANG_CHOICES, Video + +from .encoding_utils import check_file, launch_cmd +from .Encoding_video import (FFMPEG_CMD, FFMPEG_CREATE_THUMBNAIL, FFMPEG_INPUT, + FFMPEG_NB_THREADS, FFMPEG_NB_THUMBNAIL, + Encoding_video) +from .models import (EncodingAudio, EncodingLog, EncodingVideo, PlaylistVideo, + VideoRendition) DEBUG = getattr(settings, "DEBUG", True) logger = logging.getLogger(__name__) @@ -53,12 +44,10 @@ if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True - from pod.podfile.models import CustomImageModel - from pod.podfile.models import CustomFileModel + from pod.podfile.models import CustomFileModel, CustomImageModel else: __FILEPICKER__ = False - from pod.main.models import CustomImageModel - from pod.main.models import CustomFileModel + from pod.main.models import CustomFileModel, CustomImageModel class Encoding_video_model(Encoding_video): @@ -139,7 +128,9 @@ def store_json_list_mp3_m4a_files(self, info_video, video_to_encode) -> None: name="audio", video=video_to_encode, encoding_format=( - "audio/mp3" if (encode_item == "list_mp3_files") else "video/mp4" + "audio/mp3" + if (encode_item == "list_mp3_files") + else "video/mp4" ), # need to double check path source_file=self.get_true_path(mp3_files[audio_file]), @@ -150,7 +141,9 @@ def store_json_list_mp4_hls_files(self, info_video, video_to_encode) -> None: for video_file in mp4_files: if not check_file(mp4_files[video_file]): continue - rendition = VideoRendition.objects.get(resolution__contains="x" + video_file) + rendition = VideoRendition.objects.get( + resolution__contains="x" + video_file + ) encod_name = video_file + "p" encoding, created = EncodingVideo.objects.get_or_create( name=encod_name, @@ -164,7 +157,9 @@ def store_json_list_mp4_hls_files(self, info_video, video_to_encode) -> None: for video_file in hls_files: if not check_file(hls_files[video_file]): continue - rendition = VideoRendition.objects.get(resolution__contains="x" + video_file) + rendition = VideoRendition.objects.get( + resolution__contains="x" + video_file + ) encod_name = video_file + "p" encoding, created = PlaylistVideo.objects.get_or_create( name=encod_name, diff --git a/pod/video_encode_transcript/admin.py b/pod/video_encode_transcript/admin.py index ffbc70f32c..d82df31129 100644 --- a/pod/video_encode_transcript/admin.py +++ b/pod/video_encode_transcript/admin.py @@ -1,13 +1,29 @@ -from django.contrib import admin - -from django.contrib.sites.shortcuts import get_current_site +import requests +from django.contrib import admin, messages from django.contrib.sites.models import Site -from .models import EncodingAudio, EncodingVideo, VideoRendition -from .models import EncodingLog -from .models import EncodingStep -from .models import PlaylistVideo +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseRedirect +from django.urls import path, reverse +from django.utils import timezone +from django.utils.html import format_html +from django.utils.text import Truncator +from django.utils.translation import gettext_lazy as _ + from pod.video.models import Video +from .models import ( + EncodingAudio, + EncodingLog, + EncodingStep, + EncodingVideo, + PlaylistVideo, + RunnerManager, + Task, + VideoRendition, +) +from .task_queue import refresh_pending_task_ranks + @admin.register(EncodingVideo) class EncodingVideoAdmin(admin.ModelAdmin): @@ -17,7 +33,7 @@ class EncodingVideoAdmin(admin.ModelAdmin): list_filter = ["encoding_format", "rendition"] search_fields = ["id", "video__id", "video__title"] - @admin.display(description="resolution") + @admin.display(description=_("resolution")) def get_resolution(self, obj): """Get the resolution of the video rendition.""" return obj.rendition.resolution @@ -145,13 +161,311 @@ class PlaylistVideoAdmin(admin.ModelAdmin): list_filter = ["encoding_format"] def get_queryset(self, request): + """Limit queryset to objects linked to the current site for non-superusers.""" qs = super().get_queryset(request) if not request.user.is_superuser: qs = qs.filter(video__sites=get_current_site(request)) return qs def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Restrict selectable videos to those available on the current site.""" if (db_field.name) == "video": kwargs["queryset"] = Video.objects.filter(sites=Site.objects.get_current()) return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +@admin.register(RunnerManager) +class RunnerManagerAdmin(admin.ModelAdmin): + """Administration for runner managers. + + Args: + admin (ModelAdmin): admin model + """ + + change_form_template = "admin_test_connection.html" + + list_display = ( + "id", + "name", + "priority", + "url", + "site", + ) + list_display_links = ("id", "name") + ordering = ("-id", "priority") + readonly_fields = [] + search_fields = ["id", "name", "site"] + + def get_urls(self): + """Register the custom admin endpoint used to test runner connectivity.""" + custom_urls = [ + path( + "/test-connection/", + self.admin_site.admin_view(self.test_connection_view), + name="video_encode_transcript_runnermanager_test_connection", + ), + ] + return custom_urls + super().get_urls() + + def _health_url(self, runner_manager: RunnerManager) -> str: + """Build runner manager health endpoint URL.""" + return ( + runner_manager.url + "manager/health" + if runner_manager.url.endswith("/") + else runner_manager.url + "/manager/health" + ) + + def _auth_headers(self, runner_manager: RunnerManager) -> dict[str, str]: + """Build headers used for the runner manager availability check.""" + return { + "Accept": "application/json", + "Authorization": f"Bearer {runner_manager.token}", + } + + def _change_url(self, runner_manager: RunnerManager) -> str: + """Build the admin change URL for a runner manager instance.""" + return reverse( + "admin:video_encode_transcript_runnermanager_change", + args=[runner_manager.pk], + ) + + def test_connection_view(self, request, object_id): + """Call the runner health endpoint and show the result in admin messages.""" + runner_manager = self.get_object(request, object_id) + if runner_manager is None: + self.message_user( + request, + _("Runner manager not found."), + level=messages.ERROR, + ) + return HttpResponseRedirect( + reverse("admin:video_encode_transcript_runnermanager_changelist") + ) + if not self.has_change_permission(request, runner_manager): + raise PermissionDenied + + health_url = self._health_url(runner_manager) + try: + response = requests.get( + health_url, + headers=self._auth_headers(runner_manager), + timeout=15, + ) + except requests.RequestException as exc: + self.message_user( + request, + _( + "Unable to reach runner manager '%(name)s' at %(url)s. " + "Check the URL and network access. Error: %(error)s" + ) + % { + "name": runner_manager.name, + "url": runner_manager.url, + "error": str(exc), + }, + level=messages.ERROR, + ) + return HttpResponseRedirect(self._change_url(runner_manager)) + + if response.status_code in (401, 403): + self.message_user( + request, + _( + "Runner manager '%(name)s' responded but rejected authentication " + "(HTTP %(status)s). Check the bearer token." + ) + % {"name": runner_manager.name, "status": response.status_code}, + level=messages.ERROR, + ) + elif response.status_code in (200, 204): + self.message_user( + request, + _( + "Connection to runner manager '%(name)s' succeeded " + "(HTTP %(status)s)." + ) + % {"name": runner_manager.name, "status": response.status_code}, + level=messages.SUCCESS, + ) + elif response.status_code == 404: + self.message_user( + request, + _( + "Runner manager '%(name)s' is reachable but endpoint %(url)s " + "was not found (HTTP 404). Check the configured URL." + ) + % {"name": runner_manager.name, "url": health_url}, + level=messages.ERROR, + ) + else: + self.message_user( + request, + _( + "Runner manager '%(name)s' is reachable but returned an " + "unexpected response (HTTP %(status)s)." + ) + % {"name": runner_manager.name, "status": response.status_code}, + level=messages.WARNING, + ) + + return HttpResponseRedirect(self._change_url(runner_manager)) + + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + """Administration for runner manager tasks. + + Args: + admin (ModelAdmin): admin model + """ + + list_display = ( + "id", + "video_id_display", + "video_label", + "recording_id_display", + "recording_label", + "type", + "status_badge", + "task_id", + "date_added", + "runner_manager", + ) + list_display_links = ("id",) + ordering = ("-id",) + readonly_fields = ["date_added"] + fields = ( + "type", + "status", + "date_added", + "task_id", + "video", + "recording", + "runner_manager", + "script_output", + ) + search_fields = ["id", "video__id", "runner_manager__name"] + actions = ["relaunch_selected_tasks"] + + def get_readonly_fields(self, request, obj=None): + """Keep type and status immutable after task creation.""" + if obj is None: + return self.readonly_fields + return [*self.readonly_fields, "type", "status"] + + def _truncate_label(self, label): + """Return a short label for list display.""" + return Truncator(label).chars(50) + + @admin.display(description="Video ID", ordering="video__id") + def video_id_display(self, obj): + """Display the related video identifier, or '-' when absent.""" + if not obj.video_id: + return "-" + return obj.video_id + + @admin.display(description="Video", ordering="video__title") + def video_label(self, obj): + """Display a truncated video title, or '-' when no video is linked.""" + if not obj.video_id: + return "-" + return self._truncate_label(obj.video.title) + + @admin.display(description="Recording ID", ordering="recording__id") + def recording_id_display(self, obj): + """Display the related recording identifier, or '-' when absent.""" + if not obj.recording_id: + return "-" + return obj.recording_id + + @admin.display(description="Recording", ordering="recording__title") + def recording_label(self, obj): + """Display a truncated recording title, or '-' when not linked.""" + if not obj.recording_id: + return "-" + return self._truncate_label(obj.recording.title) + + @admin.display(description="Statut", ordering="status") + def status_badge(self, obj): + """Render task status with a colored badge in list display.""" + badge_map = { + "pending": "bg-secondary", + "running": "bg-warning text-dark", + "failed": "bg-danger", + "timeout": "bg-danger", + "completed": "bg-success", + } + badge_class = badge_map.get(obj.status, "bg-secondary") + status_label = obj.get_status_display() + return format_html( + '{}', + badge_class, + status_label, + ) + + @admin.action(description=_("Restart selected tasks")) + def relaunch_selected_tasks(self, request, queryset): + """Reset selected tasks and relaunch one job per unique source.""" + from .runner_manager import ( + encode_studio_recording, + encode_video, + transcript_video, + ) + + relaunched_count = 0 + skipped_count = 0 + launched_sources = set() + + for task in queryset: + source_key = (task.type, task.video_id, task.recording_id) + if source_key in launched_sources: + skipped_count += 1 + continue + + # Force selected task as pending so runner_manager helpers update this row + # instead of creating a new pending task. + task.status = "pending" + task.task_id = None + task.runner_manager = None + task.rank = None + task.script_output = None + task.date_added = timezone.now() + task.save( + update_fields=[ + "status", + "task_id", + "runner_manager", + "rank", + "script_output", + "date_added", + ] + ) + + if task.type == "encoding" and task.video_id: + encode_video(task.video_id) + elif task.type == "transcription" and task.video_id: + transcript_video(task.video_id) + elif task.type == "studio" and task.recording_id: + encode_studio_recording(task.recording_id) + else: + skipped_count += 1 + continue + + launched_sources.add(source_key) + relaunched_count += 1 + + refresh_pending_task_ranks() + self.message_user( + request, + _("%(count)s task(s) relaunched immediately.") + % {"count": relaunched_count}, + level=messages.SUCCESS, + ) + if skipped_count: + self.message_user( + request, + _("%(count)s task(s) skipped (duplicate or missing source).") + % {"count": skipped_count}, + level=messages.WARNING, + ) diff --git a/pod/video_encode_transcript/encode.py b/pod/video_encode_transcript/encode.py index b9fe25e4aa..7649da4b8c 100644 --- a/pod/video_encode_transcript/encode.py +++ b/pod/video_encode_transcript/encode.py @@ -1,32 +1,33 @@ """Esup-Pod module to handle video encoding with CPU.""" +import logging +import threading +import time + from django.conf import settings from webpush.models import PushInformation -from pod.video.models import Video -from .Encoding_video_model import Encoding_video_model -from .encoding_studio import start_encode_video_studio -from .models import EncodingLog - from pod.cut.models import CutVideo from pod.dressing.models import Dressing from pod.dressing.utils import get_dressing_input from pod.main.tasks import task_start_encode, task_start_encode_studio from pod.recorder.models import Recording +from pod.video.models import Video + from .encoding_settings import FFMPEG_DRESSING_INPUT +from .encoding_studio import start_encode_video_studio +from .Encoding_video_model import Encoding_video_model +from .models import EncodingLog from .utils import ( + add_encoding_log, change_encoding_step, check_file, - add_encoding_log, send_email, send_email_encoding, send_email_recording, send_notification_encoding, time_to_seconds, ) -import logging -import time -import threading __license__ = "LGPL v3" log = logging.getLogger(__name__) @@ -45,51 +46,72 @@ settings, "USE_REMOTE_ENCODING_TRANSCODING", False ) if USE_REMOTE_ENCODING_TRANSCODING: - from .encoding_tasks import start_encoding_task - from .encoding_tasks import start_studio_task + from .encoding_tasks import start_encoding_task, start_studio_task -FFMPEG_DRESSING_INPUT = getattr(settings, "FFMPEG_DRESSING_INPUT", FFMPEG_DRESSING_INPUT) +FFMPEG_DRESSING_INPUT = getattr( + settings, "FFMPEG_DRESSING_INPUT", FFMPEG_DRESSING_INPUT +) + +USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) # ########################################################################## # ENCODE VIDEO: THREAD TO LAUNCH ENCODE # ########################################################################## -# Disable for the moment, will be reactivated in future version - def start_encode(video_id: int, threaded=True) -> None: """Start video encoding.""" - if threaded: - if CELERY_TO_ENCODE: - task_start_encode.delay(video_id) - else: - log.info("START ENCODE VIDEO ID %s" % video_id) - t = threading.Thread(target=encode_video, args=[video_id]) - t.daemon = True - t.start() + # Special case for runner manager: delegate encoding to a remote service and return immediately without threading logic + if USE_RUNNER_MANAGER: + log.info("Start encode, with runner manager, for id: %s" % video_id) + # Load module here to prevent circular import + from .runner_manager import encode_video as runner_encode_video + + runner_encode_video(video_id) else: - encode_video(video_id) + log.info("Start encode, without runner manager, for id: %s" % video_id) + if threaded: + if CELERY_TO_ENCODE: + task_start_encode.delay(video_id) + else: + log.info("START ENCODE VIDEO ID %s" % video_id) + t = threading.Thread(target=encode_video, args=[video_id]) + t.daemon = True + t.start() + else: + encode_video(video_id) def start_encode_studio( recording_id, video_output, videos, subtime, presenter, threaded=True ) -> None: """Start studio encoding.""" - if threaded: - if CELERY_TO_ENCODE: - task_start_encode_studio.delay( - recording_id, video_output, videos, subtime, presenter - ) - else: - log.info("START ENCODE VIDEO ID %s" % recording_id) - t = threading.Thread( - target=encode_video_studio, - args=[recording_id, video_output, videos, subtime, presenter], - ) - t.daemon = True - t.start() + # Special case for runner manager: delegate encoding to a remote service and return immediately without threading logic + if USE_RUNNER_MANAGER: + log.info("Start encode studio, with runner manager, for id: %s" % recording_id) + # Load module here to prevent circular import + from .runner_manager import encode_studio_recording + + encode_studio_recording(recording_id) else: - encode_video_studio(recording_id, video_output, videos, subtime, presenter) + log.info( + "Start encode studio, without runner manager, for id: %s" % recording_id + ) + if threaded: + if CELERY_TO_ENCODE: + task_start_encode_studio.delay( + recording_id, video_output, videos, subtime, presenter + ) + else: + log.info("START ENCODE VIDEO ID %s" % recording_id) + t = threading.Thread( + target=encode_video_studio, + args=[recording_id, video_output, videos, subtime, presenter], + ) + t.daemon = True + t.start() + else: + encode_video_studio(recording_id, video_output, videos, subtime, presenter) def encode_video_studio(recording_id, video_output, videos, subtime, presenter) -> None: diff --git a/pod/video_encode_transcript/encoding_studio.py b/pod/video_encode_transcript/encoding_studio.py index 6e8fb60631..8b64dd1b46 100644 --- a/pod/video_encode_transcript/encoding_studio.py +++ b/pod/video_encode_transcript/encoding_studio.py @@ -1,27 +1,17 @@ """This module handles studio encoding with CPU.""" -import time -import subprocess import json +import subprocess +import time if __name__ == "__main__": - from encoding_settings import ( - FFMPEG_CMD, - FFPROBE_CMD, - FFMPEG_CRF, - FFMPEG_NB_THREADS, - FFPROBE_GET_INFO, - FFMPEG_STUDIO_COMMAND, - ) + from encoding_settings import (FFMPEG_CMD, FFMPEG_CRF, FFMPEG_NB_THREADS, + FFMPEG_STUDIO_COMMAND, FFPROBE_CMD, + FFPROBE_GET_INFO) else: - from .encoding_settings import ( - FFMPEG_CMD, - FFPROBE_CMD, - FFMPEG_CRF, - FFMPEG_NB_THREADS, - FFPROBE_GET_INFO, - FFMPEG_STUDIO_COMMAND, - ) + from .encoding_settings import (FFMPEG_CMD, FFMPEG_CRF, FFMPEG_NB_THREADS, + FFMPEG_STUDIO_COMMAND, FFPROBE_CMD, + FFPROBE_GET_INFO) try: from django.conf import settings @@ -171,7 +161,10 @@ def launch_encode_video_studio(input_video, subtime, subcmd, video_output): msg += "- %s\n" % ffmpegStudioCommand logfile = video_output.replace(".mp4", ".log") ffmpegstudio = subprocess.run( - ffmpegStudioCommand, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ffmpegStudioCommand, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, ) with open(logfile, "ab") as f: f.write(b"\n\ffmpegstudio:\n\n") diff --git a/pod/video_encode_transcript/encoding_tasks.py b/pod/video_encode_transcript/encoding_tasks.py index afd057d47e..b4534e6fef 100644 --- a/pod/video_encode_transcript/encoding_tasks.py +++ b/pod/video_encode_transcript/encoding_tasks.py @@ -1,8 +1,9 @@ """Esup-Pod encoding tasks.""" -from celery import Celery import logging + import requests +from celery import Celery # call local settings directly # no need to load pod application to send statement diff --git a/pod/video_encode_transcript/encoding_utils.py b/pod/video_encode_transcript/encoding_utils.py index 632f3ecaff..803b70e51c 100644 --- a/pod/video_encode_transcript/encoding_utils.py +++ b/pod/video_encode_transcript/encoding_utils.py @@ -1,11 +1,10 @@ -from collections import OrderedDict -from timeit import default_timer as timer - import json +import logging import os import shlex import subprocess -import logging +from collections import OrderedDict +from timeit import default_timer as timer try: from .encoding_settings import VIDEO_RENDITIONS @@ -55,13 +54,15 @@ def get_dressing_position_value(position: str, height: str) -> str: return "overlay=main_w-overlay_w-" + height + ":main_h-overlay_h-" + height elif position == "bottom_left": return "overlay=" + height + ":main_h-overlay_h-" + height + return "" def get_renditions(): try: - from .models import VideoRendition from django.core import serializers + from .models import VideoRendition + renditions = json.loads( serializers.serialize("json", VideoRendition.objects.all()) ) diff --git a/pod/video_encode_transcript/management/commands/import_encode_video.py b/pod/video_encode_transcript/management/commands/import_encode_video.py index 6f7aa00534..e781dd4b84 100644 --- a/pod/video_encode_transcript/management/commands/import_encode_video.py +++ b/pod/video_encode_transcript/management/commands/import_encode_video.py @@ -1,7 +1,10 @@ from django.core.management.base import BaseCommand -from pod.video_encode_transcript.Encoding_video_model import Encoding_video_model -from pod.video_encode_transcript.encode import store_encoding_info, end_of_encoding + from pod.video.models import Video +from pod.video_encode_transcript.encode import (end_of_encoding, + store_encoding_info) +from pod.video_encode_transcript.Encoding_video_model import \ + Encoding_video_model class Command(BaseCommand): @@ -16,4 +19,6 @@ def handle(self, *args, **options) -> None: encoding_video = Encoding_video_model(video_id, vid.video.path) final_video = store_encoding_info(video_id, encoding_video) end_of_encoding(final_video) - self.stdout.write(self.style.SUCCESS('Successfully import video "%s"' % video_id)) + self.stdout.write( + self.style.SUCCESS('Successfully import video "%s"' % video_id) + ) diff --git a/pod/video_encode_transcript/management/commands/process_tasks.py b/pod/video_encode_transcript/management/commands/process_tasks.py new file mode 100644 index 0000000000..90c80b3bac --- /dev/null +++ b/pod/video_encode_transcript/management/commands/process_tasks.py @@ -0,0 +1,929 @@ +""" +Django management command that orchestrates Runner Manager tasks. + +This command is intended to run periodically (typically via cron) and keeps +the remote-processing pipeline healthy across three task types: +`encoding`, `transcription`, and `studio`. + +Execution flow: +1. Resolve the target Django `Site` (current site by default, or `--site`). +2. Detect "stalled" tasks: + - Looks for tasks still marked `running` in Pod for more than 2 hours. + - Queries the remote runner (`task/status/`). + - If the remote status is `completed` or `warning`, it triggers result + retrieval so Pod can finalize local state. +3. Refresh rank metadata for pending tasks. +4. Load pending tasks by type: + - `encoding` and `transcription` are sorted by user priority + (non-students first), then by submission date. + - `studio` tasks are processed by submission date. + - Each type is capped by `--max-tasks`. +5. Submit tasks to available runner managers (ordered by manager priority): + - Build payload (`source_url`, `notify_url`, parameters, metadata). + - Try each runner until one accepts the task. + - Persist `task_id`, `runner_manager`, and returned status in Pod. +6. Refresh ranks again and clean old completed tasks according to + `RM_TASKS_DELETED_AFTER_DAYS`. + +Important behavior: +- If no runner manager exists for the site, submission is skipped. +- Network/API errors on one runner do not stop processing; the command tries +the next configured runner. +- Cleanup is skipped when `RM_TASKS_DELETED_AFTER_DAYS` is missing, invalid, + or <= 0. + +CLI: +- `python manage.py process_tasks` +- `python manage.py process_tasks --max-tasks 20 --site example.org` + +Example cron (every 3 minutes): +`*/3 * * * * /usr/bin/bash -c 'export WORKON_HOME=/home/pod/.virtualenvs; export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3; cd /usr/local/django_projects/podv4; source /usr/local/bin/virtualenvwrapper.sh; workon django_pod4; python manage.py process_tasks >> /usr/local/django_projects/podv4/pod/log/process_tasks.log 2>&1'` +""" + +import json +import logging +from datetime import timedelta + +import requests +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand +from django.utils import timezone + +from pod.cut.models import CutVideo +from pod.dressing.models import Dressing +from pod.recorder.models import Recording +from pod.video.models import Video +from pod.video_encode_transcript.models import RunnerManager, Task +from pod.video_encode_transcript.runner_manager_utils import ( + store_before_remote_encoding_recording, + store_before_remote_encoding_video, +) +from pod.video_encode_transcript.task_queue import ( + get_user_priority, + refresh_pending_task_ranks, +) +from pod.video_encode_transcript.views import download_and_import_task_result + +log = logging.getLogger(__name__) + + +def handle_stalled_task(task: Task, status: str) -> None: + """ + Handle a task that is still running in Pod but completed by the runner manager. + + Args: + task: Task object + status: Current status from runner manager + """ + # Problem found: task not completed on Pod side but completed on runner manager + # Retrieve data from runner manager + log.info( + f"Task {task.id} is still running on Pod side, but {status} on runner manager side, retrieving data" + ) + download_and_import_task_result(task) + + +class Command(BaseCommand): + help = "Process encoding tasks: check running tasks and submit pending tasks to runner managers" + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--max-tasks", + type=int, + default=20, + help="Maximum number of pending tasks to process in one run (default: 20)", + ) + parser.add_argument( + "--site", + type=str, + default=None, + help="Site domain to filter tasks (default: current site)", + ) + + def print_log(self, message: str) -> None: + """ + Print a plain log message to command stdout. + + Args: + message: Message to display + + Returns: + None + """ + self.stdout.write(message) + + def print_warning(self, message: str) -> None: + """ + Print a warning-styled message to command stdout. + + Args: + message: Warning message to display + + Returns: + None + """ + self.stdout.write(self.style.WARNING(message)) + + def print_error(self, message: str) -> None: + """ + Print an error-styled message to command stdout. + + Args: + message: Error message to display + + Returns: + None + """ + self.stdout.write(self.style.ERROR(message)) + + def print_success(self, message: str) -> None: + """ + Print a success-styled message to command stdout. + + Args: + message: Success message to display + + Returns: + None + """ + self.stdout.write(self.style.SUCCESS(message)) + + def _sort_tasks_by_priority(self, all_pending_tasks, max_tasks: int) -> list: + """ + Sort encoding tasks by priority (non-students first) and limit to max_tasks. + + Args: + all_pending_tasks: QuerySet of all pending tasks + max_tasks: Maximum number of tasks to return + + Returns: + list: List of tasks sorted by priority and limited to max_tasks + """ + tasks_with_priority = [] + for task in all_pending_tasks: + try: + priority = get_user_priority(task.video) if task.video else 1 + tasks_with_priority.append((priority, task)) + except Video.DoesNotExist: + log.warning(f"Video {task.video_id} not found for task {task.id}") + # Still add the task with default priority to avoid skipping it + tasks_with_priority.append((1, task)) + + # Sort by priority (1 first, then 2) and date_added, then limit + tasks_with_priority.sort(key=lambda x: (x[0], x[1].date_added)) + return [task for _, task in tasks_with_priority[:max_tasks]] + + def _get_site(self, site_domain: str | None) -> Site | None: + """ + Get the site object based on domain or return current site. + + Args: + site_domain: Domain name or None for current site + + Returns: + Site object or None if not found + """ + if site_domain: + try: + return Site.objects.get(domain=site_domain) + except Site.DoesNotExist: + self.print_error(f"Site {site_domain} not found") + return None + return Site.objects.get_current() + + def _check_task_status(self, task: Task) -> str | None: + """ + Check the status of a running task from the runner manager. + + Args: + task: Task object with runner_manager and task_id + + Returns: + str: Status from runner manager or None if check failed + """ + if not task.runner_manager or not task.task_id: + log.warning(f"Task {task.id} has no runner_manager or task_id") + return None + + try: + status_url = task.runner_manager.url + if not status_url.endswith("/"): + status_url += "/" + status_url += f"task/status/{task.task_id}" + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {task.runner_manager.token}", + } + + response = requests.get(status_url, headers=headers, timeout=30) + + if response.status_code == 200: + data = response.json() + status = data.get("status") + log.info(f"Task {task.id} status from runner: {status}") + return status + else: + log.warning( + f"Failed to get status for task {task.id}: " + f"HTTP {response.status_code}" + ) + return None + except requests.RequestException as exc: + log.warning(f"Cannot reach runner manager for task {task.id}: {str(exc)}") + return None + except Exception as exc: + log.error(f"Error checking status for task {task.id}: {str(exc)}") + return None + + def _check_running_tasks(self, site: Site) -> None: + """ + Check running tasks that have been running for more than 2 hours. + + Args: + site: Site object to filter tasks + """ + two_hours_ago = timezone.now() - timedelta(hours=2) + + # Get tasks that are running for more than 2 hours (encoding + transcription) + stalled_tasks = Task.objects.filter( + type__in=["encoding", "transcription"], + status="running", + date_added__lt=two_hours_ago, + ).select_related("runner_manager") + + if not stalled_tasks: + self.print_log("No stalled running tasks found") + return + + self.print_log( + f"Found {stalled_tasks.count()} task(s) running for more than 2 hours" + ) + + for task in stalled_tasks: + self.print_log(f"Checking status of task {task.id}...") + status = self._check_task_status(task) + + # Handle based on status + if status == "completed" or status == "warning": + # Problem found: task not completed on Pod side + # Retrieve data from runner manager + self.print_warning( + f"Task {task.id} is {status}, retrieving data from runner manager" + ) + handle_stalled_task(task, status) + elif status == "running": + # Still running, wait longer + self.print_success( + f"Task {task.id} is still {status}, waiting longer before taking action" + ) + elif status: + # Still not completed, no action taken + self.print_warning(f"Task {task.id} is still {status}, no action taken") + else: + self.print_error(f"Could not verify status of task {task.id}") + + def _process_tasks( + self, pending_tasks: list, site: Site, runner_managers: list + ) -> int: + """ + Process each pending encoding task and submit to runner managers. + + Args: + pending_tasks: List of tasks to process + site: Site object + runner_managers: List of available runner managers + + Returns: + int: Number of successfully submitted tasks + """ + success_count = 0 + for task in pending_tasks: + try: + video = Video.objects.get(id=task.video_id) + priority = get_user_priority(video) + priority_label = "LOW (student)" if priority == 2 else "HIGH" + self.print_log( + f"Processing task {task.id} for video {video.id} - Priority: {priority_label}" + ) + result = self._submit_encoding_task(video, task, site, runner_managers) + if result: + success_count += 1 + self.print_success( + f"Successfully submitted encoding task for video {video.id}" + ) + else: + self.print_warning( + f"Could not submit encoding task for video {video.id} (no runner available)" + ) + except Video.DoesNotExist: + self.print_error(f"Video {task.video_id} not found for task {task.id}") + except Exception as exc: + self.print_error( + f"Error processing task {task.id} for video {task.video_id}: {str(exc)}" + ) + return success_count + + def _process_studio_tasks( + self, pending_tasks: list, site: Site, runner_managers: list + ) -> int: + """ + Process each pending studio task (Recording) and submit to runner managers. + + Args: + pending_tasks: List of studio tasks to process + site: Site object + runner_managers: List of available runner managers + + Returns: + int: Number of successfully submitted tasks + """ + success_count = 0 + for task in pending_tasks: + try: + recording = Recording.objects.get(id=task.recording_id) + self.print_log( + f"Processing studio task {task.id} for recording {recording.id}" + ) + result = self._submit_studio_task( + recording, task, site, runner_managers + ) + if result: + success_count += 1 + self.print_success( + f"Successfully submitted studio task for recording {recording.id}" + ) + else: + self.print_warning( + f"Could not submit studio task for recording {recording.id} (no runner available)" + ) + except Recording.DoesNotExist: + self.print_error( + f"Recording {task.recording_id} not found for task {task.id}" + ) + except Exception as exc: + self.print_error( + f"Error processing studio task {task.id} for recording {task.recording_id}: {str(exc)}" + ) + return success_count + + def _process_transcription_tasks( + self, pending_tasks: list, site: Site, runner_managers: list + ) -> int: + """ + Process pending transcription tasks and submit them to runner managers. + + Args: + pending_tasks: List of tasks to process + site: Current site + runner_managers: List of available runner managers + + Returns: + int: Number of successfully submitted tasks + """ + success_count = 0 + for task in pending_tasks: + try: + video = Video.objects.get(id=task.video_id) + priority = get_user_priority(video) + priority_label = "LOW (student)" if priority == 2 else "HIGH" + self.print_log( + f"Processing transcription task {task.id} for video {video.id} - Priority: {priority_label}" + ) + result = self._submit_transcription_task( + video, task, site, runner_managers + ) + if result: + success_count += 1 + self.print_success( + f"Successfully submitted transcription task for video {video.id}" + ) + else: + self.print_warning( + f"Could not submit transcription task for video {video.id} (no runner available)" + ) + except Video.DoesNotExist: + self.print_error(f"Video {task.video_id} not found for task {task.id}") + except Exception as exc: + self.print_error( + f"Error processing transcription task {task.id} for video {task.video_id}: {str(exc)}" + ) + return success_count + + def _delete_old_completed_tasks(self) -> int: + """ + Delete completed tasks older than RM_TASKS_DELETED_AFTER_DAYS days. + + Returns: + int: Number of deleted tasks + """ + retention_setting = getattr(settings, "RM_TASKS_DELETED_AFTER_DAYS", None) + + if retention_setting is None: + self.print_log("Skipping cleanup: RM_TASKS_DELETED_AFTER_DAYS is not set") + return 0 + + try: + retention_days = int(retention_setting) + except (TypeError, ValueError): + self.print_error("RM_TASKS_DELETED_AFTER_DAYS must be an integer") + return 0 + + if retention_days <= 0: + self.print_log("Skipping cleanup: RM_TASKS_DELETED_AFTER_DAYS is <= 0") + return 0 + + cutoff_date = timezone.now() - timedelta(days=retention_days) + old_tasks = Task.objects.filter(status="completed", date_added__lt=cutoff_date) + deleted_count = old_tasks.count() + + if deleted_count: + old_tasks.delete() + self.print_success( + f"Deleted {deleted_count} completed task(s) older than {retention_days} day(s)" + ) + log.info( + "Deleted %s completed task(s) older than %s day(s)", + deleted_count, + retention_days, + ) + else: + self.print_log("No completed tasks to delete") + + return deleted_count + + def handle(self, *args, **options) -> None: + max_tasks = options["max_tasks"] + site_domain = options["site"] + + # Get site + site = self._get_site(site_domain) + if not site: + return + + self.print_log(f"Processing tasks for site: {site.domain}") + self.print_log("=" * 60) + + # First, check running tasks that might be stalled + self.print_log("\n1. Checking running tasks...") + self._check_running_tasks(site) + + # Then, process pending tasks + self.print_log("\n2. Processing pending encoding tasks...") + refresh_pending_task_ranks() + + # Get pending encoding tasks (without limiting to max_tasks yet) + all_pending_tasks = ( + Task.objects.filter( + type="encoding", + status="pending", + ) + .select_related("video", "video__owner", "video__owner__owner") + .order_by("date_added") + ) + + # Also get pending studio tasks + all_pending_studio_tasks = ( + Task.objects.filter( + type="studio", + status="pending", + ) + .select_related("recording") + .order_by("date_added") + ) + + # Also get pending transcription tasks + all_pending_transcription_tasks = ( + Task.objects.filter( + type="transcription", + status="pending", + ) + .select_related("video", "video__owner", "video__owner__owner") + .order_by("date_added") + ) + + if ( + not all_pending_tasks + and not all_pending_studio_tasks + and not all_pending_transcription_tasks + ): + self.print_success( + "No pending tasks found (encoding, transcription or studio)" + ) + self.print_log("\n3. Cleaning completed tasks...") + self._delete_old_completed_tasks() + return + + self.print_log(f"Found {all_pending_tasks.count()} pending encoding task(s)") + self.print_log( + f"Found {all_pending_studio_tasks.count()} pending studio task(s)" + ) + self.print_log( + f"Found {all_pending_transcription_tasks.count()} pending transcription task(s)" + ) + + # Sort tasks by priority (students last) and limit to max_tasks + pending_tasks = self._sort_tasks_by_priority(all_pending_tasks, max_tasks) + pending_studio_tasks = list(all_pending_studio_tasks[:max_tasks]) + pending_transcription_tasks = self._sort_tasks_by_priority( + all_pending_transcription_tasks, max_tasks + ) + + self.print_log( + f"Processing {len(pending_tasks)} task(s) after priority sorting" + ) + + # Get available runner managers for this site + runner_managers = list( + RunnerManager.objects.filter(site=site).order_by("priority") + ) + + if not runner_managers: + self.print_warning( + f"No runner manager defined for site {site.domain}. Cannot process tasks." + ) + return + + # Process each pending task + success_count_encoding = self._process_tasks( + pending_tasks, site, runner_managers + ) + success_count_studio = self._process_studio_tasks( + pending_studio_tasks, site, runner_managers + ) + success_count_transcription = self._process_transcription_tasks( + pending_transcription_tasks, site, runner_managers + ) + refresh_pending_task_ranks() + + self.print_log("\n3. Cleaning completed tasks...") + self._delete_old_completed_tasks() + + self.print_success( + f"Completed: encoding {success_count_encoding}/{len(pending_tasks)}; " + f"transcription {success_count_transcription}/{len(pending_transcription_tasks)}; " + f"studio {success_count_studio}/{len(pending_studio_tasks)} successfully submitted" + ) + + def _submit_encoding_task( + self, video: Video, task: Task, site: Site, runner_managers: list + ) -> bool: + """ + Try to submit an encoding task to available runner managers. + Returns True if successful, False otherwise. + """ + VERSION = getattr(settings, "VERSION", "4.X") + TEMPLATE_VISIBLE_SETTINGS = getattr( + settings, + "TEMPLATE_VISIBLE_SETTINGS", + { + "TITLE_SITE": "Pod", + "TITLE_ETB": "University name", + }, + ) + __TITLE_SITE__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE", "Pod") + __TITLE_ETB__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_ETB", "University name") + + base_url = self._build_base_url(site) + content_url = self._build_content_url(video, base_url) + parameters = self._prepare_encoding_parameters(video, base_url) + + data = { + "etab_name": f"{__TITLE_ETB__} / {__TITLE_SITE__}", + "app_name": "Esup-Pod", + "app_version": f"{VERSION}", + "task_type": "encoding", + "source_url": f"{content_url}", + "notify_url": f"{base_url}/runner/notify_task_end/", + "parameters": parameters, + } + + return self._post_encoding_to_runner(data, task, video, runner_managers) + + def _build_base_url(self, site: Site) -> str: + SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False) + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + return f"{url_scheme}://{site.domain}" + + def _build_content_url(self, video: Video, base_url: str) -> str: + return "%s/media/%s" % (base_url, video.video) + + def _prepare_encoding_parameters(self, video: Video, base_url: str) -> dict: + from pod.video_encode_transcript.encoding_utils import get_list_rendition + + list_rendition = get_list_rendition() + str_resolution = { + str(k): {"resolution": v["resolution"], "encode_mp4": v["encode_mp4"]} + for k, v in list_rendition.items() + } + parameters = {"rendition": json.dumps(str_resolution)} + + cut_info = self._get_cut_info(video) + if cut_info: + parameters["cut"] = json.dumps(cut_info) + + dressing_info = self._get_dressing_info(video, base_url) + if dressing_info: + parameters["dressing"] = json.dumps(dressing_info) + + return parameters + + def _get_cut_info(self, video: Video) -> dict | None: + try: + cut_video = CutVideo.objects.get(video=video) + return { + "start": str(cut_video.start), + "end": str(cut_video.end), + "initial_duration": str(cut_video.duration), + } + except CutVideo.DoesNotExist: + return None + + def _get_dressing_info(self, video: Video, base_url: str) -> dict | None: + try: + if not Dressing.objects.filter(videos=video).exists(): + return None + dressing = Dressing.objects.get(videos=video) + if not dressing: + return None + str_dressing_info: dict = {} + if dressing.watermark: + watermark_content_url = "%s/media/%s" % ( + base_url, + str(dressing.watermark.file.name), + ) + str_dressing_info["watermark"] = watermark_content_url + str_dressing_info["watermark_position"] = dressing.position + str_dressing_info["watermark_opacity"] = str(dressing.opacity) + if dressing.opening_credits: + str_dressing_info["opening_credits"] = dressing.opening_credits.slug + opening_content_url = "%s/media/%s" % ( + base_url, + str(dressing.opening_credits.video.name), + ) + str_dressing_info["opening_credits_video"] = opening_content_url + str_dressing_info["opening_credits_video_duration"] = str( + dressing.opening_credits.duration + ) + if dressing.ending_credits: + str_dressing_info["ending_credits"] = dressing.ending_credits.slug + ending_content_url = "%s/media/%s" % ( + base_url, + str(dressing.ending_credits.video.name), + ) + str_dressing_info["ending_credits_video"] = ending_content_url + str_dressing_info["ending_credits_video_duration"] = str( + dressing.ending_credits.duration + ) + return str_dressing_info or None + except Exception as exc: + log.warning( + "Error retrieving dressing for video id: %s, %s", video.id, str(exc) + ) + return None + + def _post_encoding_to_runner( + self, data: dict, task: Task, video: Video, runner_managers: list + ) -> bool: + for runner_manager in runner_managers: + try: + execute_url = runner_manager.url + if not execute_url.endswith("/"): + execute_url += "/" + execute_url += "task/execute" + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {runner_manager.token}", + } + + response = requests.post( + execute_url, data=json.dumps(data), headers=headers, timeout=30 + ) + + if response.status_code == 200: + task_id = response.json().get("task_id") + status = response.json().get("status") + + task.status = status + task.runner_manager = runner_manager + task.task_id = task_id + task.save() + store_before_remote_encoding_video(video.id, execute_url, data) + + log.info( + f"Successfully submitted encoding task for video {video.id} to runner manager {runner_manager.name}" + ) + return True + log.warning( + f"Runner manager {runner_manager.name} returned status code {response.status_code}" + ) + except requests.RequestException as exc: + log.warning( + f"Cannot reach runner manager {runner_manager.name}: {str(exc)}" + ) + + return False + + def _submit_transcription_task( + self, video: Video, task: Task, site: Site, runner_managers: list + ) -> bool: + """ + Try to submit a transcription task to available runner managers. + Returns True if successful, False otherwise. + """ + from pod.video_encode_transcript.transcript import ( + resolve_transcription_language, + ) + + # Get settings + SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False) + VERSION = getattr(settings, "VERSION", "4.X") + TEMPLATE_VISIBLE_SETTINGS = getattr( + settings, + "TEMPLATE_VISIBLE_SETTINGS", + { + "TITLE_SITE": "Pod", + "TITLE_ETB": "University name", + }, + ) + __TITLE_SITE__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE", "Pod") + __TITLE_ETB__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_ETB", "University name") + + # Build content URL: prefer mp3 if available, else video file + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + mp3 = video.get_video_mp3() if hasattr(video, "get_video_mp3") else None + if mp3 and getattr(mp3, "source_file", None) and getattr(mp3, "url", None): + content_url = f"{base_url}{mp3.url}" + else: + content_url = f"{base_url}/media/{video.video}" + + # Prepare transcription parameters (aligned with runner_manager._prepare_transcription_parameters) + transcription_type = getattr(settings, "TRANSCRIPTION_TYPE", None) + normalize = bool(getattr(settings, "TRANSCRIPTION_NORMALIZE", False)) + params = { + "language": resolve_transcription_language(video), + "duration": float(getattr(video, "duration", 0) or 0), + "normalize": normalize, + } + if transcription_type: + params["model_type"] = transcription_type + + data = { + "etab_name": f"{__TITLE_ETB__} / {__TITLE_SITE__}", + "app_name": "Esup-Pod", + "app_version": f"{VERSION}", + "task_type": "transcription", + "source_url": f"{content_url}", + "notify_url": f"{base_url}/runner/notify_task_end/", + "parameters": params, + } + + # Try each runner manager + for runner_manager in runner_managers: + try: + execute_url = runner_manager.url + if not execute_url.endswith("/"): + execute_url += "/" + execute_url += "task/execute" + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {runner_manager.token}", + } + + response = requests.post( + execute_url, data=json.dumps(data), headers=headers, timeout=30 + ) + + if response.status_code == 200: + task_id = response.json().get("task_id") + status = response.json().get("status") + + # Update task + task.status = status + task.runner_manager = runner_manager + task.task_id = task_id + task.save() + + log.info( + f"Successfully submitted transcription task for video {video.id} to runner manager {runner_manager.name}" + ) + return True + else: + log.warning( + f"Runner manager {runner_manager.name} returned status code {response.status_code}" + ) + except requests.RequestException as exc: + log.warning( + f"Cannot reach runner manager {runner_manager.name}: {str(exc)}" + ) + + return False + + def _submit_studio_task( + self, recording: Recording, task: Task, site: Site, runner_managers: list + ) -> bool: + """ + Try to submit a studio task (recording XML link) to available runner managers. + Returns True if successful, False otherwise. + """ + # Get settings + SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False) + VERSION = getattr(settings, "VERSION", "4.X") + TEMPLATE_VISIBLE_SETTINGS = getattr( + settings, + "TEMPLATE_VISIBLE_SETTINGS", + { + "TITLE_SITE": "Pod", + "TITLE_ETB": "University name", + }, + ) + __TITLE_SITE__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE", "Pod") + __TITLE_ETB__ = TEMPLATE_VISIBLE_SETTINGS.get("TITLE_ETB", "University name") + + # Build source XML URL from MEDIA_ROOT to MEDIA_URL + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + media_url = getattr(settings, "MEDIA_URL", "/media/").rstrip("/") + try: + import os + + rel_path = os.path.relpath( + str(recording.source_file), str(getattr(settings, "MEDIA_ROOT", "")) + ) + except Exception: + rel_path = str(recording.source_file) + rel_path = rel_path.lstrip("/") + source_url = f"{base_url}{media_url}/{rel_path}" + + # Parameters: same rendition payload as for video, no cut + from pod.video_encode_transcript.encoding_utils import get_list_rendition + + list_rendition = get_list_rendition() + str_resolution = { + str(k): {"resolution": v["resolution"], "encode_mp4": v["encode_mp4"]} + for k, v in list_rendition.items() + } + json_resolution = json.dumps(str_resolution) + parameters = {"rendition": json_resolution} + + data = { + "etab_name": f"{__TITLE_ETB__} / {__TITLE_SITE__}", + "app_name": "Esup-Pod", + "app_version": f"{VERSION}", + "task_type": "studio", + "source_url": f"{source_url}", + "notify_url": f"{base_url}/runner/notify_task_end/", + "parameters": parameters, + } + + # Try each runner manager + for runner_manager in runner_managers: + try: + execute_url = runner_manager.url + if not execute_url.endswith("/"): + execute_url += "/" + execute_url += "task/execute" + + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {runner_manager.token}", + } + + response = requests.post( + execute_url, data=json.dumps(data), headers=headers, timeout=30 + ) + + if response.status_code == 200: + task_id = response.json().get("task_id") + status = response.json().get("status") + + # Update task with studio type + task.status = status + task.runner_manager = runner_manager + task.task_id = task_id + task.save() + store_before_remote_encoding_recording( + recording.id, execute_url, data + ) + + log.info( + f"Successfully submitted studio task for recording {recording.id} to runner manager {runner_manager.name}" + ) + return True + else: + log.warning( + f"Runner manager {runner_manager.name} returned status code {response.status_code}" + ) + except requests.RequestException as exc: + log.warning( + f"Cannot reach runner manager {runner_manager.name}: {str(exc)}" + ) + + return False diff --git a/pod/video_encode_transcript/management/commands/test_encode_transcript.py b/pod/video_encode_transcript/management/commands/test_encode_transcript.py index 6482fddaa1..8912b4b292 100644 --- a/pod/video_encode_transcript/management/commands/test_encode_transcript.py +++ b/pod/video_encode_transcript/management/commands/test_encode_transcript.py @@ -1,19 +1,18 @@ -from django.core.management.base import BaseCommand, CommandError +import os +import shutil +import time +import coverage from django.conf import settings -from django.core.files.temp import NamedTemporaryFile from django.contrib.auth.models import User +from django.core.files.temp import NamedTemporaryFile +from django.core.management.base import BaseCommand, CommandError from rest_framework.authtoken.models import Token -from pod.video.models import Video, Type -from pod.video_encode_transcript import encode -from pod.video_encode_transcript.models import EncodingVideo -from pod.video_encode_transcript.models import PlaylistVideo -from pod.completion.models import Track -import shutil -import os -import time -import coverage +from pod.completion.models import Track +from pod.video.models import Type, Video +from pod.video_encode_transcript import encode +from pod.video_encode_transcript.models import EncodingVideo, PlaylistVideo VIDEO_TEST = "pod/main/static/video_test/video_test_encodage_transcription.webm" ENCODE_VIDEO = getattr(settings, "ENCODE_VIDEO", "start_encode") @@ -115,7 +114,9 @@ def test_result_encoding(self, video) -> None: video=video, encoding_format="application/x-mpegURL", ) - list_mp4 = EncodingVideo.objects.filter(video=video, encoding_format="video/mp4") + list_mp4 = EncodingVideo.objects.filter( + video=video, encoding_format="video/mp4" + ) if not len(list_mp2t) > 0: raise CommandError("no video/mp2t found") if not len(list_mp2t) + 1 == len(list_playlist_video): diff --git a/pod/video_encode_transcript/models.py b/pod/video_encode_transcript/models.py index c3c7187f03..e3694f34f6 100644 --- a/pod/video_encode_transcript/models.py +++ b/pod/video_encode_transcript/models.py @@ -2,14 +2,17 @@ import os -from django.db import models from django.conf import settings -from django.utils.translation import gettext_lazy as _ +from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator -from django.contrib.sites.models import Site +from django.db import models +from django.db.models.signals import post_save, pre_save from django.dispatch import receiver -from django.db.models.signals import post_save +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from pod.recorder.models import Recording from pod.video.models import Video, get_storage_path_video ENCODING_CHOICES = getattr( @@ -38,6 +41,8 @@ ), ) +SITE_ID = getattr(settings, "SITE_ID", 1) + class VideoRendition(models.Model): """Model representing the rendition video.""" @@ -80,7 +85,9 @@ class VideoRendition(models.Model): ), ) encoding_resolution_threshold = models.PositiveIntegerField( - _("encoding resolution threshold"), default=0, validators=[MaxValueValidator(100)] + _("encoding resolution threshold"), + default=0, + validators=[MaxValueValidator(100)], ) audio_bitrate = models.CharField( _("bitrate audio"), @@ -129,7 +136,8 @@ def bitrate(self, field_value, field_name, name=None) -> None: if not vb.isdigit(): msg = "Error in %s: " % _(name) raise ValidationError( - "%s %s" % (msg, VideoRendition._meta.get_field(field_name).help_text) + "%s %s" + % (msg, VideoRendition._meta.get_field(field_name).help_text) ) def clean_bitrate(self) -> None: @@ -141,7 +149,9 @@ def clean_bitrate(self) -> None: def clean(self) -> None: """Clean the fields of the VideoRendition model.""" if self.resolution and "x" not in self.resolution: - raise ValidationError(VideoRendition._meta.get_field("resolution").help_text) + raise ValidationError( + VideoRendition._meta.get_field("resolution").help_text + ) else: res = self.resolution.replace("x", "") if not res.isdigit(): @@ -440,3 +450,190 @@ def delete(self) -> None: if os.path.isfile(self.source_file.path): os.remove(self.source_file.path) super(PlaylistVideo, self).delete() + + +class RunnerManager(models.Model): + """Hold information about runner manager.""" + + # Runner manager name + name = models.CharField( + max_length=250, + verbose_name=_("Runner manager name"), + help_text=_("Runner manager name"), + ) + + # Priority + priority = models.IntegerField( + verbose_name=_("Priority"), + help_text=_( + "Priority of the runner manager. Lower values indicate higher priority." + ), + default=1, + ) + + # Runner manager URL + # Format: https://manager.univ.fr:port/ + url = models.CharField( + max_length=250, + verbose_name=_("URL of the runner manager"), + help_text=_("Example format: https://manager.univ.fr:port/"), + ) + + # Bearer token for the runner manager server (e.g. `6YqG_73xt-9s8v5aBz`) + token = models.CharField( + max_length=50, + verbose_name=_("Bearer token for the runner manager."), + help_text=_("Example format: 6YqG_73xt-9s8v5aBz"), + ) + + # Site + site = models.ForeignKey( + Site, + verbose_name=_("Site"), + on_delete=models.CASCADE, + default=1, + ) + + def __unicode__(self): + return "%s (%s)" % (self.name, self.site.id) + + def __str__(self): + return "%s (%s)" % (self.name, self.site.id) + + def save(self, *args, **kwargs): + super(RunnerManager, self).save(*args, **kwargs) + + class Meta: + db_table = "runner_manager" + verbose_name = _("Runner manager") + verbose_name_plural = _("Runner managers") + constraints = [ + models.UniqueConstraint( + fields=["url", "site"], + name="runner_manager_unique_url_site", + ), + ] + + +@receiver(pre_save, sender=RunnerManager) +def default_site_runner_manager(sender, instance, **kwargs): + """Save default site for this runner manager.""" + if not hasattr(instance, "site"): + instance.site = Site.objects.get_current() + + +class Task(models.Model): + """Hold information about tasks managed by the runner managers.""" + + # Task type + TYPE = ( + ("encoding", _("Encoding task")), + ("studio", _("Studio task")), + ("transcription", _("Transcription task")), + ) + type = models.CharField( + max_length=30, + verbose_name=_("Task type"), + choices=TYPE, + default=TYPE[0][0], + ) + + # Task status + STATUS = ( + ("pending", _("Task pending")), + ("running", _("Task in progress")), + ("completed", _("Task completed")), + ("failed", _("Task failed")), + ("timeout", _("Task timeouted")), + ) + status = models.CharField( + max_length=30, + verbose_name=_("Task status"), + choices=STATUS, + default=STATUS[0][0], + ) + # Task identifier from runner manager + task_id = models.CharField( + max_length=100, + verbose_name=_("Task identifier from runner manager"), + help_text=_("Identifier of the task provided by the runner manager"), + null=True, + blank=True, + ) + + # Video associated to the task + video = models.ForeignKey( + Video, + on_delete=models.CASCADE, + verbose_name=_("Video"), + help_text=_("Video associated to the task"), + null=True, + blank=True, + ) + + # Recording associated to the task (for Studio tasks) + recording = models.ForeignKey( + Recording, + on_delete=models.CASCADE, + verbose_name=_("Recording"), + help_text=_("Studio recording associated to the task"), + null=True, + blank=True, + ) + + # Runner manager that manages this task + runner_manager = models.ForeignKey( + RunnerManager, + on_delete=models.CASCADE, + verbose_name=_("Runner manager that manages this task"), + help_text=_("Runner manager that achieves this task"), + null=True, + blank=True, + ) + + # Date task added + date_added = models.DateTimeField( + verbose_name=_("Date added"), default=timezone.now, editable=False + ) + + # Queue rank for pending tasks + rank = models.IntegerField( + verbose_name=_("Queue rank"), + help_text=_("Rank of the task in the pending queue"), + null=True, + blank=True, + default=None, + ) + + # Script output + script_output = models.TextField( + verbose_name=_("Script output"), + help_text=_("Output from the runner manager script"), + null=True, + blank=True, + ) + + def __unicode__(self): + ref = ( + self.video.id + if self.video_id + else (self.recording.id if self.recording_id else "-") + ) + return "%s - %s - %s" % (ref, self.type, self.status) + + def __str__(self): + ref = ( + self.video.id + if self.video_id + else (self.recording.id if self.recording_id else "-") + ) + return "%s - %s - %s" % (ref, self.type, self.status) + + def save(self, *args, **kwargs): + super(Task, self).save(*args, **kwargs) + + class Meta: + db_table = "runner_manager_task" + verbose_name = _("Task") + verbose_name_plural = _("Tasks") + ordering = ["id"] diff --git a/pod/video_encode_transcript/rest_views.py b/pod/video_encode_transcript/rest_views.py index dbb6d8adf4..ce596f844d 100644 --- a/pod/video_encode_transcript/rest_views.py +++ b/pod/video_encode_transcript/rest_views.py @@ -1,22 +1,21 @@ -from django.conf import settings -from .models import EncodingVideo, EncodingAudio, VideoRendition, PlaylistVideo -from pod.video.models import Video -from pod.recorder.models import Recording -from pod.video.rest_views import VideoSerializer +import json +import logging +import os +import webvtt +from django.conf import settings +from django.core.exceptions import SuspiciousOperation +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt from rest_framework import serializers, viewsets -from rest_framework.decorators import action -from rest_framework.decorators import api_view +from rest_framework.decorators import action, api_view from rest_framework.response import Response -from django.shortcuts import get_object_or_404 -from django.views.decorators.csrf import csrf_exempt -from django.core.exceptions import SuspiciousOperation +from pod.recorder.models import Recording +from pod.video.models import Video +from pod.video.rest_views import VideoSerializer -import json -import logging -import os -import webvtt +from .models import EncodingAudio, EncodingVideo, PlaylistVideo, VideoRendition USE_TRANSCRIPTION = getattr(settings, "USE_TRANSCRIPTION", False) if USE_TRANSCRIPTION: @@ -163,7 +162,8 @@ def launch_encode_view(request): if ( video is not None and ( - not hasattr(video, "launch_encode") or getattr(video, "launch_encode") is True + not hasattr(video, "launch_encode") + or getattr(video, "launch_encode") is True ) and video.encoding_in_progress is False ): @@ -185,8 +185,8 @@ def launch_transcript_view(request): @api_view(["POST"]) def store_remote_encoded_video(request): """View API for storing remote encoded videos.""" + from .encode import end_of_encoding, store_encoding_info from .Encoding_video_model import Encoding_video_model - from .encode import store_encoding_info, end_of_encoding video_id = request.GET.get("id", 0) logger.info("Start importing encoding data for video: %s" % video_id) diff --git a/pod/video_encode_transcript/runner_manager.py b/pod/video_encode_transcript/runner_manager.py new file mode 100644 index 0000000000..2921b0f906 --- /dev/null +++ b/pod/video_encode_transcript/runner_manager.py @@ -0,0 +1,702 @@ +"""Runner Manager orchestration helpers for encoding and transcription tasks in Esup-Pod. + +This module builds task payloads, dispatches them to available runner managers, +and keeps local task rows synchronized with runner-side task status. +""" + +import json +import logging +import os +from typing import Any, Literal, Optional, TypeAlias, TypedDict, Union, cast + +import requests +from django.conf import settings +from django.contrib.sites.models import Site +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from pod.cut.models import CutVideo +from pod.recorder.models import Recording +from pod.video.models import Video +from pod.video_encode_transcript.models import RunnerManager, Task +from pod.video_encode_transcript.runner_manager_utils import ( + store_before_remote_encoding_recording, + store_before_remote_encoding_video, +) + +from .utils import change_encoding_step + +if __name__ == "__main__": + from encoding_utils import get_list_rendition +else: + from .encoding_utils import get_list_rendition + +log = logging.getLogger(__name__) + +DEBUG = getattr(settings, "DEBUG", True) + +# Settings for template customization +TEMPLATE_VISIBLE_SETTINGS = getattr( + settings, + "TEMPLATE_VISIBLE_SETTINGS", + { + "TITLE_SITE": "Pod", + "TITLE_ETB": "University name", + "LOGO_SITE": "img/logoPod.svg", + "LOGO_ETB": "img/esup-pod.svg", + "LOGO_PLAYER": "img/pod_favicon.svg", + "LINK_PLAYER": "", + "LINK_PLAYER_NAME": _("Home"), + "FOOTER_TEXT": ("",), + "FAVICON": "img/pod_favicon.svg", + "CSS_OVERRIDE": "", + "PRE_HEADER_TEMPLATE": "", + "POST_FOOTER_TEMPLATE": "", + "TRACKING_TEMPLATE": "", + }, +) +__TITLE_SITE__ = ( + TEMPLATE_VISIBLE_SETTINGS["TITLE_SITE"] + if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_SITE")) + else "Pod" +) +__TITLE_ETB__ = ( + TEMPLATE_VISIBLE_SETTINGS["TITLE_ETB"] + if (TEMPLATE_VISIBLE_SETTINGS.get("TITLE_ETB")) + else "University name" +) + +VERSION = getattr(settings, "VERSION", "4.X") + +SECURE_SSL_REDIRECT = getattr(settings, "SECURE_SSL_REDIRECT", False) + + +SourceType = Literal["video", "recording"] +TaskType = Literal["encoding", "studio", "transcription"] +ParametersDict: TypeAlias = dict[str, Any] +HeadersDict: TypeAlias = dict[str, str] + + +class RunnerManagerTaskPayload(TypedDict): + """Task payload expected by the Runner Manager API.""" + + etab_name: str + app_name: str + app_version: str + task_type: TaskType + source_url: str + notify_url: str + parameters: ParametersDict + + +class RunnerManagerResponse(TypedDict, total=False): + """Relevant fields returned by the Runner Manager API.""" + + task_id: str + status: str + + +def _build_rendition_parameters() -> ParametersDict: + """Return rendition parameters serialized for the runner payload.""" + list_rendition = get_list_rendition() + str_resolution: dict[str, dict[str, Any]] = { + str(k): {"resolution": v["resolution"], "encode_mp4": v["encode_mp4"]} + for k, v in list_rendition.items() + } + return {"rendition": json.dumps(str_resolution)} + + +def _attach_cut_info(parameters: ParametersDict, video: Video) -> None: + """Attach cut information to parameters if it exists for the given video.""" + try: + cut_video = CutVideo.objects.get(video=video) + str_cut_info = { + "start": str(cut_video.start), + "end": str(cut_video.end), + "initial_duration": str(cut_video.duration), + } + parameters["cut"] = json.dumps(str_cut_info) + except CutVideo.DoesNotExist: + pass + + +def _attach_dressing_info(parameters: ParametersDict, video: Video) -> None: + """Attach dressing information to parameters if available for the given video.""" + try: + from pod.dressing.models import Dressing + + site = Site.objects.get_current() + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + + str_dressing_info = {} + if Dressing.objects.filter(videos=video).exists(): + log.info("Dressing found for video id: %s", video.id) + dressing = Dressing.objects.get(videos=video) + if dressing: + if dressing.watermark: + log.info("Dressing watermark found") + watermark_content_url = "%s/media/%s" % ( + base_url, + str(dressing.watermark.file.name), + ) + str_dressing_info["watermark"] = watermark_content_url + str_dressing_info["watermark_position"] = dressing.position + str_dressing_info["watermark_opacity"] = str(dressing.opacity) + if dressing.opening_credits: + log.info("Dressing opening credits found") + str_dressing_info["opening_credits"] = dressing.opening_credits.slug + opening_content_url = "%s/media/%s" % ( + base_url, + str(dressing.opening_credits.video.name), + ) + str_dressing_info["opening_credits_video"] = opening_content_url + str_dressing_info["opening_credits_video_duration"] = str( + dressing.opening_credits.duration + ) + if dressing.ending_credits: + log.info("Dressing ending credits found") + str_dressing_info["ending_credits"] = dressing.ending_credits.slug + ending_content_url = "%s/media/%s" % ( + base_url, + str(dressing.ending_credits.video.name), + ) + str_dressing_info["ending_credits_video"] = ending_content_url + str_dressing_info["ending_credits_video_duration"] = str( + dressing.ending_credits.duration + ) + # str_dressing_info["ending_credits_video_hasaudio"] = str( + # dressing.ending_credits.video.has_audio() + # ) + if str_dressing_info: + parameters["dressing"] = json.dumps(str_dressing_info) + except Exception as exc: + log.error(f"Error obtaining dressing for video {video.id}: {str(exc)}") + + +def _prepare_encoding_parameters( + video: Optional[Video] = None, +) -> ParametersDict: + """Prepare encoding parameters for video or recording. + + Args: + video: Video object (for video encoding). + For studio recordings, pass None as cut info doesn't apply. + + Returns: + Dictionary with rendition and optionally cut information + """ + parameters = _build_rendition_parameters() + + if video: + _attach_cut_info(parameters, video) + _attach_dressing_info(parameters, video) + + return parameters + + +def _prepare_task_data( + source_url: str, + base_url: str, + parameters: ParametersDict, + task_type: TaskType, +) -> RunnerManagerTaskPayload: + """Prepare task payload for runner manager. + + Args: + source_url: URL to the source file (video or XML) + base_url: Base URL of the site + parameters: Encoding parameters + + Returns: + Dictionary with task data + """ + return { + "etab_name": f"{__TITLE_ETB__} / {__TITLE_SITE__}", + "app_name": "Esup-Pod", + "app_version": VERSION, + "task_type": task_type, + "source_url": source_url, + "notify_url": f"{base_url}/runner/notify_task_end/", + "parameters": parameters, + } + + +# ---- Runner manager helpers (module-level to keep complexity low) ---- +def _rotate_same_priority_runner_managers( + runner_managers: list[RunnerManager], +) -> list[RunnerManager]: + """Rotate a same-priority runner manager list using last assigned task.""" + if len(runner_managers) <= 1: + return runner_managers + + runner_manager_ids = [rm.id for rm in runner_managers] + last_runner_manager_id = ( + Task.objects.filter(runner_manager_id__in=runner_manager_ids) + .order_by("-date_added", "-id") + .values_list("runner_manager_id", flat=True) + .first() + ) + if not last_runner_manager_id or last_runner_manager_id not in runner_manager_ids: + return runner_managers + + last_index = runner_manager_ids.index(last_runner_manager_id) + return runner_managers[last_index + 1 :] + runner_managers[: last_index + 1] + + +def _get_runner_managers(site: Site) -> list[RunnerManager]: + """Return site runner managers ordered by priority with round-robin per priority.""" + ordered_runner_managers = list( + RunnerManager.objects.filter(site=site).order_by("priority", "id") + ) + if len(ordered_runner_managers) <= 1: + return ordered_runner_managers + + runner_managers: list[RunnerManager] = [] + current_priority: Optional[int] = None + current_group: list[RunnerManager] = [] + # Apply round-robin only inside groups that share the same priority level. + for runner_manager in ordered_runner_managers: + if current_priority is None or runner_manager.priority == current_priority: + current_group.append(runner_manager) + current_priority = runner_manager.priority + continue + + runner_managers.extend(_rotate_same_priority_runner_managers(current_group)) + current_group = [runner_manager] + current_priority = runner_manager.priority + + if current_group: + runner_managers.extend(_rotate_same_priority_runner_managers(current_group)) + + return runner_managers + + +def _ids_for( + source_type: SourceType, source_id: Union[int, str] +) -> tuple[Optional[int], Optional[int]]: + """Return (video_id, recording_id) tuple based on source type.""" + return (int(source_id), None) if source_type == "video" else (None, int(source_id)) + + +def _execute_url(rm: RunnerManager) -> str: + """Build the execute endpoint URL for the given runner manager.""" + base = rm.url if rm.url.endswith("/") else rm.url + "/" + return base + "task/execute" + + +def _headers(rm: RunnerManager) -> HeadersDict: + """Build authentication and content headers for the runner manager API.""" + return { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {rm.token}", + } + + +def _try_send_to_rm( + rm: RunnerManager, payload: RunnerManagerTaskPayload +) -> Optional[requests.Response]: + """Try to POST the payload to a runner manager; log and return None on failure.""" + try: + return requests.post( + _execute_url(rm), data=json.dumps(payload), headers=_headers(rm), timeout=30 + ) + except requests.RequestException as exc: + log.warning( + f"Cannot reach runner manager {rm.name}: {str(exc)}. Trying next one." + ) + return None + + +def _prestore_encoding_if_needed( + *, + task_type: TaskType, + source_type: SourceType, + video_id: Optional[int], + recording_id: Optional[int], + rm: RunnerManager, + data: RunnerManagerTaskPayload, +) -> None: + """Run pre-store steps for encoding/studio tasks. + + Does nothing for transcription tasks. + """ + if task_type not in ("encoding", "studio"): + return + execute_url = _execute_url(rm) + if source_type == "video": + if video_id is not None: + store_before_remote_encoding_video(video_id, execute_url, data) + else: + log.warning( + "Unexpected None video_id for source_type 'video' while preparing store_before_remote_encoding_video." + ) + elif source_type == "recording": + if recording_id is not None: + store_before_remote_encoding_recording(recording_id, execute_url, data) + else: + log.warning( + "Unexpected None recording_id for source_type 'recording' while preparing store_before_remote_encoding_recording." + ) + + +def _submit_to_runner_manager( + rm: RunnerManager, + data: RunnerManagerTaskPayload, + task_type: TaskType, + source_type: SourceType, + video_id: Optional[int], + recording_id: Optional[int], +) -> bool: + """Submit payload to one runner manager and handle response and pre-store.""" + response = _try_send_to_rm(rm, data) + if response is None: + return False + if response.status_code != 200: + log.warning( + f"Runner manager {rm.name} returned status code {response.status_code}. Trying next one." + ) + return False + log.info( + f"Runner manager {rm.name} is available to process {task_type} for {source_type} {video_id or recording_id}." + ) + # Runner may reply with no body; keep an empty payload in that case. + payload = cast(RunnerManagerResponse, response.json() if response.content else {}) + _update_task_from_response(video_id, recording_id, task_type, rm, payload) + _prestore_encoding_if_needed( + task_type=task_type, + source_type=source_type, + video_id=video_id, + recording_id=recording_id, + rm=rm, + data=data, + ) + return True + + +def _update_task_pending( + source_type: SourceType, source_id: Union[int, str], task_type: TaskType +) -> tuple[Optional[int], Optional[int]]: + """Create or set a pending task for the given source and return (video_id, recording_id).""" + video_id, recording_id = _ids_for(source_type, source_id) + log.info( + "Update task to pending for video_id: %s, recording_id: %s", + video_id, + recording_id, + ) + _edit_task( + video_id=video_id, + recording_id=recording_id, + type=task_type, + status="pending", + runner_manager_id=None, + task_id=None, + ) + return video_id, recording_id + + +def _update_task_from_response( + video_id: Optional[int], + recording_id: Optional[int], + task_type: TaskType, + rm: RunnerManager, + response_json: RunnerManagerResponse, +) -> None: + """Update the task row using the response payload returned by the runner manager.""" + task_id = response_json.get("task_id") + status = str(response_json.get("status", "pending")) + log.info( + "Update task for video_id=%s, recording_id=%s, task_type=%s with response_json=%s", + video_id, + recording_id, + task_type, + response_json, + ) + _edit_task( + video_id=video_id, + recording_id=recording_id, + type=task_type, + status=status, + runner_manager_id=rm.id, + task_id=task_id, + ) + + +def _send_task_to_runner_manager( + *, + task_type: TaskType, + source_id: Union[int, str], + source_type: SourceType, + source_url: str, + base_url: str, + parameters: ParametersDict, +) -> bool: + """Submit a task to the Runner Manager and update the DB task row. + + - task_type: one of "encoding", "studio", "transcription" + - source_type: "video" or "recording" (used to resolve ids and pre-store behavior) + """ + + try: + site = Site.objects.get_current() + runner_managers_list = _get_runner_managers(site) + if not runner_managers_list: + log.error( + f"No runner manager defined for site {site.domain}. Cannot process {task_type} for {source_type} {source_id}." + ) + return False + + # Build payload and create/set pending task + data = _prepare_task_data(source_url, base_url, parameters, task_type) + video_id, recording_id = _update_task_pending(source_type, source_id, task_type) + + # Try each runner manager by priority and stop on the first healthy one. + for rm in runner_managers_list: + if _submit_to_runner_manager( + rm, + data=data, + task_type=task_type, + source_type=source_type, + video_id=video_id, + recording_id=recording_id, + ): + return True + + log.warning( + f"No runner manager available to process {task_type} for {source_type} {source_id}. " + f"Task will remain pending and will be retried by the process_tasks command." + ) + return False + + except Exception as exc: + log.error( + f"Error to process {task_type} for {source_type} {source_id}: {str(exc)}" + ) + return False + + +def encode_video(video_id: int) -> None: + """Start video encoding with runner manager.""" + log.info("Start encoding, with runner manager, for id: %s" % video_id) + try: + site = Site.objects.get_current() + # Get video info + video = get_object_or_404(Video, id=video_id) + # Build content URL + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + content_url = "%s/media/%s" % (base_url, video.video) + + # Prepare encoding parameters + parameters = _prepare_encoding_parameters(video=video) + + # Send encoding task to runner manager + _send_task_to_runner_manager( + task_type="encoding", + source_id=video_id, + source_type="video", + source_url=content_url, + base_url=base_url, + parameters=parameters, + ) + + except Exception as exc: + log.error( + 'Error to encode video "%(id)s": %(exc)s' + % {"id": video_id, "exc": str(exc)} + ) + + +def encode_studio_recording(recording_id: int) -> None: + """Start encoding studio recording with runner manager. + + This function handles encoding of studio recordings by passing the XML + source file URL to the runner manager. + """ + log.info( + "Start encoding, with runner manager, for studio recording id %s" % recording_id + ) + try: + site = Site.objects.get_current() + # Get studio recording + recording = Recording.objects.get(id=recording_id) + # Source file corresponds to Pod XML file + source_file = recording.source_file + + # Build source file URL. + # `source_file` is sometimes an absolute MEDIA_ROOT path and sometimes already relative. + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + media_url = getattr(settings, "MEDIA_URL", "/media/").rstrip("/") + try: + rel_path = os.path.relpath( + str(source_file), str(getattr(settings, "MEDIA_ROOT", "")) + ) + except Exception: + rel_path = str(source_file) + rel_path = rel_path.lstrip("/") + source_url = f"{base_url}{media_url}/{rel_path}" + + # Prepare encoding parameters (no specific cut info for studio recordings) + parameters = _prepare_encoding_parameters(video=None) + + # Send studio task to runner manager + _send_task_to_runner_manager( + task_type="studio", + source_id=recording_id, + source_type="recording", + source_url=source_url, + base_url=base_url, + parameters=parameters, + ) + + except Recording.DoesNotExist: + log.error(f"Recording {recording_id} not found.") + except Exception as exc: + log.error(f"Error to encode recording {recording_id}: {str(exc)}") + + +def transcript_video(video_id: int) -> None: + """Start video transcription with runner manager.""" + log.info("Start transcription, with runner manager, for id: %s" % video_id) + try: + site = Site.objects.get_current() + # Get video info + video = get_object_or_404(Video, id=video_id) + # Get associated mp3 file if exists + mp3file = video.get_video_mp3().source_file if video.get_video_mp3() else None + url_scheme = "https" if SECURE_SSL_REDIRECT else "http" + base_url = url_scheme + "://" + site.domain + if mp3file is not None: + content_url = "%s%s" % (base_url, mp3file.url) + else: + # Build video content URL + content_url = "%s/media/%s" % (base_url, video.video) + + # Prepare transcript parameters + parameters = _prepare_transcription_parameters(video=video) + + # Mark video as encoding in progress + video_to_encode = Video.objects.get(id=video_id) + video_to_encode.encoding_in_progress = True + video_to_encode.save() + + # Update encoding step to transcripting audio + change_encoding_step(video_id, 5, "transcripting audio") + + # Send transcription task to runner manager + _send_task_to_runner_manager( + task_type="transcription", + source_id=video_id, + source_type="video", + source_url=content_url, + base_url=base_url, + parameters=parameters, + ) + + except Exception as exc: + log.error( + 'Error to transcribe video "%(id)s": %(exc)s' + % {"id": video_id, "exc": str(exc)} + ) + + +def _prepare_transcription_parameters(video: Video) -> ParametersDict: + """Prepare parameters for a transcription task. + + Args: + video: `Video` instance to transcribe. + + Returns: + Parameter dictionary for the Runner Manager. + """ + try: + from .transcript import resolve_transcription_language + + # Requested language (video `transcript` field) + lang = resolve_transcription_language(video) + + # Options from settings (optional on runner side) + transcription_type = getattr(settings, "TRANSCRIPTION_TYPE", None) + normalize = bool(getattr(settings, "TRANSCRIPTION_NORMALIZE", False)) + + params: ParametersDict = { + "language": lang, + # Duration may help runner to tune/optimize + "duration": float(getattr(video, "duration", 0) or 0), + # Text normalization (punctuation/casing) on runner side if supported + "normalize": normalize, + } + # If needed in future, we can add model size or other options here + if transcription_type: + params["model_type"] = transcription_type + # Possibility to add model size if needed in future + # params["model"] = "medium" + + return params + except Exception: + # Keep legacy key name for backward compatibility with older runners. + return {"lang": getattr(video, "transcript", "") or ""} + + +def _edit_task( + video_id: Optional[int], + type: str, + status: str, + runner_manager_id: Optional[int] = None, + task_id: Optional[str] = None, + recording_id: Optional[int] = None, +) -> None: + """Edit or create a task for a video or studio recording.""" + try: + from .task_queue import refresh_pending_task_ranks + + log.info( + f"Edit or create a task: {video_id} {type} {runner_manager_id} {status} {task_id}" + ) + # Check if a task already exists for this video and type with pending status + # Build base queryset depending on source type + if type == "studio": + tasks_list = list( + Task.objects.filter( + recording_id=recording_id, + type=type, + status="pending", + ) + ) + else: + tasks_list = list( + Task.objects.filter( + video_id=video_id, + type=type, + status="pending", + ) + ) + if not tasks_list: + # Create new task + task = Task( + video_id=video_id if type != "studio" else None, + recording_id=recording_id if type == "studio" else None, + type=type, + runner_manager_id=runner_manager_id, + status=status, + task_id=task_id, + ) + task.save() + else: + # Edit existing task + task = tasks_list[0] + task.status = status + if runner_manager_id is not None: + task.runner_manager_id = runner_manager_id + if task_id is not None: + task.task_id = task_id + # Keep association fields as-is + task.save() + + refresh_pending_task_ranks() + + except Exception as exc: + log.error( + f"Unable to edit a task (video_id={video_id}, recording_id={recording_id}): {str(exc)}" + ) diff --git a/pod/video_encode_transcript/runner_manager_utils.py b/pod/video_encode_transcript/runner_manager_utils.py new file mode 100644 index 0000000000..586eaf92dd --- /dev/null +++ b/pod/video_encode_transcript/runner_manager_utils.py @@ -0,0 +1,611 @@ +"""Utilities for storing and importing remote encoding artifacts in Esup-Pod. + +This module orchestrates post-encoding persistence for videos and recordings: +- updates encoding logs and processing state, +- imports generated files (video/audio/playlist/thumbnail), +- clears stale artifacts from previous runs. +""" + +import json +import logging +import os +import re +import time +from typing import Any, TypedDict, cast + +from django.conf import settings +from django.core.files import File +from webpush.models import PushInformation + +from pod.recorder.models import Recording +from pod.video.models import Video + +from .models import ( + EncodingAudio, + EncodingLog, + EncodingVideo, + PlaylistVideo, + VideoRendition, +) +from .utils import ( + add_encoding_log, + change_encoding_step, + check_file, + create_outputdir, + send_email, + send_email_encoding, + send_notification_encoding, +) + +if getattr(settings, "USE_PODFILE", False): + FILEPICKER = True + from pod.podfile.models import CustomImageModel, UserFolder +else: + FILEPICKER = False + from pod.main.models import CustomImageModel + +log = logging.getLogger(__name__) + +DEBUG = getattr(settings, "DEBUG", True) + +USE_NOTIFICATIONS = getattr(settings, "USE_NOTIFICATIONS", True) + +USE_TRANSCRIPTION = getattr(settings, "USE_TRANSCRIPTION", False) + +if USE_TRANSCRIPTION: + from . import transcript + + TRANSCRIPT_VIDEO = getattr(settings, "TRANSCRIPT_VIDEO", "start_transcript") + +EMAIL_ON_ENCODING_COMPLETION = getattr(settings, "EMAIL_ON_ENCODING_COMPLETION", True) + +ENCODING_CHOICES = getattr( + settings, + "ENCODING_CHOICES", + ( + ("audio", "audio"), + ("360p", "360p"), + ("480p", "480p"), + ("720p", "720p"), + ("1080p", "1080p"), + ("playlist", "playlist"), + ), +) + + +class EncodedAudioInfo(TypedDict): + """Audio entry produced by the remote encoder.""" + + encoding_format: str + filename: str + + +class EncodedVideoInfo(TypedDict): + """Video entry produced by the remote encoder.""" + + encoding_format: str + filename: str + rendition: str + + +class EncodedThumbnailInfo(TypedDict): + """Thumbnail entry produced by the remote encoder.""" + + filename: str + + +class RemoteEncodingInfo(TypedDict, total=False): + """Top-level JSON payload written by the remote encoder.""" + + duration: float + has_stream_video: bool + has_stream_audio: bool + has_stream_thumbnail: bool + encode_video: list[EncodedVideoInfo] + encode_audio: EncodedAudioInfo | list[EncodedAudioInfo] + encode_thumbnail: EncodedThumbnailInfo | list[EncodedThumbnailInfo] + + +def store_before_remote_encoding_recording( + recording_id: int, execute_url: str, data: dict[str, Any] +) -> None: + """Store pre-encoding metadata for a recording.""" + recording = Recording.objects.get(id=recording_id) + msg = "\nStart at: %s" % time.ctime() + msg += "\nprocess manager remote encode: %s with data %s" % (execute_url, data) + recording.comment += msg + recording.save() + + +def store_remote_encoding_log_recording(recording_id: int, video_id: int) -> None: + # Get recording info + recording = Recording.objects.get(id=recording_id) + # Get video info + video_to_encode = Video.objects.get(id=video_id) + # Store encoding log + encoding_log, created = EncodingLog.objects.get_or_create(video=video_to_encode) + encoding_log.log = "%s" % recording.comment + encoding_log.save() + + +def store_before_remote_encoding_video( + video_id: int, execute_url: str, data: dict[str, Any] +) -> None: + """Initialize video state and logs before remote encoding starts.""" + start = "Start at: %s" % time.ctime() + msg = "" + video_to_encode = Video.objects.get(id=video_id) + video_to_encode.encoding_in_progress = True + video_to_encode.save() + change_encoding_step(video_id, 0, "start") + + encoding_log, created = EncodingLog.objects.get_or_create(video=video_to_encode) + encoding_log.log = "%s" % start + encoding_log.save() + + if check_file(video_to_encode.video.path): + change_encoding_step(video_id, 1, "remove old data") + remove_msg = remove_old_data(video_id) + add_encoding_log(video_id, "remove old data: %s" % remove_msg) + + change_encoding_step(video_id, 2, "create output dir") + output_dir = create_outputdir(video_id, video_to_encode.video.path) + add_encoding_log(video_id, "output_dir: %s" % output_dir) + + open(output_dir + "/encoding.log", "w").close() + with open(output_dir + "/encoding.log", "a") as f: + f.write("%s\n" % start) + + change_encoding_step(video_id, 3, "process manager remote encode") + add_encoding_log( + video_id, + "process manager remote encode: %s with data %s" % (execute_url, data), + ) + + else: + msg += "Wrong file or path: " + "\n%s" % video_to_encode.video.path + add_encoding_log(video_id, msg) + change_encoding_step(video_id, -1, msg) + send_email(msg, video_id) + + +def store_after_remote_encoding_video(video_id: int) -> None: + """Import remote artifacts and finalize encoding state for a video.""" + msg = "" + video_to_encode = Video.objects.get(id=video_id) + output_dir = create_outputdir(video_id, video_to_encode.video.path) + info_video: RemoteEncodingInfo = {} + + with open(output_dir + "/info_video.json", encoding="utf-8") as json_file: + info_video = cast(RemoteEncodingInfo, json.load(json_file)) + + video_to_encode.duration = info_video["duration"] + video_to_encode.encoding_in_progress = True + video_to_encode.save() + + msg += remote_video_part(video_to_encode, info_video, output_dir) + msg += remote_audio_part(video_to_encode, info_video, output_dir) + + video_encoding = Video.objects.get(id=video_id) + + if not info_video["has_stream_video"]: + video_encoding.is_video = False + video_encoding.save() + + add_encoding_log(video_id, msg) + change_encoding_step(video_id, 0, "done") + + video_encoding.encoding_in_progress = False + video_encoding.save() + + add_encoding_log(video_id, "End: %s" % time.ctime()) + with open(output_dir + "/encoding.log", "a") as f: + f.write("\n\nEnd: %s" % time.ctime()) + + if ( + USE_NOTIFICATIONS + and video_encoding.owner.owner.accepts_notifications + and PushInformation.objects.filter(user=video_encoding.owner).exists() + ): + send_notification_encoding(video_encoding) + + if EMAIL_ON_ENCODING_COMPLETION: + send_email_encoding(video_encoding) + + if USE_TRANSCRIPTION and video_encoding.transcript not in ["", "0", "1"]: + start_transcript_video = getattr(transcript, TRANSCRIPT_VIDEO) + log.info( + "Start transcript video %s", + getattr(transcript, TRANSCRIPT_VIDEO), + ) + start_transcript_video(video_id, False) + + log.info("ALL is DONE") + + +def remote_audio_part( + video_to_encode: Video, info_video: RemoteEncodingInfo, output_dir: str +) -> str: + """Import audio (and optional thumbnail) artifacts from remote outputs.""" + msg = "" + if info_video["has_stream_audio"] and info_video.get("encode_audio"): + msg += import_remote_audio( + info_video["encode_audio"], output_dir, video_to_encode + ) + if info_video["has_stream_thumbnail"] and info_video.get("encode_thumbnail"): + msg += import_remote_thumbnail( + info_video["encode_thumbnail"], output_dir, video_to_encode + ) + elif info_video["has_stream_audio"] or info_video.get("encode_audio"): + msg += "\n- has stream audio but not info audio in json " + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + + +def remote_video_part( + video_to_encode: Video, info_video: RemoteEncodingInfo, output_dir: str +) -> str: + """Import video artifacts and attach optional overview/thumbnail files.""" + msg = "" + if info_video["has_stream_video"] and info_video.get("encode_video"): + msg += import_remote_video( + info_video["encode_video"], output_dir, video_to_encode + ) + video_id = video_to_encode.id + # If the remote pipeline generated overview thumbnails metadata, attach it. + overview_vtt = os.path.join(output_dir, "overview.vtt") + if check_file(overview_vtt): + try: + video_to_encode.overview = overview_vtt.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + video_to_encode.save() + msg += "\n- existing overview:\n%s" % overview_vtt + add_encoding_log( + video_id, "attach existing overview: %s" % overview_vtt + ) + except Exception as err: + err_msg = f"Error attaching existing overview: {err}" + add_encoding_log(video_id, err_msg) + else: + add_encoding_log(video_id, "No existing overview file found (overview.vtt)") + + if info_video["has_stream_thumbnail"] and info_video.get("encode_thumbnail"): + msg += import_remote_thumbnail( + info_video["encode_thumbnail"], output_dir, video_to_encode + ) + else: + add_encoding_log( + video_id, "No thumbnail info in json; skip thumbnail attach" + ) + elif info_video["has_stream_video"] or info_video.get("encode_video"): + msg += "\n- has stream video but not info video " + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + + return msg + + +def import_remote_thumbnail( + info_encode_thumbnail: EncodedThumbnailInfo | list[EncodedThumbnailInfo], + output_dir: str, + video_to_encode: Video, +) -> str: + """Import thumbnail data generated during remote encoding.""" + msg = "" + thumbnail_data = info_encode_thumbnail + if isinstance(thumbnail_data, list): + # Keep backward compatibility with payloads that wrap a single thumbnail in a list. + if not thumbnail_data: + msg += "\nERROR THUMBNAILS missing data " + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + thumbnail_data = thumbnail_data[0] + + thumbnailfilename = os.path.join(output_dir, thumbnail_data["filename"]) + if check_file(thumbnailfilename): + if FILEPICKER: + homedir, created = UserFolder.objects.get_or_create( + name="home", owner=video_to_encode.owner + ) + videodir, created = UserFolder.objects.get_or_create( + name="%s" % video_to_encode.slug, owner=video_to_encode.owner + ) + thumbnail = CustomImageModel( + folder=videodir, created_by=video_to_encode.owner + ) + thumbnail.file.save( + thumbnail_data["filename"], + File(open(thumbnailfilename, "rb")), + save=True, + ) + thumbnail.save() + video_to_encode.thumbnail = thumbnail + video_to_encode.save() + else: + thumbnail = CustomImageModel() + thumbnail.file.save( + thumbnail_data["filename"], + File(open(thumbnailfilename, "rb")), + save=True, + ) + thumbnail.save() + video_to_encode.thumbnail = thumbnail + video_to_encode.save() + msg += "\n- thumbnailfilename:\n%s" % thumbnailfilename + else: + msg += "\nERROR THUMBNAILS %s " % thumbnailfilename + msg += "Wrong file or path" + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + + +def import_remote_audio( + info_encode_audio: EncodedAudioInfo | list[EncodedAudioInfo], + output_dir: str, + video_to_encode: Video, +) -> str: + """Persist generated audio tracks (mp3/m4a) for a video.""" + msg = "" + if isinstance(info_encode_audio, dict): + info_encode_audio = [info_encode_audio] + for encode_audio in info_encode_audio: + if encode_audio["encoding_format"] == "audio/mp3": + filename = os.path.splitext(encode_audio["filename"])[0] + audiofilename = os.path.join(output_dir, "%s.mp3" % filename) + if check_file(audiofilename): + encoding, created = EncodingAudio.objects.get_or_create( + name="audio", + video=video_to_encode, + encoding_format="audio/mp3", + ) + encoding.source_file = audiofilename.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + encoding.save() + msg += "\n- encode_video_mp3:\n%s" % audiofilename + else: + msg += "\n- encode_video_mp3 Wrong file or path " + msg += audiofilename + " " + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + if encode_audio["encoding_format"] == "video/mp4": + filename = os.path.splitext(encode_audio["filename"])[0] + audiofilename = os.path.join(output_dir, "%s.m4a" % filename) + if check_file(audiofilename): + encoding, created = EncodingAudio.objects.get_or_create( + name="audio", + video=video_to_encode, + encoding_format="video/mp4", + ) + encoding.source_file = audiofilename.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + encoding.save() + msg += "\n- encode_video_m4a:\n%s" % audiofilename + else: + msg += "\n- encode_video_m4a Wrong file or path " + msg += audiofilename + " " + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + + +def import_remote_video( + info_encode_video: list[EncodedVideoInfo], + output_dir: str, + video_to_encode: Video, +) -> str: + """Persist generated video tracks and build the HLS master playlist.""" + msg = "" + master_playlist = "" + video_has_playlist = False + for encod_video in info_encode_video: + if encod_video["encoding_format"] == "video/mp2t": + video_has_playlist = True + import_msg, import_master_playlist = import_m3u8( + encod_video, output_dir, video_to_encode + ) + msg += import_msg + master_playlist += import_master_playlist + + if encod_video["encoding_format"] == "video/mp4": + import_msg = import_mp4(encod_video, output_dir, video_to_encode) + msg += import_msg + + if video_has_playlist: + # Aggregate all rendition playlists into a single HLS master playlist. + playlist_master_file = output_dir + "/playlist.m3u8" + with open(playlist_master_file, "w") as f: + f.write("#EXTM3U\n#EXT-X-VERSION:3\n" + master_playlist) + + if check_file(playlist_master_file): + playlist, created = PlaylistVideo.objects.get_or_create( + name="playlist", + video=video_to_encode, + encoding_format="application/x-mpegURL", + ) + playlist.source_file = ( + output_dir.replace(os.path.join(settings.MEDIA_ROOT, ""), "") + + "/playlist.m3u8" + ) + playlist.save() + + msg += "\n- Playlist:\n%s" % playlist_master_file + else: + msg = ( + "save_playlist_master Wrong file or path: " + + "\n%s" % playlist_master_file + ) + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + + +def import_mp4( + encod_video: EncodedVideoInfo, output_dir: str, video_to_encode: Video +) -> str: + """Persist a single MP4 rendition into EncodingVideo.""" + filename = os.path.splitext(encod_video["filename"])[0] + videofilenameMp4 = os.path.join(output_dir, "%s.mp4" % filename) + msg = "\n- videofilenameMp4:\n%s" % videofilenameMp4 + if check_file(videofilenameMp4): + rendition = VideoRendition.objects.get(resolution=encod_video["rendition"]) + encoding, created = EncodingVideo.objects.get_or_create( + name=get_encoding_choice_from_filename(filename), + video=video_to_encode, + rendition=rendition, + encoding_format="video/mp4", + ) + encoding.source_file = videofilenameMp4.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + encoding.save() + else: + msg = "save_mp4_file Wrong file or path: " + "\n%s " % (videofilenameMp4) + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg + + +def import_m3u8( + encod_video: EncodedVideoInfo, output_dir: str, video_to_encode: Video +) -> tuple[str, str]: + """Persist one HLS rendition and return its master playlist fragment.""" + msg = "" + master_playlist = "" + filename = os.path.splitext(encod_video["filename"])[0] + videofilenameM3u8 = os.path.join(output_dir, "%s.m3u8" % filename) + videofilenameTS = os.path.join(output_dir, "%s.ts" % filename) + msg += "\n- videofilenameM3u8:\n%s" % videofilenameM3u8 + msg += "\n- videofilenameTS:\n%s" % videofilenameTS + + rendition = VideoRendition.objects.get(resolution=encod_video["rendition"]) + + bitrate_match = re.search(r"(\d+)k", rendition.video_bitrate, re.I) + if bitrate_match is None: + msg = "Invalid rendition bitrate format: %s" % rendition.video_bitrate + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + return msg, master_playlist + + int_bitrate = int(bitrate_match.group(1)) + bandwidth = int_bitrate * 1000 + + if check_file(videofilenameM3u8) and check_file(videofilenameTS): + encoding, created = EncodingVideo.objects.get_or_create( + name=get_encoding_choice_from_filename(filename), + video=video_to_encode, + rendition=rendition, + encoding_format="video/mp2t", + ) + encoding.source_file = videofilenameTS.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + encoding.save() + + playlist, created = PlaylistVideo.objects.get_or_create( + name=get_encoding_choice_from_filename(filename), + video=video_to_encode, + encoding_format="application/x-mpegURL", + ) + playlist.source_file = videofilenameM3u8.replace( + os.path.join(settings.MEDIA_ROOT, ""), "" + ) + playlist.save() + + master_playlist += "#EXT-X-STREAM-INF:BANDWIDTH=%s," % bandwidth + master_playlist += "RESOLUTION=%s\n%s\n" % ( + rendition.resolution, + encod_video["filename"], + ) + else: + msg = "save_playlist_file Wrong file or path: " + "\n%s and %s" % ( + videofilenameM3u8, + videofilenameTS, + ) + add_encoding_log(video_to_encode.id, msg) + change_encoding_step(video_to_encode.id, -1, msg) + send_email(msg, video_to_encode.id) + + return msg, master_playlist + + +def get_encoding_choice_from_filename(filename: str) -> str: + """Map filename prefix to the configured encoding choice name.""" + choices: dict[str, str] = {} + for choice in ENCODING_CHOICES: + choices[choice[0][:3]] = choice[0] + return choices.get(filename[:3], "360p") + + +def remove_old_data(video_id: int) -> str: + """Remove old data.""" + video_to_encode = Video.objects.get(id=video_id) + video_to_encode.thumbnail = None + if video_to_encode.overview: + image_overview = os.path.join( + os.path.dirname(video_to_encode.overview.path), "overview.png" + ) + if os.path.isfile(image_overview): + os.remove(image_overview) + video_to_encode.overview.delete() + video_to_encode.overview = None + video_to_encode.save() + + encoding_log_msg = "" + encoding_log_msg += remove_previous_encoding_video(video_to_encode) + encoding_log_msg += remove_previous_encoding_audio(video_to_encode) + encoding_log_msg += remove_previous_encoding_playlist(video_to_encode) + return encoding_log_msg + + +def remove_previous_encoding_video(video_to_encode: Video) -> str: + """Remove previously encoded video.""" + msg = "\n" + previous_encoding_video = EncodingVideo.objects.filter(video=video_to_encode) + if len(previous_encoding_video) > 0: + msg += "\nDELETE PREVIOUS ENCODING VIDEO" + for encoding in previous_encoding_video: + encoding.delete() + else: + msg += "Video: Nothing to delete" + return msg + + +def remove_previous_encoding_audio(video_to_encode: Video) -> str: + """Remove previously encoded audio.""" + msg = "\n" + previous_encoding_audio = EncodingAudio.objects.filter(video=video_to_encode) + if len(previous_encoding_audio) > 0: + msg += "\nDELETE PREVIOUS ENCODING AUDIO" + for encoding in previous_encoding_audio: + encoding.delete() + else: + msg += "Audio: Nothing to delete" + return msg + + +def remove_previous_encoding_playlist(video_to_encode: Video) -> str: + """Remove previously encoded playlist.""" + msg = "\n" + previous_playlist = PlaylistVideo.objects.filter(video=video_to_encode) + if len(previous_playlist) > 0: + msg += "DELETE PREVIOUS PLAYLIST M3U8" + for encoding in previous_playlist: + encoding.delete() + else: + msg += "Playlist: Nothing to delete" + return msg diff --git a/pod/video_encode_transcript/task_queue.py b/pod/video_encode_transcript/task_queue.py new file mode 100644 index 0000000000..0a528e920e --- /dev/null +++ b/pod/video_encode_transcript/task_queue.py @@ -0,0 +1,91 @@ +"""Queue ranking helpers for encoding/transcription task dispatch in Esup-Pod. + +The queue is intentionally simple: +- only pending tasks receive a rank, +- rank is recomputed from scratch to keep ordering deterministic, +- non-pending tasks must not retain stale rank values. +""" + +import logging +from typing import TypeAlias + +from pod.video.models import Video + +from .models import Task + +log = logging.getLogger(__name__) + +QueuePriority: TypeAlias = int +HIGH_PRIORITY: QueuePriority = 1 +LOW_PRIORITY: QueuePriority = 2 + + +def get_user_priority(video: Video) -> QueuePriority: + """Return queue priority for a video owner.""" + try: + affiliation = video.owner.owner.affiliation + if affiliation == "student": + return LOW_PRIORITY + return HIGH_PRIORITY + except AttributeError: + log.warning( + "Cannot determine affiliation for video %s owner. Defaulting to high priority.", + video.id, + ) + return HIGH_PRIORITY + except Exception as exc: + log.warning( + "Error getting affiliation for video %s: %s. Defaulting to high priority.", + video.id, + str(exc), + ) + return HIGH_PRIORITY + + +def _task_priority(task: Task) -> QueuePriority: + """Return priority for a pending task.""" + if not task.video_id or not task.video: + return HIGH_PRIORITY + return get_user_priority(task.video) + + +def get_sorted_pending_tasks() -> list[Task]: + """Return all pending tasks sorted by queue priority and creation date.""" + pending_tasks = list( + Task.objects.filter(status="pending") + .select_related("video", "video__owner", "video__owner__owner") + .order_by("date_added", "id") + ) + pending_tasks.sort( + key=lambda task: (_task_priority(task), task.date_added, task.id), + ) + return pending_tasks + + +def refresh_pending_task_ranks() -> None: + """Recalculate rank for all pending tasks and clear rank for non-pending ones.""" + sorted_tasks = get_sorted_pending_tasks() + updates: list[Task] = [] + for index, task in enumerate(sorted_tasks, start=1): + if task.rank != index: + task.rank = index + updates.append(task) + + if updates: + # Batch write only changed rows to avoid unnecessary UPDATE queries. + Task.objects.bulk_update(updates, ["rank"]) + + # Keep DB consistent: only pending tasks are expected to have a queue rank. + Task.objects.exclude(status="pending").exclude(rank__isnull=True).update(rank=None) + + +def get_video_pending_encoding_queue_info(video: Video) -> tuple[int | None, int]: + """Return (rank, total_pending) for the encoding task linked to a video.""" + queue_total = Task.objects.filter(status="pending").count() + queue_task = ( + Task.objects.filter(video_id=video.id, type="encoding", status="pending") + .order_by("date_added", "id") + .first() + ) + queue_rank = queue_task.rank if queue_task else None + return queue_rank, queue_total diff --git a/pod/video_encode_transcript/templates/admin_test_connection.html b/pod/video_encode_transcript/templates/admin_test_connection.html new file mode 100644 index 0000000000..cadb17c76f --- /dev/null +++ b/pod/video_encode_transcript/templates/admin_test_connection.html @@ -0,0 +1,44 @@ +{% extends "admin/change_form.html" %} +{% load admin_urls i18n %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block object-tools-items %} + {{ block.super }} + {% if change and original %} +
  • + + + {% trans "Test connection" %} + +
  • + {% endif %} +{% endblock %} diff --git a/pod/video_encode_transcript/tests/test_encode.py b/pod/video_encode_transcript/tests/test_encode.py index c54f4a98aa..2976d0e1ef 100644 --- a/pod/video_encode_transcript/tests/test_encode.py +++ b/pod/video_encode_transcript/tests/test_encode.py @@ -1,24 +1,21 @@ """ -Video & Audio encoding test cases. +Video and audio encoding test cases. -* run with `python manage.py test pod.video_encode_transcript.tests.test_encode` +Run with `python manage.py test pod.video_encode_transcript.tests.test_encode` """ +import os +import shutil + from django.conf import settings -from django.test import TestCase -from django.core.files.temp import NamedTemporaryFile from django.contrib.auth.models import User +from django.core.files.temp import NamedTemporaryFile +from django.test import TestCase - -from pod.video.models import Video, Type +from pod.video.models import Type, Video from pod.video_encode_transcript import encode -from pod.video_encode_transcript.models import EncodingVideo -from pod.video_encode_transcript.models import EncodingAudio -from pod.video_encode_transcript.models import EncodingLog -from pod.video_encode_transcript.models import PlaylistVideo - -import shutil -import os +from pod.video_encode_transcript.models import (EncodingAudio, EncodingLog, + EncodingVideo, PlaylistVideo) VIDEO_TEST = getattr(settings, "VIDEO_TEST", "pod/main/static/video_test/pod.mp4") @@ -26,7 +23,7 @@ class EncodeTestCase(TestCase): - """Video and audio encoding tests.""" + """Coverage for local video/audio encoding and cleanup behavior.""" fixtures = [ "initial_data.json", @@ -35,7 +32,7 @@ class EncodeTestCase(TestCase): _one_time_setup_complete = False def setUp(self): - """Set up video and audio encoding tests.""" + """Run one-time encode setup, then execute per-test setup hook.""" if not self._one_time_setup_complete: self.before_running_all_tests() self._one_time_setup_complete = True @@ -43,9 +40,8 @@ def setUp(self): self.before_running_each_test() def before_running_all_tests(self): - """Set up that must be run just once before all tests.""" + """Create and encode one sample video and one sample audio file.""" user = User.objects.create(username="pod", password="pod1234pod") - # owner1 = Owner.objects.get(user__username="pod") video = Video.objects.create( title="Video1", owner=user, @@ -77,11 +73,11 @@ def before_running_all_tests(self): print(" ---> SetUp of EncodeTestCase: OK!") def before_running_each_test(self): - """Set up what must be run before each test.""" + """Hook for per-test setup (kept for future extension).""" pass def test_encoding_wrong_file(self): - """Test if a try to encode a wrong file ends well.""" + """Log an explicit error when trying to encode an unsupported file type.""" video = Video.objects.create( title="Video2", owner=User.objects.get(id=1), @@ -94,8 +90,8 @@ def test_encoding_wrong_file(self): self.assertTrue("Wrong file or path:" in el.log) def test_result_encoding_video(self) -> None: - """Test if video encoding worked properly.""" - # video id=1 et audio id=2 + """Generate expected video artifacts and metadata after encoding.""" + # In this fixture setup, video id=1 and audio id=2. video_to_encode = Video.objects.get(id=1) self.assertEqual("Video1", video_to_encode.title) list_mp2t = EncodingVideo.objects.filter( @@ -123,12 +119,16 @@ def test_result_encoding_video(self) -> None: print(" ---> test_encode_video of EncodeTestCase: OK!") def test_result_encoding_audio(self): - """Test if audio encoding worked properly.""" + """Generate expected audio artifacts while skipping image extraction.""" # video id=1 & audio id=2 audio = Video.objects.get(id=2) self.assertEqual("Audio1", audio.title) - list_m4a = EncodingAudio.objects.filter(video=audio, encoding_format="video/mp4") - list_mp3 = EncodingAudio.objects.filter(video=audio, encoding_format="audio/mp3") + list_m4a = EncodingAudio.objects.filter( + video=audio, encoding_format="video/mp4" + ) + list_mp3 = EncodingAudio.objects.filter( + video=audio, encoding_format="audio/mp3" + ) el = EncodingLog.objects.get(video=audio) self.assertTrue("NO VIDEO AND AUDIO FOUND" not in el.log) self.assertTrue(len(list_mp3) > 0) @@ -138,7 +138,7 @@ def test_result_encoding_audio(self): print(" ---> test_result_encoding_audio of EncodeTestCase: OK!") def test_delete_video(self): - """Test video deletion and cascade deleting.""" + """Delete encoded media and ensure related files/models are removed.""" video_to_encode = Video.objects.get(id=1) self.assertEqual("Video1", video_to_encode.title) video = video_to_encode.video.path @@ -181,7 +181,7 @@ def test_delete_video(self): self.assertEqual(list_mp4.count(), 0) self.assertEqual(EncodingLog.objects.filter(video__id=1).count(), 0) - # check video folder remove + # Ensure the generated folder for encoded assets is removed. self.assertFalse(os.path.isdir(video_dir)) audio = Video.objects.get(id=2) @@ -192,7 +192,7 @@ def test_delete_video(self): audio.delete() self.assertTrue(not os.path.exists(audio_video_path)) self.assertTrue(not os.path.exists(audio_log_file)) - # check audio folder remove + # Ensure the generated folder for encoded assets is removed. self.assertFalse(os.path.isdir(audio_dir)) print(" ---> test_delete_video of EncodeTestCase: OK!") diff --git a/pod/video_encode_transcript/tests/test_notify_task_end.py b/pod/video_encode_transcript/tests/test_notify_task_end.py new file mode 100644 index 0000000000..dbbdf09bcb --- /dev/null +++ b/pod/video_encode_transcript/tests/test_notify_task_end.py @@ -0,0 +1,88 @@ +""" +Notify-task-end authentication tests for Esup-Pod. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_notify_task_end` +""" + +import json +from unittest.mock import patch + +from django.contrib.sites.models import Site +from django.test import RequestFactory, TestCase + +from pod.video_encode_transcript.models import RunnerManager, Task +from pod.video_encode_transcript.views import notify_task_end + + +class NotifyTaskEndAuthTests(TestCase): + """Authentication coverage for the notify_task_end endpoint.""" + + def setUp(self) -> None: + """Create a runner manager and a pending task used in all test cases.""" + self.factory = RequestFactory() + site = Site.objects.filter(pk=1).first() or Site.objects.first() + if site is None: + site = Site.objects.create(domain="example.com", name="example.com") + + self.runner_manager = RunnerManager.objects.create( + name="rm-test", + priority=1, + url="https://runner.example.com/", + token="runner-token", + site=site, + ) + self.task = Task.objects.create( + task_id="task-123", + runner_manager=self.runner_manager, + status="pending", + ) + + def _post_notify(self, authorization: str | None = None, status: str = "running"): + """Send a JSON notify_task_end request with an optional bearer token.""" + headers = {} + if authorization is not None: + headers["HTTP_AUTHORIZATION"] = authorization + return notify_task_end( + self.factory.post( + "/runner/notify_task_end/", + data=json.dumps({"task_id": self.task.task_id, "status": status}), + content_type="application/json", + **headers, + ) + ) + + def test_notify_task_end_requires_bearer_token(self): + """Return 401 and keep task unchanged when the Authorization header is missing.""" + response = self._post_notify() + self.assertEqual(response.status_code, 401) + + self.task.refresh_from_db() + self.assertEqual(self.task.status, "pending") + + def test_notify_task_end_rejects_invalid_bearer_token(self): + """Return 403 and keep task unchanged when the bearer token is invalid.""" + response = self._post_notify("Bearer wrong-token") + self.assertEqual(response.status_code, 403) + + self.task.refresh_from_db() + self.assertEqual(self.task.status, "pending") + + def test_notify_task_end_accepts_runner_manager_token(self): + """Accept a valid runner token and update the task status from payload data.""" + response = self._post_notify("Bearer runner-token") + self.assertEqual(response.status_code, 200) + + self.task.refresh_from_db() + self.assertEqual(self.task.status, "running") + + @patch("pod.video_encode_transcript.views.send_email_item") + def test_notify_task_end_sends_alert_on_failed_status(self, mock_send_email_item): + """Send an alert email when runner notifies a failed task.""" + response = self._post_notify("Bearer runner-token", status="failed") + self.assertEqual(response.status_code, 200) + + self.task.refresh_from_db() + self.assertEqual(self.task.status, "failed") + mock_send_email_item.assert_called_once_with( + f"Task {self.task.id} failed", "Task", self.task.task_id + ) diff --git a/pod/video_encode_transcript/tests/test_remote_encode_transcode.py b/pod/video_encode_transcript/tests/test_remote_encode_transcode.py index f9058a909b..14191f08f1 100644 --- a/pod/video_encode_transcript/tests/test_remote_encode_transcode.py +++ b/pod/video_encode_transcript/tests/test_remote_encode_transcode.py @@ -1,28 +1,28 @@ """ -Video & Audio Remote encoding test cases. +Remote video/audio encoding and transcription test cases. -* run with `python manage.py test pod.video_encode_transcript.tests.test_remote_encode_transcode` +Run with `python manage.py test pod.video_encode_transcript.tests.test_remote_encode_transcode` """ +import json +import os +import shutil +import time from unittest import TestCase, skipUnless + from django.conf import settings +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.files.temp import NamedTemporaryFile from django.core.files.uploadedfile import SimpleUploadedFile -from django.contrib.auth.models import User from rest_framework.authtoken.models import Token +from pod.completion.models import Track from pod.cut.models import CutVideo from pod.dressing.models import Dressing -from pod.video.models import Video, Type +from pod.video.models import Type, Video from pod.video_encode_transcript import encode -from pod.video_encode_transcript.models import EncodingVideo, PlaylistVideo, EncodingLog -from pod.completion.models import Track - -import shutil -import os -import time -import json +from pod.video_encode_transcript.models import EncodingLog, EncodingVideo, PlaylistVideo TEST_REMOTE_ENCODE = getattr(settings, "TEST_REMOTE_ENCODE", False) VIDEO_TEST = "pod/main/static/video_test/video_test_encodage_transcription.mp4" @@ -37,8 +37,7 @@ TRANSCRIPT_VIDEO = getattr(settings, "TRANSCRIPT_VIDEO", "start_transcript") if getattr(settings, "USE_PODFILE", False): - from pod.podfile.models import CustomImageModel - from pod.podfile.models import UserFolder + from pod.podfile.models import CustomImageModel, UserFolder FILEPICKER = True else: @@ -50,10 +49,10 @@ TEST_REMOTE_ENCODE, "Set TEST_REMOTE_ENCODE to True before testing remote encoding." ) class RemoteEncodeTranscriptTestCase(TestCase): - """Test case for remote encoding and transcripting of videos.""" + """End-to-end scenarios for remote encoding and optional transcription.""" def setUp(self) -> None: - """Set up the test environment by creating a user, video, and credit video, and copying test files.""" + """Provision remote-encode fixtures: user, token, source video and credit video.""" print("===== SetUp of RemoteEncodeTranscriptTestCase =====") print("===> TEST_REMOTE_ENCODE: %s" % TEST_REMOTE_ENCODE) @@ -65,7 +64,7 @@ def setUp(self) -> None: user.save() self.temp_token = False - # create token + # Create a token only when the configured key does not already exist. if not Token.objects.filter(key=POD_API_TOKEN).exists(): self.temp_token = Token.objects.create(key=POD_API_TOKEN, user=user) video, created = Video.objects.update_or_create( @@ -82,7 +81,7 @@ def setUp(self) -> None: self.user = user self.video = video - # Add credit video for dressing + # Add a dedicated credit video used by dressing tests. credit_video, created = Video.objects.update_or_create( title="credit_video", owner=user, @@ -105,7 +104,7 @@ def setUp(self) -> None: print(" ---> SetUp of RemoteEncodeTranscriptTestCase: OK!") def tearDown(self) -> None: - """Clean up the test environment by deleting the created video, user, and token.""" + """Clean up created objects after each test run.""" if getattr(self, "video", False): self.video.delete() if self.temp_token: @@ -115,7 +114,7 @@ def tearDown(self) -> None: print(" ---> tearDown of RemoteEncodeTranscriptTestCase: OK!") def wait_for_encode_end(self, title="", max_delay=60) -> None: - """Wait for the encoding process to complete, raising an error if it takes too long.""" + """Poll video state until remote encoding ends, with a timeout safeguard.""" tstart = time.time() self.video.refresh_from_db() while self.video.encoding_in_progress: @@ -136,7 +135,7 @@ def wait_for_encode_end(self, title="", max_delay=60) -> None: self.video.refresh_from_db() def test_remote_encoding_transcoding(self) -> None: - """Test the remote encoding and transcripting of a video.""" + """Run remote encoding, then optional transcription when enabled.""" self.remote_encoding() if USE_TRANSCRIPTION: self.remote_transcripting() @@ -147,7 +146,7 @@ def test_remote_encoding_transcoding(self) -> None: print(" ---> test_remote_encoding_transcoding: OK!") def remote_encoding(self) -> None: - """Test the remote encoding of a video, verifying the creation of various encoding formats and logs.""" + """Validate artifacts and metadata generated by the remote encode pipeline.""" print("\n ---> Start Remote encoding video test") encode_video = getattr(encode, ENCODE_VIDEO) encode_video(self.video.id, threaded=False) @@ -180,7 +179,7 @@ def remote_encoding(self) -> None: print("\n ---> End of Remote encoding video test") def test_remote_encoding_cut(self) -> None: - """Launch test of cut video remote encoding.""" + """Encode, cut, and re-encode video while validating resulting artifacts.""" print("\n ---> Start Remote encoding cut video test") encode_video = getattr(encode, ENCODE_VIDEO) @@ -223,13 +222,15 @@ def test_remote_encoding_cut(self) -> None: print("\n ---> End of Remote encoding video cut test") def test_remote_encoding_dressing(self) -> None: - """Launch test of video remote encoding for dressing.""" + """Apply dressing configuration and verify remote encoded output.""" print("\n ---> Start Remote encoding video dressing test") encode_video = getattr(encode, ENCODE_VIDEO) currentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) simplefile = SimpleUploadedFile( name="testimage.jpg", - content=open(os.path.join(currentdir, "tests", "testimage.jpg"), "rb").read(), + content=open( + os.path.join(currentdir, "tests", "testimage.jpg"), "rb" + ).read(), content_type="image/jpeg", ) if FILEPICKER: @@ -255,10 +256,10 @@ def test_remote_encoding_dressing(self) -> None: dressing.videos.add(self.video) dressing.save() - # Start encoding + # Start remote encoding with dressing applied. encode_video(self.video.id, threaded=False) - # Wait 10 minutes for dressing encode end ?? + # Dressing steps are heavier, so use a larger timeout. self.wait_for_encode_end("dressing", 600) print("end of dressing encoding") @@ -294,7 +295,7 @@ def test_remote_encoding_dressing(self) -> None: print("\n ---> End of Remote encoding video dressing test") def remote_transcripting(self) -> None: - """Launch test of video remote transcripting.""" + """Run remote transcription and ensure a track is created.""" print("\n ---> Start Remote transcripting video test") if self.video.get_video_mp3() and not self.video.encoding_in_progress: self.video.transcript = "fr" diff --git a/pod/video_encode_transcript/tests/test_runner_manager_admin.py b/pod/video_encode_transcript/tests/test_runner_manager_admin.py new file mode 100644 index 0000000000..f71550f75f --- /dev/null +++ b/pod/video_encode_transcript/tests/test_runner_manager_admin.py @@ -0,0 +1,123 @@ +""" +Runner manager admin interface tests for Esup-Pod. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_runner_manager_admin` +""" + +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages +from django.contrib.sites.models import Site +from django.test import TestCase +from django.urls import reverse +from requests import RequestException + +from pod.video_encode_transcript.models import RunnerManager + + +class RunnerManagerAdminTests(TestCase): + """Ensure admin actions and feedback for runner manager connectivity.""" + + def setUp(self) -> None: + """Create a superuser client session and one runner manager instance.""" + user_model = get_user_model() + self.admin_user = user_model.objects.create_superuser( + username="admin-rm-test", + email="admin-rm-test@example.com", + password="admin-rm-test", + ) + self.client.force_login(self.admin_user) + + site = Site.objects.filter(pk=1).first() or Site.objects.first() + if site is None: + site = Site.objects.create(domain="example.com", name="example.com") + + self.runner_manager = RunnerManager.objects.create( + name="rm-admin-test", + priority=1, + url="https://runner.example.com/", + token="runner-token", + site=site, + ) + self.change_url = reverse( + "admin:video_encode_transcript_runnermanager_change", + args=[self.runner_manager.pk], + ) + self.test_connection_url = reverse( + "admin:video_encode_transcript_runnermanager_test_connection", + args=[self.runner_manager.pk], + ) + + def _messages(self, response): + """Extract Django message strings from a response.""" + return [str(message) for message in get_messages(response.wsgi_request)] + + def test_change_page_displays_test_connection_button(self): + """Display the custom test-connection button on the admin change form.""" + response = self.client.get(self.change_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test connection") + self.assertContains(response, "test-connection-link") + self.assertContains(response, self.test_connection_url) + + @patch("pod.video_encode_transcript.admin.requests.get") + def test_test_connection_reports_unreachable_runner(self, mocked_get): + """Show an explicit error message when the runner endpoint is unreachable.""" + mocked_get.side_effect = RequestException("connection refused") + + response = self.client.get(self.test_connection_url, follow=True) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + any( + "Unable to reach runner manager" in message + for message in self._messages(response) + ) + ) + + @patch("pod.video_encode_transcript.admin.requests.get") + def test_test_connection_reports_invalid_token(self, mocked_get): + """Show an authentication error when runner manager rejects the token.""" + mocked_get.return_value = Mock(status_code=401) + + response = self.client.get(self.test_connection_url, follow=True) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + any( + "rejected authentication" in message + for message in self._messages(response) + ) + ) + mocked_get.assert_called_once_with( + "https://runner.example.com/manager/health", + headers={ + "Accept": "application/json", + "Authorization": "Bearer runner-token", + }, + timeout=15, + ) + + @patch("pod.video_encode_transcript.admin.requests.get") + def test_test_connection_reports_success(self, mocked_get): + """Show a success message when health endpoint validates the runner token.""" + mocked_get.return_value = Mock(status_code=200) + + response = self.client.get(self.test_connection_url, follow=True) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + any( + "Connection to runner manager" in message + for message in self._messages(response) + ) + ) + mocked_get.assert_called_once_with( + "https://runner.example.com/manager/health", + headers={ + "Accept": "application/json", + "Authorization": "Bearer runner-token", + }, + timeout=15, + ) diff --git a/pod/video_encode_transcript/tests/test_runner_manager_round_robin.py b/pod/video_encode_transcript/tests/test_runner_manager_round_robin.py new file mode 100644 index 0000000000..88760caa7b --- /dev/null +++ b/pod/video_encode_transcript/tests/test_runner_manager_round_robin.py @@ -0,0 +1,112 @@ +""" +Runner manager round-robin ordering tests for Esup-Pod. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_runner_manager_round_robin` +""" + +from django.contrib.sites.models import Site +from django.test import TestCase + +from pod.video_encode_transcript.models import RunnerManager, Task +from pod.video_encode_transcript.runner_manager import _get_runner_managers + + +class RunnerManagerRoundRobinTests(TestCase): + """Validate runner manager ordering and per-priority group rotation.""" + + def setUp(self) -> None: + """Ensure a site exists for runner manager associations.""" + self.site = Site.objects.filter(pk=1).first() + if self.site is None: + self.site = Site.objects.create(domain="example.com", name="example.com") + + def test_get_runner_managers_initial_order_when_no_history(self): + """Keep creation order inside each priority level when no history exists.""" + rm1 = RunnerManager.objects.create( + name="rm-1", + priority=1, + url="https://rr-initial-1.example.com/", + token="token-1", + site=self.site, + ) + rm2 = RunnerManager.objects.create( + name="rm-2", + priority=1, + url="https://rr-initial-2.example.com/", + token="token-2", + site=self.site, + ) + rm3 = RunnerManager.objects.create( + name="rm-3", + priority=2, + url="https://rr-initial-3.example.com/", + token="token-3", + site=self.site, + ) + + ordered_ids = [rm.id for rm in _get_runner_managers(self.site)] + + self.assertEqual(ordered_ids, [rm1.id, rm2.id, rm3.id]) + + def test_get_runner_managers_rotates_same_priority_group(self): + """Rotate only managers of the same priority after each running task.""" + rm1 = RunnerManager.objects.create( + name="rm-a", + priority=1, + url="https://rr-rotate-a.example.com/", + token="token-a", + site=self.site, + ) + rm2 = RunnerManager.objects.create( + name="rm-b", + priority=1, + url="https://rr-rotate-b.example.com/", + token="token-b", + site=self.site, + ) + + Task.objects.create(type="encoding", status="running", runner_manager=rm1) + ordered_after_rm1 = [rm.id for rm in _get_runner_managers(self.site)] + self.assertEqual(ordered_after_rm1, [rm2.id, rm1.id]) + + Task.objects.create(type="encoding", status="running", runner_manager=rm2) + ordered_after_rm2 = [rm.id for rm in _get_runner_managers(self.site)] + self.assertEqual(ordered_after_rm2, [rm1.id, rm2.id]) + + def test_get_runner_managers_rotates_each_priority_group_independently(self): + """Rotate each priority group independently based on its own task history.""" + rm1 = RunnerManager.objects.create( + name="rm-p1-a", + priority=1, + url="https://rr-group-p1-a.example.com/", + token="token-p1-a", + site=self.site, + ) + rm2 = RunnerManager.objects.create( + name="rm-p1-b", + priority=1, + url="https://rr-group-p1-b.example.com/", + token="token-p1-b", + site=self.site, + ) + rm3 = RunnerManager.objects.create( + name="rm-p2-a", + priority=2, + url="https://rr-group-p2-a.example.com/", + token="token-p2-a", + site=self.site, + ) + rm4 = RunnerManager.objects.create( + name="rm-p2-b", + priority=2, + url="https://rr-group-p2-b.example.com/", + token="token-p2-b", + site=self.site, + ) + + Task.objects.create(type="encoding", status="running", runner_manager=rm1) + Task.objects.create(type="encoding", status="running", runner_manager=rm3) + + ordered_ids = [rm.id for rm in _get_runner_managers(self.site)] + + self.assertEqual(ordered_ids, [rm2.id, rm1.id, rm4.id, rm3.id]) diff --git a/pod/video_encode_transcript/tests/test_studio_integration.py b/pod/video_encode_transcript/tests/test_studio_integration.py new file mode 100644 index 0000000000..3e301c2f3a --- /dev/null +++ b/pod/video_encode_transcript/tests/test_studio_integration.py @@ -0,0 +1,84 @@ +""" +Studio task integration tests for Esup-Pod video creation helpers. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_studio_integration` +""" + +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase, override_settings + +from pod.video_encode_transcript.views import _create_video_from_studio_task + + +class StudioFlowIntegrationTests(TestCase): + """Cover the studio task to video creation path and file relocation behavior.""" + + def test_create_video_and_move_task_dir(self): + """Create a video from a studio task and move extracted files to final media path.""" + with tempfile.TemporaryDirectory(prefix="podv4_media_") as tmp_media_root: + # Arrange settings + with override_settings(MEDIA_ROOT=tmp_media_root, VIDEOS_DIR="videos"): + task_id = 42 + recording_id = 9 + user_hash = "abc123" + src_dir = os.path.join(tmp_media_root, "tasks", str(task_id)) + os.makedirs(src_dir, exist_ok=True) + + # Create expected source files + studio_base = os.path.join(src_dir, "studio_base.mp4") + with open(studio_base, "wb") as f: + f.write(b"MP4DATA") + # Additional file to ensure the whole folder is moved + with open( + os.path.join(src_dir, "extra.txt"), "w", encoding="utf-8" + ) as f: + f.write("EXTRA") + + # Fake objects returned by patched calls + fake_video = SimpleNamespace(id=777) + fake_recording = SimpleNamespace( + user=SimpleNamespace(owner=SimpleNamespace(hashkey=user_hash)) + ) + + # Build a lightweight task-like object + task = SimpleNamespace(id=task_id, recording_id=recording_id) + + with patch( + "pod.video_encode_transcript.views.save_basic_video", + return_value=fake_video, + ) as mock_save_video, patch( + "pod.video_encode_transcript.views.Recording.objects.get", + return_value=fake_recording, + ) as mock_get_recording: + # Act + video_id = _create_video_from_studio_task( + task, extracted_dir=src_dir + ) + + # Assert save_basic_video usage + mock_save_video.assert_called_once() + args, kwargs = mock_save_video.call_args + # (recording, src_file) + self.assertEqual(args[1], studio_base) + mock_get_recording.assert_called_once_with(id=recording_id) + self.assertEqual(video_id, fake_video.id) + + # Destination path must include user hash and video id + dest_dir = os.path.join( + tmp_media_root, "videos", user_hash, str(fake_video.id) + ) + self.assertTrue(os.path.isdir(dest_dir)) + # Source folder removed + self.assertFalse(os.path.exists(src_dir)) + # Files moved + self.assertTrue( + os.path.isfile(os.path.join(dest_dir, "studio_base.mp4")) + ) + with open( + os.path.join(dest_dir, "extra.txt"), "r", encoding="utf-8" + ) as f: + self.assertEqual(f.read(), "EXTRA") diff --git a/pod/video_encode_transcript/tests/test_task_queue.py b/pod/video_encode_transcript/tests/test_task_queue.py new file mode 100644 index 0000000000..d0ec03015f --- /dev/null +++ b/pod/video_encode_transcript/tests/test_task_queue.py @@ -0,0 +1,140 @@ +""" +Task queue ranking and lookup tests for Esup-Pod. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_task_queue` +""" + +from datetime import timedelta + +from django.contrib.auth.models import User +from django.contrib.sites.models import Site +from django.test import TestCase +from django.utils import timezone + +from pod.video.models import Type, Video +from pod.video_encode_transcript.models import Task +from pod.video_encode_transcript.task_queue import ( + get_video_pending_encoding_queue_info, + refresh_pending_task_ranks, +) + +# ggignore-start +# gitguardian:ignore +PWD = "azerty1234" # nosec +# ggignore-end + + +class TaskQueueTests(TestCase): + """Validate queue rank computation and pending-queue lookup behavior.""" + + fixtures = ["initial_data.json"] + + def setUp(self) -> None: + """Create users/videos with different affiliations used by ranking tests.""" + self.site = Site.objects.get(id=1) + self.teacher = User.objects.create(username="teacher", password=PWD) # nosem + self.student = User.objects.create(username="student", password=PWD) # nosem + + self.teacher.owner.affiliation = "faculty" + self.teacher.owner.sites.add(Site.objects.get_current()) + self.teacher.owner.save() + + self.student.owner.affiliation = "student" + self.student.owner.sites.add(Site.objects.get_current()) + self.student.owner.save() + + video_type = Type.objects.get(id=1) + self.teacher_video = Video.objects.create( + title="Teacher video", + owner=self.teacher, + video="teacher.mp4", + type=video_type, + ) + self.teacher_video.sites.add(self.site) + + self.student_video = Video.objects.create( + title="Student video", + owner=self.student, + video="student.mp4", + type=video_type, + ) + self.student_video.sites.add(self.site) + + def test_refresh_pending_task_ranks_applies_priority(self): + """Order pending tasks using affiliation priority and creation time.""" + base_time = timezone.now() - timedelta(hours=1) + student_task = Task.objects.create( + video=self.student_video, + type="encoding", + status="pending", + date_added=base_time, + ) + teacher_task = Task.objects.create( + video=self.teacher_video, + type="encoding", + status="pending", + date_added=base_time + timedelta(minutes=5), + ) + studio_task = Task.objects.create( + type="studio", + status="pending", + date_added=base_time + timedelta(minutes=10), + ) + + refresh_pending_task_ranks() + + teacher_task.refresh_from_db() + studio_task.refresh_from_db() + student_task.refresh_from_db() + + self.assertEqual(teacher_task.rank, 1) + self.assertEqual(studio_task.rank, 2) + self.assertEqual(student_task.rank, 3) + + def test_refresh_pending_task_ranks_clears_non_pending_rank(self): + """Clear rank values on tasks that are no longer pending.""" + running_task = Task.objects.create( + video=self.teacher_video, + type="encoding", + status="running", + rank=9, + ) + Task.objects.create( + video=self.student_video, + type="encoding", + status="pending", + ) + + refresh_pending_task_ranks() + running_task.refresh_from_db() + + self.assertIsNone(running_task.rank) + + def test_get_video_pending_encoding_queue_info_returns_rank_and_total(self): + """Return the expected rank and pending total for a given video.""" + teacher_task = Task.objects.create( + video=self.teacher_video, + type="encoding", + status="pending", + ) + student_task = Task.objects.create( + video=self.student_video, + type="encoding", + status="pending", + ) + Task.objects.create( + video=self.teacher_video, + type="encoding", + status="running", + ) + + refresh_pending_task_ranks() + rank, total = get_video_pending_encoding_queue_info(self.student_video) + + teacher_task.refresh_from_db() + student_task.refresh_from_db() + + self.assertEqual(teacher_task.rank, 1) + self.assertEqual(student_task.rank, 2) + self.assertEqual(rank, 2) + self.assertEqual(total, 2) diff --git a/pod/video_encode_transcript/tests/test_transcription_language.py b/pod/video_encode_transcript/tests/test_transcription_language.py new file mode 100644 index 0000000000..25435a6885 --- /dev/null +++ b/pod/video_encode_transcript/tests/test_transcription_language.py @@ -0,0 +1,57 @@ +"""Tests for Esup-Pod transcription language resolution fallbacks.""" + +from django.contrib.auth.models import User +from django.test import TestCase + +from pod.completion.models import Track +from pod.video.models import Type, Video +from pod.video_encode_transcript.runner_manager import _prepare_transcription_parameters +from pod.video_encode_transcript.transcript import resolve_transcription_language + + +class TranscriptionLanguageResolutionTests(TestCase): + """Validate fallback behavior when transcription language is missing.""" + + fixtures = [ + "initial_data.json", + ] + + def setUp(self) -> None: + """Create a baseline video object used by each language fallback test.""" + owner = User.objects.create(username="lang_resolution_owner") + videotype = Type.objects.create(title="others") + self.video = Video.objects.create( + title="video-lang-resolution", + type=videotype, + owner=owner, + video="test.mp4", + main_lang="fr", + ) + + def test_resolve_transcription_language_prefers_video_transcript(self) -> None: + """Use video.transcript when it is explicitly set.""" + self.video.transcript = "en" + self.video.save(update_fields=["transcript"]) + Track.objects.create(video=self.video, lang="de") + + self.assertEqual(resolve_transcription_language(self.video), "en") + + def test_resolve_transcription_language_uses_track_when_transcript_empty( + self, + ) -> None: + """Fallback to the first available track language when transcript is empty.""" + self.video.transcript = "" + self.video.save(update_fields=["transcript"]) + Track.objects.create(video=self.video, lang="de") + + self.assertEqual(resolve_transcription_language(self.video), "de") + + def test_prepare_transcription_parameters_uses_resolved_language(self) -> None: + """Pass the resolved fallback language into transcription runner params.""" + self.video.transcript = "" + self.video.save(update_fields=["transcript"]) + Track.objects.create(video=self.video, lang="es") + + params = _prepare_transcription_parameters(self.video) + + self.assertEqual(params["language"], "es") diff --git a/pod/video_encode_transcript/tests/test_utils.py b/pod/video_encode_transcript/tests/test_utils.py index 6cd9237ce9..51c3089e48 100644 --- a/pod/video_encode_transcript/tests/test_utils.py +++ b/pod/video_encode_transcript/tests/test_utils.py @@ -1,10 +1,11 @@ """ Unit tests for Esup-Pod video encoding utilities. -Run with `python manage.py test pod.video_encode_transcript.tests.EncodingUtilitiesTests` +Run with `python manage.py test pod.video_encode_transcript.tests.test_utils` """ import unittest + from ..encoding_utils import get_dressing_position_value, sec_to_timestamp @@ -12,7 +13,7 @@ class EncodingUtilitiesTests(unittest.TestCase): """TestCase for Esup-Pod encoding utilities.""" def test_dressing_position_value(self) -> None: - """Test for the get_position_value function.""" + """Return the expected ffmpeg overlay expression for each watermark corner.""" result = get_dressing_position_value("top_right", "720") self.assertEqual(result, "overlay=main_w-overlay_w-36.0:36.0") @@ -28,7 +29,7 @@ def test_dressing_position_value(self) -> None: print(" ---> get_dressing_position_value: OK! --- EncodginUtilsTest") def test_sec_to_timestamp(self) -> None: - """Test sec_to_timestamp return values.""" + """Convert seconds to a normalized HH:MM:SS.mmm timestamp string.""" self.assertEqual(sec_to_timestamp(-1), "00:00:00.000") self.assertEqual(sec_to_timestamp(60.000), "00:01:00.000") print(" ---> sec_to_timestamp: OK! --- EncodginUtilsTest") diff --git a/pod/video_encode_transcript/tests/test_views_helpers.py b/pod/video_encode_transcript/tests/test_views_helpers.py new file mode 100644 index 0000000000..b9012cf0a6 --- /dev/null +++ b/pod/video_encode_transcript/tests/test_views_helpers.py @@ -0,0 +1,335 @@ +""" +Views helper unit tests for Esup-Pod video encoding workflows. + +Run with `python manage.py test pod.video_encode_transcript.tests.test_views_helpers` +""" + +import os +import shutil +import tempfile +import unittest +from hashlib import sha256 +from types import SimpleNamespace +from unittest.mock import patch + +import requests + +from pod.video_encode_transcript.views import ( + _download_and_store_manifest_member, + _get_user_hashkey_from_recording, + _merge_or_move_directory, +) + + +class FakeOwner: + """Minimal owner-like object exposing a hashkey.""" + + def __init__(self, hashkey): + self.hashkey = hashkey + + +class FakeUser: + """Minimal user-like object exposing an owner relation.""" + + def __init__(self, owner): + self.owner = owner + + +class FakeRecording: + """Minimal recording-like object exposing a user relation.""" + + def __init__(self, user): + self.user = user + + +class ViewsHelpersTests(unittest.TestCase): + """Test utility helpers used by view-side studio ingestion flow.""" + + def setUp(self) -> None: + """Create a dedicated temporary root folder for each test.""" + self.tmp_root = tempfile.mkdtemp(prefix="podv4_test_") + + def tearDown(self) -> None: + """Clean up temporary files created during the test.""" + try: + shutil.rmtree(self.tmp_root) + except Exception: + pass + + def _touch(self, path: str, content: str = "x") -> None: + """Create a file with content, creating parent directories as needed.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + def test_merge_or_move_directory_move_when_dest_absent(self): + """Move source directory as-is when destination does not exist.""" + src_dir = os.path.join(self.tmp_root, "src") + dest_dir = os.path.join(self.tmp_root, "dest") + os.makedirs(src_dir, exist_ok=True) + self._touch(os.path.join(src_dir, "a.txt"), "A") + os.makedirs(os.path.join(src_dir, "sub"), exist_ok=True) + self._touch(os.path.join(src_dir, "sub", "b.txt"), "B") + + _merge_or_move_directory(src_dir, dest_dir) + + self.assertFalse(os.path.exists(src_dir)) + self.assertTrue(os.path.isdir(dest_dir)) + with open(os.path.join(dest_dir, "a.txt"), "r", encoding="utf-8") as f: + self.assertEqual(f.read(), "A") + with open(os.path.join(dest_dir, "sub", "b.txt"), "r", encoding="utf-8") as f: + self.assertEqual(f.read(), "B") + + def test_merge_or_move_directory_merge_when_dest_exists(self): + """Merge source content into destination and replace collisions with source files.""" + src_dir = os.path.join(self.tmp_root, "src") + dest_dir = os.path.join(self.tmp_root, "dest") + os.makedirs(src_dir, exist_ok=True) + os.makedirs(dest_dir, exist_ok=True) + + # Files in src + self._touch(os.path.join(src_dir, "same.txt"), "SRC") + self._touch(os.path.join(src_dir, "only_src.txt"), "ONLY_SRC") + os.makedirs(os.path.join(src_dir, "dirX"), exist_ok=True) + self._touch(os.path.join(src_dir, "dirX", "in_src.txt"), "IN_SRC") + + # Files in dest + self._touch(os.path.join(dest_dir, "same.txt"), "DEST") # should be replaced + self._touch( + os.path.join(dest_dir, "only_dest.txt"), "ONLY_DEST" + ) # should remain + os.makedirs(os.path.join(dest_dir, "dirX"), exist_ok=True) + self._touch( + os.path.join(dest_dir, "dirX", "to_remove.txt"), "TO_REMOVE" + ) # dir should be replaced + + _merge_or_move_directory(src_dir, dest_dir) + + # src dir removed + self.assertFalse(os.path.exists(src_dir)) + # dest contains merged content + with open(os.path.join(dest_dir, "same.txt"), "r", encoding="utf-8") as f: + self.assertEqual(f.read(), "SRC") # replaced by src + with open(os.path.join(dest_dir, "only_dest.txt"), "r", encoding="utf-8") as f: + self.assertEqual(f.read(), "ONLY_DEST") # preserved + with open(os.path.join(dest_dir, "only_src.txt"), "r", encoding="utf-8") as f: + self.assertEqual(f.read(), "ONLY_SRC") # added + # dirX replaced by src version + self.assertTrue(os.path.isdir(os.path.join(dest_dir, "dirX"))) + self.assertFalse( + os.path.exists(os.path.join(dest_dir, "dirX", "to_remove.txt")) + ) + with open( + os.path.join(dest_dir, "dirX", "in_src.txt"), "r", encoding="utf-8" + ) as f: + self.assertEqual(f.read(), "IN_SRC") + + def test_get_user_hashkey_from_recording_success(self): + """Extract owner hashkey from a well-formed recording-like object.""" + fake = FakeRecording(FakeUser(FakeOwner("abc123"))) + hk = _get_user_hashkey_from_recording(fake) + self.assertEqual(hk, "abc123") + + def test_get_user_hashkey_from_recording_failure(self): + """Raise RuntimeError when expected nested attributes are missing.""" + + class BadRecording: + pass + + with self.assertRaises(RuntimeError): + _get_user_hashkey_from_recording(BadRecording()) + + def test_download_and_store_manifest_member_retries_chunked_transfer(self): + """Retry streamed download on chunked transfer break and overwrite partial data.""" + + class FakeResponse: + def __init__( + self, + chunks: list[bytes], + error: Exception | None = None, + ) -> None: + self._chunks = chunks + self._error = error + self.closed = False + + def iter_content(self, chunk_size: int = 0): + del chunk_size + for chunk in self._chunks: + yield chunk + if self._error is not None: + raise self._error + + def close(self) -> None: + self.closed = True + + first = FakeResponse( + [b"partial"], + requests.exceptions.ChunkedEncodingError("Connection broken"), + ) + second = FakeResponse([b"complete"]) + task = SimpleNamespace(id=7) + + with patch( + "pod.video_encode_transcript.views._download_manifest_member", + side_effect=[first, second], + ) as mock_download: + with patch("pod.video_encode_transcript.views.time.sleep") as mock_sleep: + with patch( + "pod.video_encode_transcript.views._compute_manifest_retry_delay", + return_value=0.0, + ): + dest_path = _download_and_store_manifest_member( + "https://runner.example/result/file", + {"Authorization": "Bearer test"}, + "folder/video.mp4", + task, + self.tmp_root, + max_attempts=2, + ) + + self.assertEqual(mock_download.call_count, 2) + self.assertEqual(mock_sleep.call_count, 1) + self.assertTrue(first.closed) + self.assertTrue(second.closed) + self.assertIsNotNone(dest_path) + self.assertTrue(isinstance(dest_path, str)) + with open(str(dest_path), "rb") as f: + self.assertEqual(f.read(), b"complete") + + def test_download_and_store_manifest_member_removes_partial_file_on_failure(self): + """Clean up partial file and return None when all retries fail.""" + + class FailingResponse: + def __init__(self) -> None: + self.closed = False + + def iter_content(self, chunk_size: int = 0): + del chunk_size + yield b"incomplete" + raise requests.exceptions.ChunkedEncodingError("stream interrupted") + + def close(self) -> None: + self.closed = True + + responses = [FailingResponse(), FailingResponse()] + task = SimpleNamespace(id=9) + + with patch( + "pod.video_encode_transcript.views._download_manifest_member", + side_effect=responses, + ): + with patch("pod.video_encode_transcript.views.time.sleep"): + with patch( + "pod.video_encode_transcript.views._compute_manifest_retry_delay", + return_value=0.0, + ): + dest_path = _download_and_store_manifest_member( + "https://runner.example/result/file", + {"Authorization": "Bearer test"}, + "broken/output.bin", + task, + self.tmp_root, + max_attempts=2, + ) + + self.assertIsNone(dest_path) + self.assertFalse( + os.path.exists(os.path.join(self.tmp_root, "broken", "output.bin")) + ) + self.assertTrue(all(response.closed for response in responses)) + + def test_download_and_store_manifest_member_retries_on_checksum_mismatch(self): + """Retry when checksum validation fails and keep final valid file.""" + + class FakeResponse: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.closed = False + + def iter_content(self, chunk_size: int = 0): + del chunk_size + for chunk in self._chunks: + yield chunk + + def close(self) -> None: + self.closed = True + + expected_hash = sha256(b"good-data").hexdigest() + first = FakeResponse([b"bad-data"]) + second = FakeResponse([b"good-data"]) + task = SimpleNamespace(id=11) + + with patch( + "pod.video_encode_transcript.views._download_manifest_member", + side_effect=[first, second], + ) as mock_download: + with patch("pod.video_encode_transcript.views.time.sleep") as mock_sleep: + with patch( + "pod.video_encode_transcript.views._compute_manifest_retry_delay", + return_value=0.0, + ): + dest_path = _download_and_store_manifest_member( + "https://runner.example/result/file", + {"Authorization": "Bearer test"}, + "checksum/output.bin", + task, + self.tmp_root, + expected_sha256=expected_hash, + max_attempts=2, + ) + + self.assertEqual(mock_download.call_count, 2) + self.assertEqual(mock_sleep.call_count, 1) + self.assertTrue(first.closed) + self.assertTrue(second.closed) + self.assertIsNotNone(dest_path) + self.assertTrue(isinstance(dest_path, str)) + with open(str(dest_path), "rb") as f: + self.assertEqual(f.read(), b"good-data") + + def test_download_and_store_manifest_member_atomic_write_keeps_existing_file(self): + """Do not corrupt existing file when checksum validation fails.""" + + class FakeResponse: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + self.closed = False + + def iter_content(self, chunk_size: int = 0): + del chunk_size + for chunk in self._chunks: + yield chunk + + def close(self) -> None: + self.closed = True + + expected_hash = sha256(b"expected").hexdigest() + task = SimpleNamespace(id=12) + existing_path = os.path.join(self.tmp_root, "atomic", "artifact.bin") + os.makedirs(os.path.dirname(existing_path), exist_ok=True) + with open(existing_path, "wb") as f: + f.write(b"stable-data") + + failing_response = FakeResponse([b"corrupt-data"]) + with patch( + "pod.video_encode_transcript.views._download_manifest_member", + return_value=failing_response, + ): + dest_path = _download_and_store_manifest_member( + "https://runner.example/result/file", + {"Authorization": "Bearer test"}, + "atomic/artifact.bin", + task, + self.tmp_root, + expected_sha256=expected_hash, + max_attempts=1, + ) + + self.assertIsNone(dest_path) + self.assertTrue(failing_response.closed) + with open(existing_path, "rb") as f: + self.assertEqual(f.read(), b"stable-data") + + +if __name__ == "__main__": + unittest.main() diff --git a/pod/video_encode_transcript/transcript.py b/pod/video_encode_transcript/transcript.py index 508c76cdc9..8edee9f97f 100644 --- a/pod/video_encode_transcript/transcript.py +++ b/pod/video_encode_transcript/transcript.py @@ -1,19 +1,20 @@ """Esup-Pod transcript video functions.""" +import importlib.util + from django.conf import settings from django.core.files import File from pod.completion.models import Track from pod.main.tasks import task_start_transcript from webvtt import Caption, WebVTT +from ..video.models import Video from .utils import ( + add_encoding_log, + change_encoding_step, send_email, send_email_transcript, - change_encoding_step, - add_encoding_log, ) -from ..video.models import Video -import importlib.util if ( importlib.util.find_spec("vosk") is not None @@ -26,15 +27,13 @@ def start_transcripting(*args, **kwargs): raise NotImplementedError("No transcription engine available.") -from .encoding_utils import sec_to_timestamp - +import logging import os +import threading import time - from tempfile import NamedTemporaryFile -import threading -import logging +from .encoding_utils import sec_to_timestamp if getattr(settings, "USE_PODFILE", False): __FILEPICKER__ = True @@ -65,29 +64,68 @@ def start_transcripting(*args, **kwargs): "CAPTIONS_STRICT_ACCESSIBILITY", False, ) +USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) log = logging.getLogger(__name__) -# ########################################################################## -# TRANSCRIPT VIDEO: THREAD TO LAUNCH TRANSCRIPT -# ########################################################################## +def resolve_transcription_language(video: Video, lang_code: str | None = None) -> str: + """Resolve transcription language with fallbacks. + + Priority: + 1. Explicit lang_code argument. + 2. Video.transcript field. + 3. Last existing subtitle track language for this video. + 4. Video.main_lang. + 5. DEFAULT_LANG_TRACK setting (fallback "fr"). + """ + if lang_code: + return lang_code + + if getattr(video, "transcript", None): + return str(video.transcript) + + track_lang = ( + Track.objects.filter(video=video, kind="subtitles") + .exclude(lang__isnull=True) + .exclude(lang="") + .order_by("-id") + .values_list("lang", flat=True) + .first() + ) + if track_lang: + return str(track_lang) + + if getattr(video, "main_lang", None): + return str(video.main_lang) + + return str(getattr(settings, "DEFAULT_LANG_TRACK", "fr")) + + def start_transcript(video_id, threaded=True) -> None: """ Call to start transcript main function. Will launch transcript mode depending on configuration. """ - if threaded: - if CELERY_TO_ENCODE: - task_start_transcript.delay(video_id) - else: - log.info("START TRANSCRIPT VIDEO %s" % video_id) - t = threading.Thread(target=main_threaded_transcript, args=[video_id]) - t.daemon = True - t.start() + if USE_RUNNER_MANAGER: + log.info("Start transcription, with runner manager, for id: %s" % video_id) + # Load module here to prevent circular import + from .runner_manager import transcript_video + + transcript_video(video_id) else: - main_threaded_transcript(video_id) + log.info("Start transcription, without runner manager, for id: %s" % video_id) + if threaded: + if CELERY_TO_ENCODE: + task_start_transcript.delay(video_id) + else: + log.info("START TRANSCRIPT VIDEO %s" % video_id) + t = threading.Thread(target=main_threaded_transcript, args=[video_id]) + t.daemon = True + t.start() + else: + main_threaded_transcript(video_id) def main_threaded_transcript(video_to_encode_id) -> None: @@ -145,10 +183,24 @@ def save_vtt_and_notify(video_to_encode, msg, webvtt) -> None: add_encoding_log(video_to_encode.id, msg) +def save_vtt_and_notify_with_lang( + video_to_encode, msg, webvtt, lang_code: str = None +) -> None: + """Call save vtt file function and notify by mail at the end.""" + msg += save_vtt(video_to_encode, webvtt, lang_code) + change_encoding_step(video_to_encode.id, 0, "done") + video_to_encode.encoding_in_progress = False + video_to_encode.save() + # envois mail fin transcription + if EMAIL_ON_TRANSCRIPTING_COMPLETION: + send_email_transcript(video_to_encode) + add_encoding_log(video_to_encode.id, msg) + + def save_vtt(video: Video, webvtt: WebVTT, lang_code: str = None) -> str: """Save webvtt file with the video.""" msg = "\nSAVE TRANSCRIPT WEBVTT : %s" % time.ctime() - lang = lang_code if lang_code else video.transcript + lang = resolve_transcription_language(video, lang_code) temp_vtt_file = NamedTemporaryFile(suffix=".vtt") webvtt.save(temp_vtt_file.name) if webvtt.captions: diff --git a/pod/video_encode_transcript/transcript_model.py b/pod/video_encode_transcript/transcript_model.py index 6b63e89079..75c353c75c 100644 --- a/pod/video_encode_transcript/transcript_model.py +++ b/pod/video_encode_transcript/transcript_model.py @@ -1,17 +1,21 @@ -import numpy as np -import shlex -import subprocess -import json +"""Transcription helpers for Esup-Pod video encoding workflows. + +This module prepares audio, runs Vosk or Whisper inference, and builds WebVTT captions. +""" -import os -from timeit import default_timer as timer import datetime as dt +import json +import logging +import os +import shlex +import subprocess from datetime import timedelta -import webvtt -from webvtt import WebVTT, Caption from shlex import quote +from timeit import default_timer as timer -import logging +import numpy as np +import webvtt +from webvtt import Caption, WebVTT try: from ..custom import settings_local @@ -27,7 +31,7 @@ if USE_TRANSCRIPTION: TRANSCRIPTION_TYPE = getattr(settings_local, "TRANSCRIPTION_TYPE", "WHISPER") if TRANSCRIPTION_TYPE == "VOSK": - from vosk import Model, KaldiRecognizer + from vosk import KaldiRecognizer, Model elif TRANSCRIPTION_TYPE == "WHISPER": import whisper from whisper.utils import get_writer @@ -52,7 +56,9 @@ def get_model(lang): """Get model for Whisper or Vosk software to transcript audio.""" - transript_model = Model(TRANSCRIPTION_MODEL_PARAM[TRANSCRIPTION_TYPE][lang]["model"]) + transript_model = Model( + TRANSCRIPTION_MODEL_PARAM[TRANSCRIPTION_TYPE][lang]["model"] + ) return transript_model @@ -213,7 +219,10 @@ def words_to_vtt( # 0.58, 'duration': 7.34} text_caption.append(word["word"]) if not ( - (((word[start_key]) - start_caption) < TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH) + ( + ((word[start_key]) - start_caption) + < TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH + ) and ( next_word is not None and (blank_duration < TRANSCRIPTION_STT_SENTENCE_BLANK_SPLIT_TIME) diff --git a/pod/video_encode_transcript/transcripting_tasks.py b/pod/video_encode_transcript/transcripting_tasks.py index ac023c2a8f..2d9628fdc3 100644 --- a/pod/video_encode_transcript/transcripting_tasks.py +++ b/pod/video_encode_transcript/transcripting_tasks.py @@ -3,11 +3,13 @@ # pip3 install celery==5.4.0 # pip3 install webvtt-py # pip3 install redis==4.5.4 -from celery import Celery -from tempfile import NamedTemporaryFile import logging import os +from tempfile import NamedTemporaryFile + import requests +from celery import Celery + from ..main.settings import MEDIA_ROOT # call local settings directly diff --git a/pod/video_encode_transcript/urls.py b/pod/video_encode_transcript/urls.py new file mode 100644 index 0000000000..09d37c0f08 --- /dev/null +++ b/pod/video_encode_transcript/urls.py @@ -0,0 +1,11 @@ +"""URL patterns used for Esup-Pod in video_encode_transcript application.""" + +from django.urls import path +from pod.video_encode_transcript.views import notify_task_end + +app_name = "video_encode_transcript" + +urlpatterns = [ + # This endpoint is called by the runner manager when a task is completed, to update the task status and send notifications. + path("notify_task_end/", notify_task_end, name="notify_task_end"), +] diff --git a/pod/video_encode_transcript/utils.py b/pod/video_encode_transcript/utils.py index e0f2b13012..3da27e50e2 100644 --- a/pod/video_encode_transcript/utils.py +++ b/pod/video_encode_transcript/utils.py @@ -1,21 +1,23 @@ """Esup-Pod video encoding and transcripting utilities.""" -import bleach import logging import os import time -from django.urls import reverse +import bleach from django.conf import settings +from django.core.mail import ( + EmailMultiAlternatives, + mail_admins, + mail_managers, + send_mail, +) +from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django.core.mail import mail_admins -from django.core.mail import send_mail -from django.core.mail import mail_managers -from django.core.mail import EmailMultiAlternatives -from pod.video.models import Video from pod.progressive_web_app.utils import notify_user -from .models import EncodingStep -from .models import EncodingLog +from pod.video.models import Video + +from .models import EncodingLog, EncodingStep DEBUG = getattr(settings, "DEBUG", True) logger = logging.getLogger(__name__) diff --git a/pod/video_encode_transcript/views.py b/pod/video_encode_transcript/views.py new file mode 100644 index 0000000000..048fa61ee1 --- /dev/null +++ b/pod/video_encode_transcript/views.py @@ -0,0 +1,894 @@ +"""Views and helpers, useful only for Runner Manager callbacks and artifact imports in Esup-Pod. + +This module handles the full post-processing flow for remote tasks: +- validate and authorize webhook notifications, +- download task result files from the Runner Manager, +- import artifacts back into Pod (encoding, studio, transcription). +""" + +import json +import logging +import os +import random +import secrets +import shutil +import tempfile +import time +from hashlib import sha256 +from typing import TypeAlias, TypedDict, cast + +import requests +import webvtt +from django.conf import settings +from django.core.handlers.wsgi import WSGIRequest +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from pod.recorder.models import Recording +from pod.recorder.plugins.type_studio import save_basic_video +from pod.video.models import Video +from pod.video_encode_transcript.models import RunnerManager, Task +from pod.video_encode_transcript.runner_manager_utils import ( + store_after_remote_encoding_video, + store_remote_encoding_log_recording, +) +from pod.video_encode_transcript.task_queue import refresh_pending_task_ranks +from pod.video_encode_transcript.transcript import save_vtt_and_notify +from pod.video_encode_transcript.utils import send_email_item + +log = logging.getLogger(__name__) + +DEBUG = getattr(settings, "DEBUG", True) +MANIFEST_MEMBER_DOWNLOAD_MAX_RETRIES = 5 +MANIFEST_MEMBER_DOWNLOAD_BACKOFF_BASE_SECONDS = 0.5 +MANIFEST_MEMBER_DOWNLOAD_BACKOFF_MAX_SECONDS = 8.0 + +media_root_setting = getattr(settings, "MEDIA_ROOT", None) +if not media_root_setting: + raise RuntimeError("MEDIA_ROOT is not configured in settings") +MEDIA_ROOT = str(media_root_setting) + +VIDEOS_DIR = getattr(settings, "VIDEOS_DIR", "videos") + +HeadersDict: TypeAlias = dict[str, str] + + +def _get_media_root() -> str: + """Return MEDIA_ROOT from settings at runtime.""" + media_root = getattr(settings, "MEDIA_ROOT", None) + if not media_root: + raise RuntimeError("MEDIA_ROOT is not configured in settings") + return str(media_root) + + +def _get_videos_dir() -> str: + """Return VIDEOS_DIR from settings at runtime.""" + return str(getattr(settings, "VIDEOS_DIR", "videos")) + + +class NotifyTaskPayload(TypedDict, total=False): + """Payload sent by Runner Manager to the notify endpoint.""" + + task_id: str + status: str + script_output: str + error_message: str + + +class ResultManifest(TypedDict, total=False): + """Manifest returned by the Runner Manager result endpoint.""" + + files: list[object] + + +class ManifestMemberIntegrityError(RuntimeError): + """Raised when a downloaded manifest member fails integrity validation.""" + + +def _build_result_url(manager_url: str, task_id: str) -> str: + """Return runner manager result URL ending with trailing slash.""" + if not manager_url.endswith("/"): + manager_url += "/" + return manager_url + f"task/result/{task_id}" + + +def _build_result_file_url(manager_url: str, task_id: str, file_path: str) -> str: + """Return runner manager file URL for a given task result file.""" + if not manager_url.endswith("/"): + manager_url += "/" + return manager_url + f"task/result/{task_id}/file/{file_path}" + + +def _build_headers(token: str) -> HeadersDict: + """Construct headers used when querying runner manager.""" + return { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + + +def _build_file_headers(token: str) -> HeadersDict: + """Construct headers used when downloading files from runner manager.""" + return { + "Accept": "*/*", + "Authorization": f"Bearer {token}", + } + + +def _extract_bearer_token(request: WSGIRequest) -> str | None: + """Return token from Authorization header when using Bearer scheme.""" + authorization = request.headers.get("Authorization", "") + auth_type, _, token = authorization.partition(" ") + token = token.strip() + if auth_type.lower() != "bearer" or not token: + return None + return token + + +def _fetch_task_result( + url: str, headers: HeadersDict, task_id: int | str +) -> requests.Response | None: + """Return HTTP response for a task result or None on failure.""" + try: + response = requests.get(url, headers=headers, timeout=30) + log.info(f"Fetched result for task {task_id}: {url}") + except requests.RequestException as exc: + log.error(f"Error reaching runner manager for task {task_id}: {exc}") + return None + if response.status_code != 200: + log.error( + f"Failed to download result for task {task_id}: {response.status_code} {response.text}" + ) + return None + return response + + +def _finalize_task_import( + task: Task, extracted_dir: str, extracted_vtt_path: str +) -> None: + """Persist result path and import artifacts based on task type.""" + if hasattr(task, "result_path"): + task.result_path = extracted_dir + task.save() + + try: + if task.type == "studio" or getattr(task, "recording_id", None): + # Studio tasks generate a new Video from the base media before importing artifacts. + video_id = _create_video_from_studio_task(task) + recording_id = task.recording_id + if recording_id is None: + raise RuntimeError( + "Studio task missing recording_id after video creation" + ) + store_remote_encoding_log_recording(recording_id, video_id) + store_after_remote_encoding_video(video_id) + elif task.type == "transcription": + _import_transcription_result(task, extracted_vtt_path) + else: + store_after_remote_encoding_video(task.video.id) + task.status = "completed" + task.save() + except Exception as exc: # noqa: BLE001 + log.error(f"Error while importing result for task {task.id}: {exc}") + + +def _parse_notify_task_end_request( + request: WSGIRequest, +) -> tuple[NotifyTaskPayload | None, str | None, JsonResponse | None]: + """Validate request shape and return parsed JSON payload with bearer token.""" + if request.method != "POST": + return ( + None, + None, + JsonResponse({"error": "Only POST requests are allowed."}, status=405), + ) + + content_type = request.headers.get("Content-Type") or "" + if "application/json" not in content_type: + return ( + None, + None, + JsonResponse( + {"error": "Only application/json content type is allowed."}, status=415 + ), + ) + + bearer_token = _extract_bearer_token(request) + if not bearer_token: + return ( + None, + None, + JsonResponse({"error": "Missing or invalid Bearer token."}, status=401), + ) + + try: + payload = json.loads(request.body) + except json.JSONDecodeError: + return None, None, JsonResponse({"error": "Invalid request."}, status=400) + + if not isinstance(payload, dict): + return None, None, JsonResponse({"error": "Invalid request."}, status=400) + + data = cast(NotifyTaskPayload, payload) + return data, bearer_token, None + + +def _get_notify_task( + data: NotifyTaskPayload, +) -> tuple[Task | None, JsonResponse | None]: + """Fetch task referenced in notification payload.""" + task_id = data.get("task_id") + if not isinstance(task_id, str) or not task_id: + return None, JsonResponse({"error": "No task id in the request."}, status=400) + + task = Task.objects.filter(task_id=task_id).select_related("runner_manager").first() + if not task: + return None, JsonResponse({"error": "Task not found."}, status=404) + + return task, None + + +def _authorize_notify_task(task: Task, bearer_token: str) -> JsonResponse | None: + """Check bearer token against the task runner manager token.""" + runner_manager = task.runner_manager + if not runner_manager or not runner_manager.token: + log.error("Task %s has no runner manager token configured", task.task_id) + return JsonResponse( + {"error": "Runner manager token is not configured."}, + status=500, + ) + + if not secrets.compare_digest(bearer_token, runner_manager.token): + return JsonResponse({"error": "Invalid Bearer token."}, status=403) + + return None + + +def _apply_notify_payload_to_task(task: Task, data: NotifyTaskPayload) -> None: + """Persist task status and append optional script output details.""" + task.status = str(data["status"]) + + script_output = task.script_output or "" + error_message = data.get("error_message") + if error_message is not None: + script_output += f"{error_message}\n---\n" + script_output_payload = data.get("script_output") + if script_output_payload is not None: + script_output += str(script_output_payload) + + task.script_output = script_output + task.save() + refresh_pending_task_ranks() + + +@csrf_exempt +def notify_task_end(request: WSGIRequest) -> JsonResponse: + """Receive webhook from the Runner Manager service.""" + data, bearer_token, error_response = _parse_notify_task_end_request(request) + if error_response: + return error_response + + if data is None or bearer_token is None: + return JsonResponse({"error": "Invalid request."}, status=400) + + task, error_response = _get_notify_task(data) + if error_response: + return error_response + + if task is None: + return JsonResponse({"error": "Task not found."}, status=404) + + error_response = _authorize_notify_task(task, bearer_token) + if error_response: + return error_response + + if "status" not in data: + return JsonResponse( + {"status": "Task has not yet been successfully achieved."}, + status=500, + ) + + _apply_notify_payload_to_task(task, data) + + if task.status == "failed": + send_email_item(f"Task {task.id} failed", "Task", task.task_id) + + if task.status == "completed": + download_and_import_task_result(task) + + return JsonResponse({"status": "OK"}, status=200) + + +def _get_runner_manager_for_task(task: Task) -> RunnerManager | None: + """Return task runner manager if available, otherwise log and return None.""" + try: + return RunnerManager.objects.get(id=task.runner_manager_id) + except RunnerManager.DoesNotExist: + log.error(f"Runner manager not found for task {task.id}") + return None + except Exception as exc: # noqa: BLE001 + log.error(f"Error downloading result for task {task.id}: {exc}") + return None + + +def _get_task_result_manifest( + task: Task, runner_manager: RunnerManager +) -> ResultManifest | None: + """Fetch and validate task result manifest from runner manager.""" + if not task.task_id: + log.error(f"Missing task_id for task {task.id}") + return None + + result_url = _build_result_url(runner_manager.url, task.task_id) + headers = _build_headers(runner_manager.token) + response = _fetch_task_result(result_url, headers, task.id) + if not response: + return None + + try: + manifest_payload = response.json() + except ValueError: + manifest_payload = {} + + if not isinstance(manifest_payload, dict): + log.error(f"Invalid manifest JSON for task {task.id}") + return None + + manifest = cast(ResultManifest, manifest_payload) + if not manifest.get("files"): + log.error(f"Invalid manifest JSON for task {task.id}") + return None + + return manifest + + +def download_and_import_task_result(task: Task) -> None: + """Download the result of a completed task from the runner manager, extract it, + and import the encoded video back into Pod. + + Args: + task (Task): Task object + """ + runner_manager = _get_runner_manager_for_task(task) + if not runner_manager: + return + + manifest = _get_task_result_manifest(task, runner_manager) + if manifest is None: + return + + extracted_dir, extracted_vtt_path = _save_manifest_files( + manifest, task, runner_manager.url, runner_manager.token + ) + + if not extracted_dir: + log.error(f"Failed to import result for task {task.id}") + return + + log.info(f"Successfully downloaded and extracted result for task {task.id}") + _finalize_task_import(task, extracted_dir, extracted_vtt_path) + + +def _import_transcription_result(task: Task, extracted_vtt_path: str) -> None: + """Import a transcription result produced by the Runner Manager. + + Expected: at least one .vtt file in the extracted directory. + It will be attached to the related video. Language defaults to the + video's `transcript` field (fallback to main_lang when empty). + + Args: + task: Task of type "transcription" linked to a `Video`. + extracted_vtt_path: Path to the extracted VTT file. + """ + if not getattr(task, "video", None): + raise RuntimeError("Transcription task is not linked to a video") + + log.info(f"Importing VTT file {extracted_vtt_path} for video {task.video.id}") + + # Get the video + video_id = task.video.id + video = Video.objects.get(id=video_id) + # Default message + msg = f"Transcription imported successfully: {extracted_vtt_path}" + # Read the VTT file + wvtt = webvtt.read(extracted_vtt_path) + # Save VTT for Pod and notify user + save_vtt_and_notify(video, msg, wvtt) + log.info(f"Attached VTT transcript to video {video.id}") + + +def _get_destination_directory(task: Task, dest_base: str | None = None) -> str: + """Get and create the destination directory for extraction. + + Args: + task (Task): Task instance used to name the destination directory. + dest_base (str | None): parent directory where to extract. + Returns: + str: path of the destination directory. + """ + if dest_base is None: + # Choose base directory based on task type (encoding, studio or transcription) + if task.type == "transcription" and getattr(task, "video", None): + dest_dir = os.path.join( + MEDIA_ROOT, + VIDEOS_DIR, + str(task.video.owner.owner.hashkey), + str(task.video.id), + ) + elif task.type == "studio": + dest_dir = os.path.join(MEDIA_ROOT, "tasks", str(task.id)) + else: + dest_dir = os.path.join( + MEDIA_ROOT, + VIDEOS_DIR, + str(task.video.owner.owner.hashkey), + str(task.video.id), + ) + log.info(f"Save result into {dest_dir}") + os.makedirs(os.path.dirname(dest_dir), exist_ok=True) + else: + dest_dir = os.path.join(dest_base, str(task.id)) + os.makedirs(dest_dir, exist_ok=True) + return dest_dir + + +def _is_safe_path(dest_dir: str, member: str) -> bool: + """Check whether a manifest entry path is safe to write. + + Args: + dest_dir (str): The destination directory. + member (str): The manifest file path to validate. + Returns: + bool: True if the path is safe, False otherwise. + """ + member_path = os.path.normpath(member) + # Skip absolute paths and parent-traversal entries + if member_path.startswith("..") or os.path.isabs(member_path): + return False + dest_path = os.path.normpath(os.path.join(dest_dir, member_path)) + normalized_dest = os.path.normpath(dest_dir) + # Ensure extraction is within destination directory + return ( + dest_path.startswith(normalized_dest + os.sep) or dest_path == normalized_dest + ) + + +def _should_extract_transcription_member(member: str) -> bool: + """Return True when member must be extracted for transcription tasks.""" + if member.endswith("/"): + return False + base_l = os.path.basename(member).lower() + return base_l.endswith(".vtt") or base_l.endswith(".json") + + +def _should_download_manifest_member(task: Task, dest_dir: str, file_path: str) -> bool: + """Return True when the given manifest entry should be downloaded.""" + if not file_path: + return False + + if task.type == "transcription" and not _should_extract_transcription_member( + file_path + ): + return False + + if not _is_safe_path(dest_dir, file_path): + log.warning(f"Ignored suspicious manifest file path: {file_path}") + return False + + return True + + +def _download_manifest_member( + url: str, + headers: HeadersDict, + file_path: str, + task: Task, +) -> requests.Response | None: + """Download a single manifest member file.""" + try: + response = requests.get(url, headers=headers, timeout=60, stream=True) + except requests.RequestException as exc: + log.error(f"Error downloading file {file_path} for task {task.id}: {exc}") + return None + + if response.status_code != 200: + log.error( + "Failed to download file %s for task %s: %s %s", + file_path, + task.id, + response.status_code, + response.text, + ) + return None + + return response + + +def _store_manifest_member( + response: requests.Response, + dest_dir: str, + file_path: str, + expected_sha256: str | None = None, +) -> str: + """Write a downloaded manifest member to disk and return destination path.""" + dest_path = os.path.normpath(os.path.join(dest_dir, file_path)) + parent = os.path.dirname(dest_path) + if parent: + os.makedirs(parent, exist_ok=True) + + if file_path.endswith("/"): + os.makedirs(dest_path, exist_ok=True) + return dest_path + + temp_path = _create_manifest_temp_path(parent or dest_dir) + try: + actual_sha256 = _stream_manifest_member_to_tempfile(response, temp_path) + _validate_manifest_member_checksum(file_path, expected_sha256, actual_sha256) + os.replace(temp_path, dest_path) + except Exception: + _remove_file_if_exists(temp_path) + raise + + return dest_path + + +def _create_manifest_temp_path(temp_dir: str) -> str: + """Create and return an empty temporary file path for atomic writes.""" + temp_file_descriptor, temp_path = tempfile.mkstemp( + prefix=".manifest_", + suffix=".part", + dir=temp_dir, + ) + os.close(temp_file_descriptor) + return temp_path + + +def _stream_manifest_member_to_tempfile(response: requests.Response, temp_path: str) -> str: + """Stream response content to temp_path and return computed SHA-256.""" + checksum = sha256() + with open(temp_path, "wb") as target: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + target.write(chunk) + checksum.update(chunk) + return checksum.hexdigest() + + +def _validate_manifest_member_checksum( + file_path: str, + expected_sha256: str | None, + actual_sha256: str, +) -> None: + """Raise on checksum mismatch when manifest provides an expected hash.""" + if expected_sha256 is None: + return + if actual_sha256 == expected_sha256: + return + raise ManifestMemberIntegrityError( + f"Checksum mismatch for {file_path}: expected {expected_sha256}" + ) + + +def _remove_file_if_exists(path: str) -> None: + """Delete file if present, ignoring absence and cleanup race conditions.""" + try: + os.remove(path) + except FileNotFoundError: + pass + except OSError: + pass + + +def _parse_manifest_member_entry( + task: Task, manifest_entry: object +) -> tuple[str | None, str | None]: + """Extract (file_path, optional_sha256) from one manifest entry.""" + if isinstance(manifest_entry, str): + return manifest_entry, None + + if not isinstance(manifest_entry, dict): + log.warning( + "Ignored manifest entry with unsupported format for task %s: %r", + task.id, + manifest_entry, + ) + return None, None + + file_path = manifest_entry.get("file_path") + if not isinstance(file_path, str) or not file_path: + alt_file_path = manifest_entry.get("path") + if isinstance(alt_file_path, str) and alt_file_path: + file_path = alt_file_path + else: + log.warning( + "Ignored manifest entry without file path for task %s: %r", + task.id, + manifest_entry, + ) + return None, None + + checksum_value = manifest_entry.get("sha256") + if checksum_value is None: + return file_path, None + + if not isinstance(checksum_value, str): + log.warning( + "Ignored non-string sha256 for file %s in task %s", + file_path, + task.id, + ) + return file_path, None + + normalized_checksum = checksum_value.strip().lower() + if len(normalized_checksum) != 64 or any( + char not in "0123456789abcdef" for char in normalized_checksum + ): + log.warning( + "Ignored invalid sha256 for file %s in task %s", + file_path, + task.id, + ) + return file_path, None + + return file_path, normalized_checksum + + +def _compute_manifest_retry_delay(attempt: int) -> float: + """Return delay in seconds using exponential backoff with full jitter.""" + exponential_delay = min( + MANIFEST_MEMBER_DOWNLOAD_BACKOFF_MAX_SECONDS, + MANIFEST_MEMBER_DOWNLOAD_BACKOFF_BASE_SECONDS * (2 ** (attempt - 1)), + ) + return random.uniform(0.0, exponential_delay) + + +def _download_and_store_manifest_member( + url: str, + headers: HeadersDict, + file_path: str, + task: Task, + dest_dir: str, + expected_sha256: str | None = None, + max_attempts: int = MANIFEST_MEMBER_DOWNLOAD_MAX_RETRIES, +) -> str | None: + """Download and store one manifest file with retry and integrity checks.""" + for attempt in range(1, max_attempts + 1): + response = _download_manifest_member(url, headers, file_path, task) + if response is None: + if attempt == max_attempts: + return None + retry_delay = _compute_manifest_retry_delay(attempt) + log.warning( + "Retrying file %s for task %s after failed request (%s/%s), next try in %.2fs", + file_path, + task.id, + attempt, + max_attempts, + retry_delay, + ) + time.sleep(retry_delay) + continue + + try: + return _store_manifest_member( + response, + dest_dir, + file_path, + expected_sha256=expected_sha256, + ) + except (requests.RequestException, ManifestMemberIntegrityError) as exc: + if attempt == max_attempts: + log.error( + "Failed to fully download file %s for task %s after %s attempts: %s", + file_path, + task.id, + max_attempts, + exc, + ) + return None + retry_delay = _compute_manifest_retry_delay(attempt) + log.warning( + "Retrying file %s for task %s after streamed transfer/integrity error (%s/%s), next try in %.2fs: %s", + file_path, + task.id, + attempt, + max_attempts, + retry_delay, + exc, + ) + time.sleep(retry_delay) + except OSError as exc: + log.error(f"Failed to write file {file_path} for task {task.id}: {exc}") + return None + finally: + response.close() + return None + + +def _save_manifest_files( + manifest: ResultManifest, + task: Task, + manager_url: str, + token: str, + dest_base: str | None = None, +) -> tuple[str | None, str]: + """Download files listed in a manifest JSON and store them in dest_dir. + + Args: + manifest: Manifest JSON with at least a "files" list. + task: Task instance used to name the destination directory. + manager_url: Runner manager base URL. + token: Runner manager token for Authorization header. + dest_base: Optional parent directory where to store. + + Returns: + (dest_dir, vtt_path) where vtt_path may be empty. + """ + files = manifest.get("files") + if not isinstance(files, list) or not files: + log.error(f"Manifest missing files for task {task.id}") + return None, "" + + if not task.task_id: + log.error(f"Missing task_id for task {task.id}") + return None, "" + + dest_dir = _get_destination_directory(task, dest_base) + dest_vtt_path = "" + headers = _build_file_headers(token) + + for manifest_entry in files: + file_path, expected_sha256 = _parse_manifest_member_entry(task, manifest_entry) + if file_path is None: + continue + + if not _should_download_manifest_member(task, dest_dir, file_path): + continue + + url = _build_result_file_url(manager_url, task.task_id, file_path) + dest_path = _download_and_store_manifest_member( + url, + headers, + file_path, + task, + dest_dir, + expected_sha256=expected_sha256, + ) + if dest_path is None: + return None, "" + + if task.type == "transcription" and dest_path.lower().endswith(".vtt"): + dest_vtt_path = dest_path + + log.info(f"Downloaded manifest files for task {task.id} into {dest_dir}") + return dest_dir, dest_vtt_path + + +def _create_video_from_studio_task(task: Task, extracted_dir: str | None = None) -> int: + """Create a Pod video from a studio task and relocate artifacts. + + Workflow: + - Validate that the task references a recording (`task.recording_id`). + - Expect `studio_base.mp4` under MEDIA_ROOT/tasks/ by default. + - Create a new Video via `save_basic_video(recording, src_file)`. + - Move the task extraction directory to MEDIA_ROOT/VIDEOS_DIR//. + - Return the created video's id. + + Args: + task: The `Task` instance (must contain `recording_id`). + extracted_dir: Optional source directory containing extracted studio files. + + Returns: + int: The created video id. + + Raises: + RuntimeError: If recording is missing or expected files/directories are not present. + """ + # Ensure the task is linked to a recording + if not getattr(task, "recording_id", None): + raise RuntimeError("Studio task missing recording_id") + + # Compute source file path from extracted_dir or default MEDIA_ROOT/tasks/. + src_dir = extracted_dir or os.path.join(_get_media_root(), "tasks", str(task.id)) + src_file = os.path.join(src_dir, "studio_base.mp4") + if not os.path.exists(src_file) or not os.path.isfile(src_file): + raise RuntimeError(f"Source studio file not found: {src_file}") + + # Load recording and create a new Pod video from the base file + try: + recording = Recording.objects.get(id=task.recording_id) + # Create a new Pod video + video = save_basic_video(recording, src_file) + except Recording.DoesNotExist: + raise RuntimeError(f"Recording not found: {task.recording_id}") + + # Move the rest of the task artifacts into the new video directory + _move_task_directory_to_video(task, video.id, src_dir=src_dir, recording=recording) + + return video.id + + +def _get_user_hashkey_from_recording(recording: Recording) -> str: + """Return the user's hashkey from a recording or raise a clear error.""" + try: + return str(recording.user.owner.hashkey) + except Exception as exc: + raise RuntimeError("Unable to resolve recording owner's hashkey") from exc + + +def _merge_or_move_directory(src_dir: str, dest_dir: str) -> None: + """Move src_dir to dest_dir; merge contents if destination already exists.""" + # Ensure parent of dest_dir exists + os.makedirs(os.path.dirname(dest_dir), exist_ok=True) + + # If destination exists, merge contents; else move the whole directory + if os.path.exists(dest_dir): + if not os.path.isdir(dest_dir): + raise RuntimeError( + f"Destination path exists and is not a directory: {dest_dir}" + ) + for entry in os.listdir(src_dir): + src_path = os.path.join(src_dir, entry) + target_path = os.path.join(dest_dir, entry) + # If target exists, remove it before move to avoid errors + if os.path.exists(target_path): + if os.path.isdir(target_path): + shutil.rmtree(target_path) + else: + os.remove(target_path) + shutil.move(src_path, dest_dir) + # Cleanup empty src_dir (best effort) + try: + os.rmdir(src_dir) + except OSError: + pass + else: + shutil.move(src_dir, dest_dir) + + +def _move_task_directory_to_video( + task: Task, + video_id: int, + src_dir: str | None = None, + recording: Recording | None = None, +) -> None: + """Move the task extraction directory into the final video directory. + + Source: MEDIA_ROOT/tasks/ by default. + Destination: MEDIA_ROOT/VIDEOS_DIR// + + Args: + task: The `Task` instance (must contain `recording_id`). + video_id: The destination video id used to build the target path. + src_dir: Optional explicit source directory for extracted studio files. + recording: Optional recording object to avoid loading it twice. + + Raises: + RuntimeError: When required data or paths are missing. + """ + if not getattr(task, "id", None): + raise RuntimeError("Task missing id") + + if not getattr(task, "recording_id", None): + raise RuntimeError("Task missing recording_id") + + source_dir = src_dir or os.path.join(_get_media_root(), "tasks", str(task.id)) + if not os.path.exists(source_dir) or not os.path.isdir(source_dir): + raise RuntimeError(f"Source directory not found: {source_dir}") + + if recording is None: + try: + recording = Recording.objects.get(id=task.recording_id) + except Recording.DoesNotExist: + raise RuntimeError(f"Recording not found: {task.recording_id}") + + user_hashkey = _get_user_hashkey_from_recording(recording) + dest_dir = os.path.join( + _get_media_root(), _get_videos_dir(), user_hashkey, str(video_id) + ) + + _merge_or_move_directory(source_dir, dest_dir) + + log.info(f"Moved task directory from {source_dir} to {dest_dir}") From 44095e509fab5cabe8ed1cf462fea63a4ce5c308 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 26 Feb 2026 04:57:59 +0000 Subject: [PATCH 09/15] Fixup. Format code with Black --- pod/recorder/admin.py | 12 +- pod/urls.py | 4 +- pod/video/rest_views.py | 9 +- pod/video/views.py | 134 ++++------------ pod/video_encode_transcript/Encoding_video.py | 149 ++++++++++-------- .../Encoding_video_model.py | 32 ++-- pod/video_encode_transcript/admin.py | 3 +- pod/video_encode_transcript/encode.py | 8 +- .../encoding_studio.py | 22 ++- .../commands/import_encode_video.py | 10 +- .../management/commands/process_tasks.py | 16 +- .../commands/test_encode_transcript.py | 4 +- pod/video_encode_transcript/models.py | 7 +- pod/video_encode_transcript/rest_views.py | 3 +- pod/video_encode_transcript/runner_manager.py | 3 +- .../runner_manager_utils.py | 8 +- .../tests/test_encode.py | 16 +- .../tests/test_remote_encode_transcode.py | 4 +- .../tests/test_studio_integration.py | 12 +- .../tests/test_views_helpers.py | 8 +- .../transcript_model.py | 9 +- pod/video_encode_transcript/views.py | 8 +- 22 files changed, 198 insertions(+), 283 deletions(-) diff --git a/pod/recorder/admin.py b/pod/recorder/admin.py index 864c7d2a2d..13c0ecf609 100644 --- a/pod/recorder/admin.py +++ b/pod/recorder/admin.py @@ -33,9 +33,7 @@ class RecordingAdmin(admin.ModelAdmin): def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter( - sites=Site.objects.get_current() - ) + kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) if (db_field.name) == "user": kwargs["queryset"] = User.objects.filter( owner__sites=Site.objects.get_current() @@ -107,9 +105,7 @@ def delete_source(self, request, queryset) -> None: def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter( - sites=Site.objects.get_current() - ) + kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_queryset(self, request): @@ -217,7 +213,5 @@ def get_queryset(self, request): def formfield_for_foreignkey(self, db_field, request, **kwargs): if (db_field.name) == "recorder": - kwargs["queryset"] = Recorder.objects.filter( - sites=Site.objects.get_current() - ) + kwargs["queryset"] = Recorder.objects.filter(sites=Site.objects.get_current()) return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/pod/urls.py b/pod/urls.py index 7019fc3276..3fcf8ce4d3 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -202,9 +202,7 @@ # IMPORT_VIDEO if USE_IMPORT_VIDEO: urlpatterns += [ - path( - "import_video/", include("pod.import_video.urls", namespace="import_video") - ), + path("import_video/", include("pod.import_video.urls", namespace="import_video")), ] if USE_DUPLICATE: diff --git a/pod/video/rest_views.py b/pod/video/rest_views.py index a5ba63fcd6..1e492b0daa 100644 --- a/pod/video/rest_views.py +++ b/pod/video/rest_views.py @@ -214,10 +214,11 @@ class VideoViewSet(viewsets.ModelViewSet): def user_videos(self, request): # Manage additional_owners filtering username = request.GET.get("username") - user_videos = self.filter_queryset(self.get_queryset()).filter( - Q(owner__username=username) - | Q(additional_owners__username=username) - ).distinct() + user_videos = ( + self.filter_queryset(self.get_queryset()) + .filter(Q(owner__username=username) | Q(additional_owners__username=username)) + .distinct() + ) if request.GET.get("encoded") and request.GET.get("encoded") == "true": user_videos = user_videos.exclude( pk__in=[vid.id for vid in user_videos if not vid.encoded] diff --git a/pod/video/views.py b/pod/video/views.py index b6f5787c73..48f28ff090 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -464,9 +464,7 @@ def channel_edit(request, slug): if request.user not in channel.owners.all() and not ( request.user.is_superuser or request.user.has_perm("video.change_channel") ): - messages.add_message( - request, messages.ERROR, _("You cannot edit this channel.") - ) + messages.add_message(request, messages.ERROR, _("You cannot edit this channel.")) raise PermissionDenied channel_form = ChannelForm( instance=channel, @@ -513,9 +511,7 @@ def theme_edit(request, slug): if request.user not in channel.owners.all() and not ( request.user.is_superuser or request.user.has_perm("video.change_theme") ): - messages.add_message( - request, messages.ERROR, _("You cannot edit this channel.") - ) + messages.add_message(request, messages.ERROR, _("You cannot edit this channel.")) raise PermissionDenied if is_ajax(request): @@ -626,9 +622,7 @@ def dashboard(request): videos_list = get_videos_for_owner(request) if USER_VIDEO_CATEGORY: - categories = Category.objects.prefetch_related("video").filter( - owner=request.user - ) + categories = Category.objects.prefetch_related("video").filter(owner=request.user) selected_categories = request.GET.getlist("categories") if selected_categories: @@ -1103,9 +1097,7 @@ def get_video_access(request, video, slug_private): # or is_password_protected ) if is_access_protected: - access_granted_for_private = ( - slug_private and slug_private == video.get_hashkey() - ) + access_granted_for_private = slug_private and slug_private == video.get_hashkey() access_granted_for_draft = request.user.is_authenticated and ( request.user == video.owner or request.user.is_superuser @@ -1188,9 +1180,7 @@ def video(request, slug, slug_c=None, slug_t=None, slug_private=None): raise PermissionDenied( _("You cannot access this playlist because it is private.") ) - return render_video( - request, id, slug_c, slug_t, slug_private, template_video, params - ) + return render_video(request, id, slug_c, slug_t, slug_private, template_video, params) def toggle_render_video_user_can_see_video( @@ -1211,8 +1201,7 @@ def toggle_render_video_user_can_see_video( slug_private == video.get_hashkey() or slug_private in [ - str(tok.token) - for tok in VideoAccessToken.objects.filter(video=video) + str(tok.token) for tok in VideoAccessToken.objects.filter(video=video) ] ) ) @@ -1399,9 +1388,7 @@ def video_edit(request, slug=None): video and request.user != video.owner and ( - not ( - request.user.is_superuser or request.user.has_perm("video.change_video") - ) + not (request.user.is_superuser or request.user.has_perm("video.change_video")) ) and (request.user not in video.additional_owners.all()) ): @@ -1542,9 +1529,7 @@ def video_is_deletable(request, video) -> bool: if request.user != video.owner and not ( request.user.is_superuser or request.user.has_perm("video.delete_video") ): - messages.add_message( - request, messages.ERROR, _("You cannot delete this media.") - ) + messages.add_message(request, messages.ERROR, _("You cannot delete this media.")) raise PermissionDenied if WEBTV_MODE: @@ -1576,9 +1561,7 @@ def video_edit_access_tokens(request: WSGIRequest, slug: str = None): video and request.user != video.owner and ( - not ( - request.user.is_superuser or request.user.has_perm("video.change_video") - ) + not (request.user.is_superuser or request.user.has_perm("video.change_video")) ) and (request.user not in video.additional_owners.all()) ): @@ -1625,9 +1608,7 @@ def delete_token(request, video: Video, token: VideoAccessToken): try: uuid.UUID(str(token)) VideoAccessToken.objects.get(video=video, token=token).delete() - messages.add_message( - request, messages.SUCCESS, _("The token has been deleted.") - ) + messages.add_message(request, messages.SUCCESS, _("The token has been deleted.")) except (ValueError, ObjectDoesNotExist): messages.add_message(request, messages.ERROR, _("Token not found.")) @@ -1638,9 +1619,7 @@ def update_token(request, video: Video, token: VideoAccessToken): Token = VideoAccessToken.objects.get(video=video, token=token) Token.name = request.POST.get("name") Token.save() - messages.add_message( - request, messages.SUCCESS, _("The token has been updated.") - ) + messages.add_message(request, messages.SUCCESS, _("The token has been updated.")) except (ValueError, ObjectDoesNotExist): messages.add_message(request, messages.ERROR, _("Token not found.")) @@ -1661,15 +1640,11 @@ def video_transcript(request, slug=None): video and request.user != video.owner and ( - not ( - request.user.is_superuser or request.user.has_perm("video.change_video") - ) + not (request.user.is_superuser or request.user.has_perm("video.change_video")) ) and (request.user not in video.additional_owners.all()) ): - messages.add_message( - request, messages.ERROR, _("You cannot manage this video.") - ) + messages.add_message(request, messages.ERROR, _("You cannot manage this video.")) raise PermissionDenied if not video.encoded or video.encoding_in_progress is True: @@ -1985,9 +1960,7 @@ def video_note_form_case(request, params): and idNote is not None and ( (request.method == "POST" and request.POST.get("action") == "form_com_edit") - or ( - request.method == "GET" and request.GET.get("action") == "form_com_edit" - ) + or (request.method == "GET" and request.GET.get("action") == "form_com_edit") ) ): can_edit_or_remove_note_or_com(request, com, "edit") @@ -2081,9 +2054,7 @@ def video_note_save(request, slug): q.update({"video": video.id}) form = AdvancedNotesForm(q) noteToEdit = note - elif request.method == "POST" and ( - request.POST.get("action").startswith("save_com") - ): + elif request.method == "POST" and (request.POST.get("action").startswith("save_com")): form = NoteCommentsForm(request.POST) comToEdit = { "save_com": com, @@ -2163,9 +2134,7 @@ def video_note_save_form_valid(request, video, params): com.status = request.POST.get("status") com.modified_on = timezone.now() com.save() - messages.add_message( - request, messages.INFO, _("The comment has been modified.") - ) + messages.add_message(request, messages.INFO, _("The comment has been modified.")) noteToDisplay, comToDisplay = note, get_com_tree(com) listNotesCom = get_adv_note_com_list(request, idNote) dictComments = get_com_coms_dict(request, listNotesCom) @@ -2189,9 +2158,7 @@ def video_note_save_form_valid(request, video, params): dictComments = get_com_coms_dict(request, listNotesCom) # Saving a note after an edit elif ( - idCom is None - and idNote is not None - and request.POST.get("action") == "save_note" + idCom is None and idNote is not None and request.POST.get("action") == "save_note" ): note.note = request.POST.get("note") note.status = request.POST.get("status") @@ -2200,9 +2167,7 @@ def video_note_save_form_valid(request, video, params): messages.add_message(request, messages.INFO, _("Your note has been modified.")) # Saving a new com for a note elif ( - idCom is None - and idNote is not None - and request.POST.get("action") == "save_com" + idCom is None and idNote is not None and request.POST.get("action") == "save_com" ): com = NoteComments.objects.create( user=request.user, @@ -2286,9 +2251,7 @@ def video_note_remove(request, slug): if idNote and request.POST.get("action") == "remove_note": can_edit_or_remove_note_or_com(request, note, "delete") note.delete() - messages.add_message( - request, messages.INFO, _("The note has been deleted.") - ) + messages.add_message(request, messages.INFO, _("The note has been deleted.")) listNotesCom, comToDisplay = None, None elif idNote and idCom and request.POST.get("action") == "remove_com": can_edit_or_remove_note_or_com(request, com, "delete") @@ -2402,11 +2365,8 @@ def rec_expl_coms(idNote, lComs) -> None: str(_("Content")), ] response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = ( - "attachment; \ - filename=%s_notes_and_comments.csv" - % slug - ) + response["Content-Disposition"] = "attachment; \ + filename=%s_notes_and_comments.csv" % slug df.to_csv( path_or_buf=response, sep="|", @@ -2613,9 +2573,7 @@ def get_all_views_count(v_id, date_filter=date.today()): all_views["year"] = count if count else 0 # view count since video was created - count = ViewCount.objects.filter(video_id=v_id).aggregate(Sum("count"))[ - "count__sum" - ] + count = ViewCount.objects.filter(video_id=v_id).aggregate(Sum("count"))["count__sum"] all_views["since_created"] = count if count else 0 # playlist addition in day @@ -2834,9 +2792,7 @@ def stats_view(request, slug=None, slug_t=None): ) min_date = ( - get_available_videos() - .aggregate(Min("date_added"))["date_added__min"] - .date() + get_available_videos().aggregate(Min("date_added"))["date_added__min"].date() ) data.append({"min_date": min_date}) @@ -3034,9 +2990,7 @@ def get_children_comment(request, comment_id, video_slug): parent_comment = ( Comment.objects.filter(video=v, id=comment_id) .annotate( - author_name=Concat( - "author__first_name", Value(" "), "author__last_name" - ) + author_name=Concat("author__first_name", Value(" "), "author__last_name") ) .annotate(nbr_child=Count("children", distinct=True)) .annotate( @@ -3090,9 +3044,7 @@ def get_comments(request, video_slug): .order_by("added") .annotate(nbr_vote=Count("vote", distinct=True)) .annotate( - author_name=Concat( - "author__first_name", Value(" "), "author__last_name" - ) + author_name=Concat("author__first_name", Value(" "), "author__last_name") ) .annotate(nbr_child=Count("children", distinct=True)) .annotate( @@ -3145,9 +3097,7 @@ def delete_comment(request, video_slug, comment_id): if in_maintenance(): return HttpResponseForbidden( - _( - "Sorry, you can’t delete a comment while the server is under maintenance." - ) + _("Sorry, you can’t delete a comment while the server is under maintenance.") ) if c.author == c_user or v.owner == c_user or c_user.is_superuser: @@ -3357,9 +3307,7 @@ def add_category(request): data_context["videos"] = videos if request.GET.get("page"): - return render( - request, "videos/category_modal_video_list.html", data_context - ) + return render(request, "videos/category_modal_video_list.html", data_context) data_context = { "modal_action": "add", @@ -3542,9 +3490,7 @@ class PodChunkedUploadView(ChunkedUploadView): def check_permissions(self, request): if not request.user.is_authenticated: return False - elif ( - RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False - ): + elif RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: return False pass @@ -3556,9 +3502,7 @@ class PodChunkedUploadCompleteView(ChunkedUploadCompleteView): def check_permissions(self, request): if not request.user.is_authenticated: return False - elif ( - RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False - ): + elif RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY and request.user.is_staff is False: return False pass @@ -3661,9 +3605,7 @@ def filter_owners(request): return auth_get_owners(search, limit, offset) except Exception as err: - return JsonResponse( - {"success": False, "detail": "Syntax error: {0}".format(err)} - ) + return JsonResponse({"success": False, "detail": "Syntax error: {0}".format(err)}) @login_required(redirect_field_name="referrer") @@ -3677,9 +3619,7 @@ def filter_videos(request, user_id): return video_get_videos(title, user_id, search, limit, offset) except Exception as err: - return JsonResponse( - {"success": False, "detail": "Syntax error: {0}".format(err)} - ) + return JsonResponse({"success": False, "detail": "Syntax error: {0}".format(err)}) def get_serialized_channels(request: WSGIRequest, channels: QueryDict) -> dict: @@ -3774,9 +3714,7 @@ def get_channels_for_specific_channel_tab(request: WSGIRequest) -> JsonResponse: return JsonResponse(response, safe=False) -def get_theme_list_for_specific_channel( - request: WSGIRequest, slug: str -) -> JsonResponse: +def get_theme_list_for_specific_channel(request: WSGIRequest, slug: str) -> JsonResponse: """ Get the themes for a specific channel. @@ -3794,9 +3732,7 @@ def get_theme_list_for_specific_channel( def available_filters(request): """API endpoint to return all available video filters based on user's videos.""" user_videos = get_videos_for_owner(request) - categories_qs = Category.objects.prefetch_related("video").filter( - owner=request.user - ) + categories_qs = Category.objects.prefetch_related("video").filter(owner=request.user) categories = list(categories_qs.values("id", "title")[:20]) types_qs = ( Type.objects.filter(video__in=user_videos) @@ -3865,9 +3801,7 @@ def available_filter_by_type(request, filter_name): uv, term, lim ), "tag": lambda req, uv, term, lim: get_filtered_tags_for_videos(uv, term, lim), - "owner": lambda req, uv, term, lim: get_filtered_owners_for_videos( - uv, term, lim - ), + "owner": lambda req, uv, term, lim: get_filtered_owners_for_videos(uv, term, lim), } try: diff --git a/pod/video_encode_transcript/Encoding_video.py b/pod/video_encode_transcript/Encoding_video.py index 0aa96fe949..d8e9bb776f 100644 --- a/pod/video_encode_transcript/Encoding_video.py +++ b/pod/video_encode_transcript/Encoding_video.py @@ -10,55 +10,85 @@ from webvtt import Caption, WebVTT if __name__ == "__main__": - from encoding_settings import (FFMPEG_AUDIO_BITRATE, FFMPEG_CMD, - FFMPEG_CREATE_OVERVIEW, - FFMPEG_CREATE_THUMBNAIL, FFMPEG_CRF, - FFMPEG_DRESSING_AUDIO, - FFMPEG_DRESSING_CONCAT, - FFMPEG_DRESSING_FILTER_COMPLEX, - FFMPEG_DRESSING_INPUT, - FFMPEG_DRESSING_OUTPUT, - FFMPEG_DRESSING_SCALE, - FFMPEG_DRESSING_SILENT, - FFMPEG_DRESSING_WATERMARK, - FFMPEG_EXTRACT_SUBTITLE, - FFMPEG_EXTRACT_THUMBNAIL, - FFMPEG_HLS_COMMON_PARAMS, - FFMPEG_HLS_ENCODE_PARAMS, FFMPEG_HLS_TIME, - FFMPEG_INPUT, FFMPEG_LEVEL, FFMPEG_LIBX, - FFMPEG_M4A_ENCODE, FFMPEG_MP3_ENCODE, - FFMPEG_MP4_ENCODE, FFMPEG_NB_THREADS, - FFMPEG_NB_THUMBNAIL, FFMPEG_PRESET, - FFMPEG_PROFILE, FFPROBE_CMD, - FFPROBE_GET_INFO) - from encoding_utils import (check_file, get_dressing_position_value, - get_info_from_video, get_list_rendition, - launch_cmd) + from encoding_settings import ( + FFMPEG_AUDIO_BITRATE, + FFMPEG_CMD, + FFMPEG_CREATE_OVERVIEW, + FFMPEG_CREATE_THUMBNAIL, + FFMPEG_CRF, + FFMPEG_DRESSING_AUDIO, + FFMPEG_DRESSING_CONCAT, + FFMPEG_DRESSING_FILTER_COMPLEX, + FFMPEG_DRESSING_INPUT, + FFMPEG_DRESSING_OUTPUT, + FFMPEG_DRESSING_SCALE, + FFMPEG_DRESSING_SILENT, + FFMPEG_DRESSING_WATERMARK, + FFMPEG_EXTRACT_SUBTITLE, + FFMPEG_EXTRACT_THUMBNAIL, + FFMPEG_HLS_COMMON_PARAMS, + FFMPEG_HLS_ENCODE_PARAMS, + FFMPEG_HLS_TIME, + FFMPEG_INPUT, + FFMPEG_LEVEL, + FFMPEG_LIBX, + FFMPEG_M4A_ENCODE, + FFMPEG_MP3_ENCODE, + FFMPEG_MP4_ENCODE, + FFMPEG_NB_THREADS, + FFMPEG_NB_THUMBNAIL, + FFMPEG_PRESET, + FFMPEG_PROFILE, + FFPROBE_CMD, + FFPROBE_GET_INFO, + ) + from encoding_utils import ( + check_file, + get_dressing_position_value, + get_info_from_video, + get_list_rendition, + launch_cmd, + ) else: - from .encoding_settings import (FFMPEG_AUDIO_BITRATE, FFMPEG_CMD, - FFMPEG_CREATE_OVERVIEW, - FFMPEG_CREATE_THUMBNAIL, FFMPEG_CRF, - FFMPEG_DRESSING_AUDIO, - FFMPEG_DRESSING_CONCAT, - FFMPEG_DRESSING_FILTER_COMPLEX, - FFMPEG_DRESSING_INPUT, - FFMPEG_DRESSING_OUTPUT, - FFMPEG_DRESSING_SCALE, - FFMPEG_DRESSING_SILENT, - FFMPEG_DRESSING_WATERMARK, - FFMPEG_EXTRACT_SUBTITLE, - FFMPEG_EXTRACT_THUMBNAIL, - FFMPEG_HLS_COMMON_PARAMS, - FFMPEG_HLS_ENCODE_PARAMS, FFMPEG_HLS_TIME, - FFMPEG_INPUT, FFMPEG_LEVEL, FFMPEG_LIBX, - FFMPEG_M4A_ENCODE, FFMPEG_MP3_ENCODE, - FFMPEG_MP4_ENCODE, FFMPEG_NB_THREADS, - FFMPEG_NB_THUMBNAIL, FFMPEG_PRESET, - FFMPEG_PROFILE, FFPROBE_CMD, - FFPROBE_GET_INFO) - from .encoding_utils import (check_file, get_dressing_position_value, - get_info_from_video, get_list_rendition, - launch_cmd) + from .encoding_settings import ( + FFMPEG_AUDIO_BITRATE, + FFMPEG_CMD, + FFMPEG_CREATE_OVERVIEW, + FFMPEG_CREATE_THUMBNAIL, + FFMPEG_CRF, + FFMPEG_DRESSING_AUDIO, + FFMPEG_DRESSING_CONCAT, + FFMPEG_DRESSING_FILTER_COMPLEX, + FFMPEG_DRESSING_INPUT, + FFMPEG_DRESSING_OUTPUT, + FFMPEG_DRESSING_SCALE, + FFMPEG_DRESSING_SILENT, + FFMPEG_DRESSING_WATERMARK, + FFMPEG_EXTRACT_SUBTITLE, + FFMPEG_EXTRACT_THUMBNAIL, + FFMPEG_HLS_COMMON_PARAMS, + FFMPEG_HLS_ENCODE_PARAMS, + FFMPEG_HLS_TIME, + FFMPEG_INPUT, + FFMPEG_LEVEL, + FFMPEG_LIBX, + FFMPEG_M4A_ENCODE, + FFMPEG_MP3_ENCODE, + FFMPEG_MP4_ENCODE, + FFMPEG_NB_THREADS, + FFMPEG_NB_THUMBNAIL, + FFMPEG_PRESET, + FFMPEG_PROFILE, + FFPROBE_CMD, + FFPROBE_GET_INFO, + ) + from .encoding_utils import ( + check_file, + get_dressing_position_value, + get_info_from_video, + get_list_rendition, + launch_cmd, + ) __author__ = "Nicolas CAN " __license__ = "LGPL v3" @@ -95,9 +125,7 @@ FFMPEG_MP3_ENCODE = getattr(settings, "FFMPEG_MP3_ENCODE", FFMPEG_MP3_ENCODE) FFMPEG_M4A_ENCODE = getattr(settings, "FFMPEG_M4A_ENCODE", FFMPEG_M4A_ENCODE) FFMPEG_NB_THREADS = getattr(settings, "FFMPEG_NB_THREADS", FFMPEG_NB_THREADS) - FFMPEG_AUDIO_BITRATE = getattr( - settings, "FFMPEG_AUDIO_BITRATE", FFMPEG_AUDIO_BITRATE - ) + FFMPEG_AUDIO_BITRATE = getattr(settings, "FFMPEG_AUDIO_BITRATE", FFMPEG_AUDIO_BITRATE) FFMPEG_EXTRACT_THUMBNAIL = getattr( settings, "FFMPEG_EXTRACT_THUMBNAIL", FFMPEG_EXTRACT_THUMBNAIL ) @@ -293,9 +321,7 @@ def add_stream(self, stream): language = "" if stream.get("tags"): language = stream.get("tags").get("language", "") - self.list_subtitle_track["%s" % stream.get("index")] = { - "language": language - } + self.list_subtitle_track["%s" % stream.get("index")] = {"language": language} def get_output_dir(self) -> str: dirname = os.path.dirname(self.video_file) @@ -465,9 +491,7 @@ def handle_dressing_credits(self) -> str: duration = self.json_dressing.get(duration_key) try: - duration = ( - int(duration) if duration and int(duration) > 0 else 1 - ) + duration = int(duration) if duration and int(duration) > 0 else 1 except (ValueError, TypeError): duration = 1 @@ -614,8 +638,7 @@ def add_dressing_credits( params = f"{params}[{name}][{audio_out}]" filters.append( - FFMPEG_DRESSING_SCALE - % {"number": str(order), "height": height, "name": name} + FFMPEG_DRESSING_SCALE % {"number": str(order), "height": height, "name": name} ) return filters, params, interval_silent @@ -673,9 +696,7 @@ def get_mp3_command(self) -> str: "input": self.video_file, "nb_threads": FFMPEG_NB_THREADS, } - output_file = os.path.join( - self.output_dir, "audio_%s.mp3" % FFMPEG_AUDIO_BITRATE - ) + output_file = os.path.join(self.output_dir, "audio_%s.mp3" % FFMPEG_AUDIO_BITRATE) mp3_command += FFMPEG_MP3_ENCODE % { # "audio_bitrate": AUDIO_BITRATE, "cut": self.get_subtime(self.cutting_start, self.cutting_stop), @@ -690,9 +711,7 @@ def get_m4a_command(self) -> str: "input": self.video_file, "nb_threads": FFMPEG_NB_THREADS, } - output_file = os.path.join( - self.output_dir, "audio_%s.m4a" % FFMPEG_AUDIO_BITRATE - ) + output_file = os.path.join(self.output_dir, "audio_%s.m4a" % FFMPEG_AUDIO_BITRATE) m4a_command += FFMPEG_M4A_ENCODE % { "cut": self.get_subtime(self.cutting_start, self.cutting_stop), "audio_bitrate": FFMPEG_AUDIO_BITRATE, diff --git a/pod/video_encode_transcript/Encoding_video_model.py b/pod/video_encode_transcript/Encoding_video_model.py index c49a6d9578..7e78549507 100644 --- a/pod/video_encode_transcript/Encoding_video_model.py +++ b/pod/video_encode_transcript/Encoding_video_model.py @@ -13,11 +13,21 @@ from pod.video.models import LANG_CHOICES, Video from .encoding_utils import check_file, launch_cmd -from .Encoding_video import (FFMPEG_CMD, FFMPEG_CREATE_THUMBNAIL, FFMPEG_INPUT, - FFMPEG_NB_THREADS, FFMPEG_NB_THUMBNAIL, - Encoding_video) -from .models import (EncodingAudio, EncodingLog, EncodingVideo, PlaylistVideo, - VideoRendition) +from .Encoding_video import ( + FFMPEG_CMD, + FFMPEG_CREATE_THUMBNAIL, + FFMPEG_INPUT, + FFMPEG_NB_THREADS, + FFMPEG_NB_THUMBNAIL, + Encoding_video, +) +from .models import ( + EncodingAudio, + EncodingLog, + EncodingVideo, + PlaylistVideo, + VideoRendition, +) DEBUG = getattr(settings, "DEBUG", True) logger = logging.getLogger(__name__) @@ -128,9 +138,7 @@ def store_json_list_mp3_m4a_files(self, info_video, video_to_encode) -> None: name="audio", video=video_to_encode, encoding_format=( - "audio/mp3" - if (encode_item == "list_mp3_files") - else "video/mp4" + "audio/mp3" if (encode_item == "list_mp3_files") else "video/mp4" ), # need to double check path source_file=self.get_true_path(mp3_files[audio_file]), @@ -141,9 +149,7 @@ def store_json_list_mp4_hls_files(self, info_video, video_to_encode) -> None: for video_file in mp4_files: if not check_file(mp4_files[video_file]): continue - rendition = VideoRendition.objects.get( - resolution__contains="x" + video_file - ) + rendition = VideoRendition.objects.get(resolution__contains="x" + video_file) encod_name = video_file + "p" encoding, created = EncodingVideo.objects.get_or_create( name=encod_name, @@ -157,9 +163,7 @@ def store_json_list_mp4_hls_files(self, info_video, video_to_encode) -> None: for video_file in hls_files: if not check_file(hls_files[video_file]): continue - rendition = VideoRendition.objects.get( - resolution__contains="x" + video_file - ) + rendition = VideoRendition.objects.get(resolution__contains="x" + video_file) encod_name = video_file + "p" encoding, created = PlaylistVideo.objects.get_or_create( name=encod_name, diff --git a/pod/video_encode_transcript/admin.py b/pod/video_encode_transcript/admin.py index d82df31129..00196d1292 100644 --- a/pod/video_encode_transcript/admin.py +++ b/pod/video_encode_transcript/admin.py @@ -458,8 +458,7 @@ def relaunch_selected_tasks(self, request, queryset): refresh_pending_task_ranks() self.message_user( request, - _("%(count)s task(s) relaunched immediately.") - % {"count": relaunched_count}, + _("%(count)s task(s) relaunched immediately.") % {"count": relaunched_count}, level=messages.SUCCESS, ) if skipped_count: diff --git a/pod/video_encode_transcript/encode.py b/pod/video_encode_transcript/encode.py index 7649da4b8c..6de01d4c54 100644 --- a/pod/video_encode_transcript/encode.py +++ b/pod/video_encode_transcript/encode.py @@ -48,9 +48,7 @@ if USE_REMOTE_ENCODING_TRANSCODING: from .encoding_tasks import start_encoding_task, start_studio_task -FFMPEG_DRESSING_INPUT = getattr( - settings, "FFMPEG_DRESSING_INPUT", FFMPEG_DRESSING_INPUT -) +FFMPEG_DRESSING_INPUT = getattr(settings, "FFMPEG_DRESSING_INPUT", FFMPEG_DRESSING_INPUT) USE_RUNNER_MANAGER = getattr(settings, "USE_RUNNER_MANAGER", False) @@ -94,9 +92,7 @@ def start_encode_studio( encode_studio_recording(recording_id) else: - log.info( - "Start encode studio, without runner manager, for id: %s" % recording_id - ) + log.info("Start encode studio, without runner manager, for id: %s" % recording_id) if threaded: if CELERY_TO_ENCODE: task_start_encode_studio.delay( diff --git a/pod/video_encode_transcript/encoding_studio.py b/pod/video_encode_transcript/encoding_studio.py index 8b64dd1b46..3ae01492a4 100644 --- a/pod/video_encode_transcript/encoding_studio.py +++ b/pod/video_encode_transcript/encoding_studio.py @@ -5,13 +5,23 @@ import time if __name__ == "__main__": - from encoding_settings import (FFMPEG_CMD, FFMPEG_CRF, FFMPEG_NB_THREADS, - FFMPEG_STUDIO_COMMAND, FFPROBE_CMD, - FFPROBE_GET_INFO) + from encoding_settings import ( + FFMPEG_CMD, + FFMPEG_CRF, + FFMPEG_NB_THREADS, + FFMPEG_STUDIO_COMMAND, + FFPROBE_CMD, + FFPROBE_GET_INFO, + ) else: - from .encoding_settings import (FFMPEG_CMD, FFMPEG_CRF, FFMPEG_NB_THREADS, - FFMPEG_STUDIO_COMMAND, FFPROBE_CMD, - FFPROBE_GET_INFO) + from .encoding_settings import ( + FFMPEG_CMD, + FFMPEG_CRF, + FFMPEG_NB_THREADS, + FFMPEG_STUDIO_COMMAND, + FFPROBE_CMD, + FFPROBE_GET_INFO, + ) try: from django.conf import settings diff --git a/pod/video_encode_transcript/management/commands/import_encode_video.py b/pod/video_encode_transcript/management/commands/import_encode_video.py index e781dd4b84..39eb4ef033 100644 --- a/pod/video_encode_transcript/management/commands/import_encode_video.py +++ b/pod/video_encode_transcript/management/commands/import_encode_video.py @@ -1,10 +1,8 @@ from django.core.management.base import BaseCommand from pod.video.models import Video -from pod.video_encode_transcript.encode import (end_of_encoding, - store_encoding_info) -from pod.video_encode_transcript.Encoding_video_model import \ - Encoding_video_model +from pod.video_encode_transcript.encode import end_of_encoding, store_encoding_info +from pod.video_encode_transcript.Encoding_video_model import Encoding_video_model class Command(BaseCommand): @@ -19,6 +17,4 @@ def handle(self, *args, **options) -> None: encoding_video = Encoding_video_model(video_id, vid.video.path) final_video = store_encoding_info(video_id, encoding_video) end_of_encoding(final_video) - self.stdout.write( - self.style.SUCCESS('Successfully import video "%s"' % video_id) - ) + self.stdout.write(self.style.SUCCESS('Successfully import video "%s"' % video_id)) diff --git a/pod/video_encode_transcript/management/commands/process_tasks.py b/pod/video_encode_transcript/management/commands/process_tasks.py index 90c80b3bac..c37f65cb74 100644 --- a/pod/video_encode_transcript/management/commands/process_tasks.py +++ b/pod/video_encode_transcript/management/commands/process_tasks.py @@ -346,9 +346,7 @@ def _process_studio_tasks( self.print_log( f"Processing studio task {task.id} for recording {recording.id}" ) - result = self._submit_studio_task( - recording, task, site, runner_managers - ) + result = self._submit_studio_task(recording, task, site, runner_managers) if result: success_count += 1 self.print_success( @@ -516,9 +514,7 @@ def handle(self, *args, **options) -> None: return self.print_log(f"Found {all_pending_tasks.count()} pending encoding task(s)") - self.print_log( - f"Found {all_pending_studio_tasks.count()} pending studio task(s)" - ) + self.print_log(f"Found {all_pending_studio_tasks.count()} pending studio task(s)") self.print_log( f"Found {all_pending_transcription_tasks.count()} pending transcription task(s)" ) @@ -530,9 +526,7 @@ def handle(self, *args, **options) -> None: all_pending_transcription_tasks, max_tasks ) - self.print_log( - f"Processing {len(pending_tasks)} task(s) after priority sorting" - ) + self.print_log(f"Processing {len(pending_tasks)} task(s) after priority sorting") # Get available runner managers for this site runner_managers = list( @@ -546,9 +540,7 @@ def handle(self, *args, **options) -> None: return # Process each pending task - success_count_encoding = self._process_tasks( - pending_tasks, site, runner_managers - ) + success_count_encoding = self._process_tasks(pending_tasks, site, runner_managers) success_count_studio = self._process_studio_tasks( pending_studio_tasks, site, runner_managers ) diff --git a/pod/video_encode_transcript/management/commands/test_encode_transcript.py b/pod/video_encode_transcript/management/commands/test_encode_transcript.py index 8912b4b292..2ac2005dd8 100644 --- a/pod/video_encode_transcript/management/commands/test_encode_transcript.py +++ b/pod/video_encode_transcript/management/commands/test_encode_transcript.py @@ -114,9 +114,7 @@ def test_result_encoding(self, video) -> None: video=video, encoding_format="application/x-mpegURL", ) - list_mp4 = EncodingVideo.objects.filter( - video=video, encoding_format="video/mp4" - ) + list_mp4 = EncodingVideo.objects.filter(video=video, encoding_format="video/mp4") if not len(list_mp2t) > 0: raise CommandError("no video/mp2t found") if not len(list_mp2t) + 1 == len(list_playlist_video): diff --git a/pod/video_encode_transcript/models.py b/pod/video_encode_transcript/models.py index e3694f34f6..c71d0103ef 100644 --- a/pod/video_encode_transcript/models.py +++ b/pod/video_encode_transcript/models.py @@ -136,8 +136,7 @@ def bitrate(self, field_value, field_name, name=None) -> None: if not vb.isdigit(): msg = "Error in %s: " % _(name) raise ValidationError( - "%s %s" - % (msg, VideoRendition._meta.get_field(field_name).help_text) + "%s %s" % (msg, VideoRendition._meta.get_field(field_name).help_text) ) def clean_bitrate(self) -> None: @@ -149,9 +148,7 @@ def clean_bitrate(self) -> None: def clean(self) -> None: """Clean the fields of the VideoRendition model.""" if self.resolution and "x" not in self.resolution: - raise ValidationError( - VideoRendition._meta.get_field("resolution").help_text - ) + raise ValidationError(VideoRendition._meta.get_field("resolution").help_text) else: res = self.resolution.replace("x", "") if not res.isdigit(): diff --git a/pod/video_encode_transcript/rest_views.py b/pod/video_encode_transcript/rest_views.py index ce596f844d..b456632b7a 100644 --- a/pod/video_encode_transcript/rest_views.py +++ b/pod/video_encode_transcript/rest_views.py @@ -162,8 +162,7 @@ def launch_encode_view(request): if ( video is not None and ( - not hasattr(video, "launch_encode") - or getattr(video, "launch_encode") is True + not hasattr(video, "launch_encode") or getattr(video, "launch_encode") is True ) and video.encoding_in_progress is False ): diff --git a/pod/video_encode_transcript/runner_manager.py b/pod/video_encode_transcript/runner_manager.py index 2921b0f906..f5bd25e756 100644 --- a/pod/video_encode_transcript/runner_manager.py +++ b/pod/video_encode_transcript/runner_manager.py @@ -502,8 +502,7 @@ def encode_video(video_id: int) -> None: except Exception as exc: log.error( - 'Error to encode video "%(id)s": %(exc)s' - % {"id": video_id, "exc": str(exc)} + 'Error to encode video "%(id)s": %(exc)s' % {"id": video_id, "exc": str(exc)} ) diff --git a/pod/video_encode_transcript/runner_manager_utils.py b/pod/video_encode_transcript/runner_manager_utils.py index 586eaf92dd..80c7e71d3f 100644 --- a/pod/video_encode_transcript/runner_manager_utils.py +++ b/pod/video_encode_transcript/runner_manager_utils.py @@ -263,9 +263,7 @@ def remote_video_part( ) video_to_encode.save() msg += "\n- existing overview:\n%s" % overview_vtt - add_encoding_log( - video_id, "attach existing overview: %s" % overview_vtt - ) + add_encoding_log(video_id, "attach existing overview: %s" % overview_vtt) except Exception as err: err_msg = f"Error attaching existing overview: {err}" add_encoding_log(video_id, err_msg) @@ -277,9 +275,7 @@ def remote_video_part( info_video["encode_thumbnail"], output_dir, video_to_encode ) else: - add_encoding_log( - video_id, "No thumbnail info in json; skip thumbnail attach" - ) + add_encoding_log(video_id, "No thumbnail info in json; skip thumbnail attach") elif info_video["has_stream_video"] or info_video.get("encode_video"): msg += "\n- has stream video but not info video " add_encoding_log(video_to_encode.id, msg) diff --git a/pod/video_encode_transcript/tests/test_encode.py b/pod/video_encode_transcript/tests/test_encode.py index 2976d0e1ef..56a1b449b5 100644 --- a/pod/video_encode_transcript/tests/test_encode.py +++ b/pod/video_encode_transcript/tests/test_encode.py @@ -14,8 +14,12 @@ from pod.video.models import Type, Video from pod.video_encode_transcript import encode -from pod.video_encode_transcript.models import (EncodingAudio, EncodingLog, - EncodingVideo, PlaylistVideo) +from pod.video_encode_transcript.models import ( + EncodingAudio, + EncodingLog, + EncodingVideo, + PlaylistVideo, +) VIDEO_TEST = getattr(settings, "VIDEO_TEST", "pod/main/static/video_test/pod.mp4") @@ -123,12 +127,8 @@ def test_result_encoding_audio(self): # video id=1 & audio id=2 audio = Video.objects.get(id=2) self.assertEqual("Audio1", audio.title) - list_m4a = EncodingAudio.objects.filter( - video=audio, encoding_format="video/mp4" - ) - list_mp3 = EncodingAudio.objects.filter( - video=audio, encoding_format="audio/mp3" - ) + list_m4a = EncodingAudio.objects.filter(video=audio, encoding_format="video/mp4") + list_mp3 = EncodingAudio.objects.filter(video=audio, encoding_format="audio/mp3") el = EncodingLog.objects.get(video=audio) self.assertTrue("NO VIDEO AND AUDIO FOUND" not in el.log) self.assertTrue(len(list_mp3) > 0) diff --git a/pod/video_encode_transcript/tests/test_remote_encode_transcode.py b/pod/video_encode_transcript/tests/test_remote_encode_transcode.py index 14191f08f1..4bd30245b4 100644 --- a/pod/video_encode_transcript/tests/test_remote_encode_transcode.py +++ b/pod/video_encode_transcript/tests/test_remote_encode_transcode.py @@ -228,9 +228,7 @@ def test_remote_encoding_dressing(self) -> None: currentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) simplefile = SimpleUploadedFile( name="testimage.jpg", - content=open( - os.path.join(currentdir, "tests", "testimage.jpg"), "rb" - ).read(), + content=open(os.path.join(currentdir, "tests", "testimage.jpg"), "rb").read(), content_type="image/jpeg", ) if FILEPICKER: diff --git a/pod/video_encode_transcript/tests/test_studio_integration.py b/pod/video_encode_transcript/tests/test_studio_integration.py index 3e301c2f3a..9f6fb72da0 100644 --- a/pod/video_encode_transcript/tests/test_studio_integration.py +++ b/pod/video_encode_transcript/tests/test_studio_integration.py @@ -33,9 +33,7 @@ def test_create_video_and_move_task_dir(self): with open(studio_base, "wb") as f: f.write(b"MP4DATA") # Additional file to ensure the whole folder is moved - with open( - os.path.join(src_dir, "extra.txt"), "w", encoding="utf-8" - ) as f: + with open(os.path.join(src_dir, "extra.txt"), "w", encoding="utf-8") as f: f.write("EXTRA") # Fake objects returned by patched calls @@ -55,9 +53,7 @@ def test_create_video_and_move_task_dir(self): return_value=fake_recording, ) as mock_get_recording: # Act - video_id = _create_video_from_studio_task( - task, extracted_dir=src_dir - ) + video_id = _create_video_from_studio_task(task, extracted_dir=src_dir) # Assert save_basic_video usage mock_save_video.assert_called_once() @@ -75,9 +71,7 @@ def test_create_video_and_move_task_dir(self): # Source folder removed self.assertFalse(os.path.exists(src_dir)) # Files moved - self.assertTrue( - os.path.isfile(os.path.join(dest_dir, "studio_base.mp4")) - ) + self.assertTrue(os.path.isfile(os.path.join(dest_dir, "studio_base.mp4"))) with open( os.path.join(dest_dir, "extra.txt"), "r", encoding="utf-8" ) as f: diff --git a/pod/video_encode_transcript/tests/test_views_helpers.py b/pod/video_encode_transcript/tests/test_views_helpers.py index b9012cf0a6..e6238a608a 100644 --- a/pod/video_encode_transcript/tests/test_views_helpers.py +++ b/pod/video_encode_transcript/tests/test_views_helpers.py @@ -95,9 +95,7 @@ def test_merge_or_move_directory_merge_when_dest_exists(self): # Files in dest self._touch(os.path.join(dest_dir, "same.txt"), "DEST") # should be replaced - self._touch( - os.path.join(dest_dir, "only_dest.txt"), "ONLY_DEST" - ) # should remain + self._touch(os.path.join(dest_dir, "only_dest.txt"), "ONLY_DEST") # should remain os.makedirs(os.path.join(dest_dir, "dirX"), exist_ok=True) self._touch( os.path.join(dest_dir, "dirX", "to_remove.txt"), "TO_REMOVE" @@ -116,9 +114,7 @@ def test_merge_or_move_directory_merge_when_dest_exists(self): self.assertEqual(f.read(), "ONLY_SRC") # added # dirX replaced by src version self.assertTrue(os.path.isdir(os.path.join(dest_dir, "dirX"))) - self.assertFalse( - os.path.exists(os.path.join(dest_dir, "dirX", "to_remove.txt")) - ) + self.assertFalse(os.path.exists(os.path.join(dest_dir, "dirX", "to_remove.txt"))) with open( os.path.join(dest_dir, "dirX", "in_src.txt"), "r", encoding="utf-8" ) as f: diff --git a/pod/video_encode_transcript/transcript_model.py b/pod/video_encode_transcript/transcript_model.py index 75c353c75c..278a173042 100644 --- a/pod/video_encode_transcript/transcript_model.py +++ b/pod/video_encode_transcript/transcript_model.py @@ -56,9 +56,7 @@ def get_model(lang): """Get model for Whisper or Vosk software to transcript audio.""" - transript_model = Model( - TRANSCRIPTION_MODEL_PARAM[TRANSCRIPTION_TYPE][lang]["model"] - ) + transript_model = Model(TRANSCRIPTION_MODEL_PARAM[TRANSCRIPTION_TYPE][lang]["model"]) return transript_model @@ -219,10 +217,7 @@ def words_to_vtt( # 0.58, 'duration': 7.34} text_caption.append(word["word"]) if not ( - ( - ((word[start_key]) - start_caption) - < TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH - ) + (((word[start_key]) - start_caption) < TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH) and ( next_word is not None and (blank_duration < TRANSCRIPTION_STT_SENTENCE_BLANK_SPLIT_TIME) diff --git a/pod/video_encode_transcript/views.py b/pod/video_encode_transcript/views.py index 048fa61ee1..562c7916d4 100644 --- a/pod/video_encode_transcript/views.py +++ b/pod/video_encode_transcript/views.py @@ -449,9 +449,7 @@ def _is_safe_path(dest_dir: str, member: str) -> bool: dest_path = os.path.normpath(os.path.join(dest_dir, member_path)) normalized_dest = os.path.normpath(dest_dir) # Ensure extraction is within destination directory - return ( - dest_path.startswith(normalized_dest + os.sep) or dest_path == normalized_dest - ) + return dest_path.startswith(normalized_dest + os.sep) or dest_path == normalized_dest def _should_extract_transcription_member(member: str) -> bool: @@ -544,7 +542,9 @@ def _create_manifest_temp_path(temp_dir: str) -> str: return temp_path -def _stream_manifest_member_to_tempfile(response: requests.Response, temp_path: str) -> str: +def _stream_manifest_member_to_tempfile( + response: requests.Response, temp_path: str +) -> str: """Stream response content to temp_path and return computed SHA-256.""" checksum = sha256() with open(temp_path, "wb") as target: From ed8189ee531f68888d973fb0cabf4928a3c5c9f0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 26 Feb 2026 04:58:51 +0000 Subject: [PATCH 10/15] Auto-update configuration files --- CONFIGURATION_FR.md | 710 ++++++++++++++++++++++---------------------- 1 file changed, 361 insertions(+), 349 deletions(-) diff --git a/CONFIGURATION_FR.md b/CONFIGURATION_FR.md index 6080ad2e12..d4918a34bb 100644 --- a/CONFIGURATION_FR.md +++ b/CONFIGURATION_FR.md @@ -15,42 +15,42 @@ Elle est compatible avec les versions 3.10 et 3.12 de Python.
    Voici les configurations des applications tierces utilisées par Esup-Pod.
    * `CAS` - > valeur par défaut : `1.5.3` + > default value: `1.5.3` >> Système d’authentification SSO_CAS
    >> [kstateome/django-cas](https://github.com/kstateome/django-cas)
    * `ModelTranslation` - > valeur par défaut : `0.19.11` + > default value: `0.19.11` >> L’application modeltranslation est utilisée pour traduire le contenu dynamique
    >> des modèles Django existants
    >> [django-modeltranslation.readthedocs.io](https://django-modeltranslation.readthedocs.io/en/latest/installation.html#configuration)
    * `captcha` - > valeur par défaut : `0.6.0` + > default value: `0.6.0` >> Gestion du captcha du formulaire de contact
    >> [django-simple-captcha.readthedocs.io](https://django-simple-captcha.readthedocs.io/en/latest/usage.html)
    * `chunked_upload` - > valeur par défaut : `2.0.0` + > default value: `2.0.0` >> Envoi de fichier par morceaux // voir pour mettre à jour si nécessaire
    >> [juliomalegria/django-chunked-upload](https://github.com/juliomalegria/django-chunked-upload)
    * `ckeditor` - > valeur par défaut : `6.3.0` + > default value: `6.3.0` >> ATTENTION. django-ckeditor integre la version gratuite de CKEditor 4.22.1,
    >> qui n'est plus prise en charge et qui présente des problèmes de sécurité non résolus,
    >> voir par exemple .
    * `django_select2` - > valeur par défaut : `latest` + > default value: `latest` >> Recherche et completion dans les formulaires
    >> [django-select2.readthedocs.io](https://django-select2.readthedocs.io/en/latest/)
    * `honeypot` - > valeur par défaut : `1.2.1` + > default value: `1.2.1` >> Utilisé pour le formulaire de contact de Pod -
    >> ajoute un champ caché pour diminuer le spam
    >> [jamesturk/django-honeypot](https://github.com/jamesturk/django-honeypot/)
    * `mozilla_django_oidc` - > valeur par défaut : `4.0.1` + > default value: `4.0.1` >> Système d’authentification OpenID Connect
    >> [mozilla-django-oidc.readthedocs.io](https://mozilla-django-oidc.readthedocs.io/en/stable/installation.html)
    * `pwa` - > valeur par défaut : `2.0.1` + > default value: `2.0.1` >> Mise en place du mode PWA grâce à l’application Django-pwa
    >> Voici la configuration par défaut pour Pod,
    >> vous pouvez surcharger chaque variable dans votre fichier de configuration.
    @@ -76,23 +76,23 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    >> >> Pour en savoir plus : [silviolleite/django-pwa](https://github.com/silviolleite/django-pwa)
    * `rest_framework` - > valeur par défaut : `3.15.2` + > default value: `3.15.2` >> mise en place de l’API rest pour l’application
    >> [django-rest-framework.org](https://www.django-rest-framework.org/)
    * `shibboleth` - > valeur par défaut : `latest` + > default value: `latest` >> Système d’authentification Shibboleth
    >> [Brown-University-Library/django-shibboleth-remoteuser](https://github.com/Brown-University-Library/django-shibboleth-remoteuser)
    * `sorl.thumbnail` - > valeur par défaut : `12.11.0` + > default value: `12.11.0` >> Utilisée pour la génération de miniature des images
    >> [sorl-thumbnail.readthedocs.io](https://sorl-thumbnail.readthedocs.io/en/latest/reference/settings.html)
    * `tagging` - > valeur par défaut : `0.5.0` + > default value: `0.5.0` >> Gestion des mots-clés associés à une vidéo // voir pour référencer une nouvelle application
    >> [django-tagging.readthedocs.io](https://django-tagging.readthedocs.io/en/develop/#settings)
    * `tagulous` - > valeur par défaut : `2.1.0` + > default value: `2.1.0` >> Gestion des mots-clés associés à un objet Django.
    >> [django-tagulous.readthedocs.io](https://django-tagulous.readthedocs.io)
    @@ -101,7 +101,7 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    ### Base de données * `DATABASES` - > valeur par défaut : + > default value: ```python { @@ -142,34 +142,34 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    ### Courriel * `CONTACT_US_EMAIL` - > valeur par défaut : `` + > default value: `` >> Liste des adresses destinataires des courriels de contact
    * `CUSTOM_CONTACT_US` - > valeur par défaut : `False` + > default value: `False` >> Si 'True', les e-mails de contacts seront adressés, selon le sujet,
    >> soit au propriétaire de la vidéo soit au(x) manager(s) des vidéos Pod.
    >> (voir `USER_CONTACT_EMAIL_CASE` et `USE_ESTABLISHMENT_FIELD`)
    * `DEFAULT_FROM_EMAIL` - > valeur par défaut : `noreply` + > default value: `noreply` >> Expediteur par défaut pour les envois de courriel (contact, encodage etc.)
    * `EMAIL_HOST` - > valeur par défaut : `smtp.univ.fr` + > default value: `smtp.univ.fr` >> nom du serveur smtp
    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#email-host)_
    * `EMAIL_PORT` - > valeur par défaut : `25` + > default value: `25` >> Port d’écoute du serveur SMTP.
    * `EMAIL_SUBJECT_PREFIX` - > valeur par défaut : `` + > default value: `` >> Préfixe par défaut pour l’objet des courriels.
    * `NOTIFY_SENDER` - > valeur par défaut : `True` + > default value: `True` >> En mode non authentifié, lors de l'utilisation du formulaire de contact, envoie une copie du message à l'adresse saisie dans le formulaire.
    * `SERVER_EMAIL` - > valeur par défaut : `noreply` + > default value: `noreply` >> Expediteur par défaut pour les envois automatique (erreur de code etc.)
    * `SUBJECT_CHOICES` - > valeur par défaut : `()` + > default value: `()` >> Choix de sujet pour les courriels envoyés depuis la plateforme
    >> >> ```python @@ -185,11 +185,11 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    >> ``` >> * `SUPPORT_EMAIL` - > valeur par défaut : `None` + > default value: `None` >> Liste de destinataire(s) pour les demandes d’assistance, si différent de `CONTACT_US_EMAIL`
    >> i.e.: `SUPPORT_EMAIL = ["assistance_pod@univ.fr"]`
    * `USER_CONTACT_EMAIL_CASE` - > valeur par défaut : `` + > default value: `` >> Une liste contenant les sujets de contact dont l’utilisateur
    >> sera seul destinataire plutôt que le(s) manager(s).
    >> Si la liste est vide, les mails de contact seront envoyés au(x) manager(s).
    @@ -197,7 +197,7 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    >> `info`, `contribute`, `request_password`,
    >> `inapropriate_content`, `bug`, `other`
    * `USE_ESTABLISHMENT_FIELD` - > valeur par défaut : `False` + > default value: `False` >> Si valeur vaut 'True', rajoute un attribut 'establishment'
    >> à l’utilisateur Pod, ce qui permet de gérer plus d’un établissement.
    >> Dans ce cas, les emails de contact par exemple seront envoyés
    @@ -211,140 +211,140 @@ Voici les configurations des applications tierces utilisées par Esup-Pod.
    ### Encodage * `FFMPEG_AUDIO_BITRATE` - > valeur par défaut : `192k` + > default value: `192k` >> * `FFMPEG_CMD` - > valeur par défaut : `ffmpeg` + > default value: `ffmpeg` >> * `FFMPEG_CREATE_THUMBNAIL` - > valeur par défaut : `-vf "fps=1/(%(duration)s/%(nb_thumbnail)s)" -vsync vfr "%(output)s_%%04d.png"` + > default value: `-vf "fps=1/(%(duration)s/%(nb_thumbnail)s)" -vsync vfr "%(output)s_%%04d.png"` >> * `FFMPEG_CRF` - > valeur par défaut : `20` + > default value: `20` >> * `FFMPEG_EXTRACT_SUBTITLE` - > valeur par défaut : `-map 0:%(index)s -f webvtt -y "%(output)s"` + > default value: `-map 0:%(index)s -f webvtt -y "%(output)s"` >> * `FFMPEG_EXTRACT_THUMBNAIL` - > valeur par défaut : `-map 0:%(index)s -an -c:v copy -y "%(output)s"` + > default value: `-map 0:%(index)s -an -c:v copy -y "%(output)s"` >> * `FFMPEG_HLS_COMMON_PARAMS` - > valeur par défaut : `-c:v %(libx)s -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -c:a aac -ar 48000 -max_muxing_queue_size 4000` + > default value: `-c:v %(libx)s -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -c:a aac -ar 48000 -max_muxing_queue_size 4000` >> * `FFMPEG_HLS_ENCODE_PARAMS` - > valeur par défaut : `-vf "scale=-2:%(height)s" -maxrate %(maxrate)s -bufsize %(bufsize)s -b:a:0 %(ba)s -hls_playlist_type vod -hls_time %(hls_time)s -hls_flags single_file -master_pl_name "livestream%(height)s.m3u8" -y "%(output)s"` + > default value: `-vf "scale=-2:%(height)s" -maxrate %(maxrate)s -bufsize %(bufsize)s -b:a:0 %(ba)s -hls_playlist_type vod -hls_time %(hls_time)s -hls_flags single_file -master_pl_name "livestream%(height)s.m3u8" -y "%(output)s"` >> * `FFMPEG_HLS_TIME` - > valeur par défaut : `2` + > default value: `2` >> * `FFMPEG_INPUT` - > valeur par défaut : `-hide_banner -threads %(nb_threads)s -i "%(input)s"` + > default value: `-hide_banner -threads %(nb_threads)s -i "%(input)s"` >> * `FFMPEG_LEVEL` - > valeur par défaut : `3` + > default value: `3` >> * `FFMPEG_LIBX` - > valeur par défaut : `libx264` + > default value: `libx264` >> * `FFMPEG_M4A_ENCODE` - > valeur par défaut : `-vn -c:a aac -b:a %(audio_bitrate)s "%(output)s"` + > default value: `-vn -c:a aac -b:a %(audio_bitrate)s "%(output)s"` >> * `FFMPEG_MP3_ENCODE` - > valeur par défaut : `-vn -codec:a libmp3lame -qscale:a 2 -y "%(output)s"` + > default value: `-vn -codec:a libmp3lame -qscale:a 2 -y "%(output)s"` >> * `FFMPEG_MP4_ENCODE` - > valeur par défaut : `-map 0:v:0 %(map_audio)s -c:v %(libx)s -vf "scale=-2:%(height)s" -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -maxrate %(maxrate)s -bufsize %(bufsize)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -c:a aac -ar 48000 -b:a %(ba)s -movflags faststart -y -vsync 0 "%(output)s"` + > default value: `-map 0:v:0 %(map_audio)s -c:v %(libx)s -vf "scale=-2:%(height)s" -preset %(preset)s -profile:v %(profile)s -pix_fmt yuv420p -level %(level)s -crf %(crf)s -maxrate %(maxrate)s -bufsize %(bufsize)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -c:a aac -ar 48000 -b:a %(ba)s -movflags faststart -y -vsync 0 "%(output)s"` >> * `FFMPEG_NB_THREADS` - > valeur par défaut : `0` + > default value: `0` >> * `FFMPEG_NB_THUMBNAIL` - > valeur par défaut : `3` + > default value: `3` >> * `FFMPEG_PRESET` - > valeur par défaut : `slow` + > default value: `slow` >> * `FFMPEG_PROFILE` - > valeur par défaut : `high` + > default value: `high` >> * `FFMPEG_STUDIO_COMMAND` - > valeur par défaut : `-hide_banner -threads %(nb_threads)s %(input)s %(subtime)s -c:a aac -ar 48000 -c:v h264 -profile:v high -pix_fmt yuv420p -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -deinterlace` + > default value: `-hide_banner -threads %(nb_threads)s %(input)s %(subtime)s -c:a aac -ar 48000 -c:v h264 -profile:v high -pix_fmt yuv420p -crf %(crf)s -sc_threshold 0 -force_key_frames "expr:gte(t,n_forced*1)" -max_muxing_queue_size 4000 -deinterlace` >> * `FFPROBE_CMD` - > valeur par défaut : `ffprobe` + > default value: `ffprobe` >> * `FFPROBE_GET_INFO` - > valeur par défaut : `%(ffprobe)s -v quiet -show_format -show_streams %(select_streams)s -print_format json -i %(source)s` + > default value: `%(ffprobe)s -v quiet -show_format -show_streams %(select_streams)s -print_format json -i %(source)s` >> * `FFMPEG_DRESSING_OUTPUT` - > valeur par défaut : ` -c:v libx264 -y -vsync 0 "%(output)s" ` + > default value: ` -c:v libx264 -y -vsync 0 "%(output)s" ` >> Spécifie les paramètres d'encodage de sortie FFmpeg pour générer le fichier vidéo temporaire d'habillage, utilisant le codec H.264 avec écrasement forcé et synchronisation de la sortie vidéo.
    * `FFMPEG_DRESSING_INPUT` - > valeur par défaut : ` -i "%(input)s" ` + > default value: ` -i "%(input)s" ` >> Définit le fichier d'entrée pour le traitement FFmpeg de la vidéo intermédiaire d'habillage.
    * `FFMPEG_DRESSING_FILTER_COMPLEX` - > valeur par défaut : ` -filter_complex "%(filter)s" ` + > default value: ` -filter_complex "%(filter)s" ` >> Applique des chaînes de filtres complexes à la vidéo intermédiaire d'habillage avec FFmpeg.
    * `FFMPEG_DRESSING_WATERMARK` - > valeur par défaut : ` [1]format=rgba,colorchannelmixer=aa=%(opacity)s[logo]; [logo][vid]scale2ref=oh*mdar:ih*0.1[logo][video2]; [video2][logo]%(position)s%(name_out)s ` + > default value: ` [1]format=rgba,colorchannelmixer=aa=%(opacity)s[logo]; [logo][vid]scale2ref=oh*mdar:ih*0.1[logo][video2]; [video2][logo]%(position)s%(name_out)s ` >> Ajoute un filigrane à la vidéo intermédiaire d'habillage avec une opacité et une position personnalisables.
    * `FFMPEG_DRESSING_SCALE` - > valeur par défaut : `[%(number)s]scale=w='if(gt(a,16/9),16/9*%(height)s,-2)':h='if(gt(a,16/9),-2,%(height)s)',pad=ceil(16/9*%(height)s):%(height)s:(ow-iw)/2:(oh-ih)/2[%(name)s]` + > default value: `[%(number)s]scale=w='if(gt(a,16/9),16/9*%(height)s,-2)':h='if(gt(a,16/9),-2,%(height)s)',pad=ceil(16/9*%(height)s):%(height)s:(ow-iw)/2:(oh-ih)/2[%(name)s]` >> Redimensionne la vidéo intermédiaire d'habillage pour maintenir un ratio d'aspect 16:9 avec ajout de bordures si nécessaire.
    * `FFMPEG_DRESSING_CONCAT` - > valeur par défaut : `%(params)sconcat=n=%(number)s:v=1:a=1:unsafe=1[v][a]` + > default value: `%(params)sconcat=n=%(number)s:v=1:a=1:unsafe=1[v][a]` >> Concatène plusieurs flux vidéo et audio en une seule sortie de vidéo temporaire d'habillage.
    * `FFMPEG_DRESSING_SILENT` - > valeur par défaut : ` -f lavfi -t %(duration)s -i anullsrc=r=44100:cl=stereo` + > default value: ` -f lavfi -t %(duration)s -i anullsrc=r=44100:cl=stereo` >> Génère un audio silencieux d'une durée spécifiée pour la vidéo temporaire d'habillage.
    * `FFMPEG_DRESSING_AUDIO` - > valeur par défaut : `[%(param_in)s]anull[%(param_out)s]` + > default value: `[%(param_in)s]anull[%(param_out)s]` >> Traite l'audio sans modifications pour l'inclure dans la vidéo temporaire d'habillage.
    ### Gestion des fichiers * `FILES_DIR` - > valeur par défaut : `files` + > default value: `files` >> Nom du répertoire racine où les fichiers "complémentaires"
    >> (hors vidéos etc.) sont téléversés. Notament utilisé par PODFILE
    >> À modifier principalement pour indiquer dans LOCATION
    >> votre serveur de cache si elle n’est pas sur la même machine que votre POD.
    * `FILE_UPLOAD_TEMP_DIR` - > valeur par défaut : `/var/tmp` + > default value: `/var/tmp` >> Le répertoire dans lequel stocker temporairement les données
    >> (typiquement pour les fichiers plus grands que `FILE_UPLOAD_MAX_MEMORY_SIZE`)
    >> lors des téléversements de fichiers.
    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#file-upload-temp-dir)_
    * `MEDIA_ROOT` - > valeur par défaut : `/pod/media` + > default value: `/pod/media` >> Chemin absolu du système de fichiers pointant vers le répertoire qui contiendra
    >> les fichiers téléversés par les utilisateurs.

    >> Attention, ce répertoire doit exister.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#std:setting-MEDIA_ROOT)_
    * `MEDIA_URL` - > valeur par défaut : `/media/` + > default value: `/media/` >> prefix url utilisé pour accéder aux fichiers du répertoire media
    * `STATICFILES_STORAGE` - > valeur par défaut : `` + > default value: `` >> Indique à django de compresser automatiquement les fichiers css/js
    >> les plus gros lors du collectstatic pour optimiser les tailles de requetes.

    >> À combiner avec un réglage webserver (`gzip_static on;` sur nginx)

    >> _ref : [whs/django-static-compress](https://github.com/whs/django-static-compress)
    * `STATIC_ROOT` - > valeur par défaut : `/pod/static` + > default value: `/pod/static` >> Le chemin absolu vers le répertoire dans lequel collectstatic rassemble
    >> les fichiers statiques en vue du déploiement.
    >> Ce chemin sera précisé dans le fichier de configurtation du vhost nginx.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#std:setting-STATIC_ROOT)_
    * `STATIC_URL` - > valeur par défaut : `/static/` + > default value: `/static/` >> prefix url utilisé pour accèder aux fichiers static
    * `USE_PODFILE` - > valeur par défaut : `False` + > default value: `False` >> Utiliser l’application de gestion de fichier fourni avec le projet.
    >> Si False, chaque fichier envoyé ne pourra être utilisé qu’une seule fois.
    * `VIDEOS_DIR` - > valeur par défaut : `videos` + > default value: `videos` >> Répertoire par défaut pour le téléversement des vidéos.
    ### Langue @@ -354,16 +354,16 @@ Vous pouvez tout à fait rajouter des langues comme vous le souhaitez.
    Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    * `LANGUAGES` - > valeur par défaut : `(('fr', 'Français'), ('en', 'English')))` + > default value: `(('fr', 'Français'), ('en', 'English')))` >> Langue disponible et traduite
    * `LANGUAGE_CODE` - > valeur par défaut : `fr` + > default value: `fr` >> Langue par défaut si non détectée
    ### Divers * `ADMINS` - > valeur par défaut : `[("Name", "adminmail@univ.fr"),]` + > default value: `[("Name", "adminmail@univ.fr"),]` >> Une liste de toutes les personnes qui reçoivent les notifications d’erreurs dans le code.

    >> Lorsque DEBUG=False et qu’une vue lève une exception,
    >> Django envoie un courriel à ces personnes contenant les informations complètes de l’exception.

    @@ -374,16 +374,16 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> d’encodage ou de flux RSS si la variable `CONTACT_US_EMAIL` n’est pas renseignée.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#admins)_
    * `ALLOWED_HOSTS` - > valeur par défaut : `['pod.localhost']` + > default value: `['pod.localhost']` >> Une liste de chaînes représentant des noms de domaine/d’hôte que ce site Django peut servir.

    >> C’est une mesure de sécurité pour empêcher les attaques d’en-tête Host HTTP,
    >> qui sont possibles même avec bien des configurations de serveur Web apparemment sécurisées.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#allowed-hosts)_
    * `BASE_DIR` - > valeur par défaut : `os.path.dirname(os.path.dirname(os.path.abspath(__file__)))` + > default value: `os.path.dirname(os.path.dirname(os.path.abspath(__file__)))` >> répertoire de base
    * `CACHES` - > valeur par défaut : `{}` + > default value: `{}` >> >> ```python >> CACHES = { @@ -410,28 +410,28 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> ``` >> * `CSRF_COOKIE_SECURE` - > valeur par défaut : `not DEBUG` + > default value: `not DEBUG` >> Ces 3 variables servent à sécuriser la plateforme en passant
    >> l’ensemble des requetes en https.
    >> Idem pour les cookies de session et de cross-sites qui seront également sécurisés

    >> Il faut les passer à False en cas d’usage du runserver (phase de développement / debugage)

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#secure-ssl-redirect)_
    * `DEBUG` - > valeur par défaut : `True` + > default value: `True` >> Une valeur booléenne qui active ou désactive le mode de débogage.

    >> Ne déployez jamais de site en production avec le réglage DEBUG activé.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#debug)_
    * `USE_DEBUG_TOOLBAR` - > valeur par défaut : `True` + > default value: `True` >> Une valeur booléenne qui active ou désactive l’outil de débogage.

    >> Ne déployez jamais de site en production avec le réglage USE_DEBUG_TOOLBAR activé.

    >> _ref : [django-debug-toolbar.readthedocs.io](https://django-debug-toolbar.readthedocs.io/en/latest/)_
    * `LOGIN_URL` - > valeur par défaut : `/authentication_login/` + > default value: `/authentication_login/` >> url de redirection pour l’authentification de l’utilisateur
    >> voir : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#login-url)
    * `MANAGERS` - > valeur par défaut : `[]` + > default value: `[]` >> Dans Pod, les "managers" sont destinataires des courriels de fin d’encodage
    >> (et ainsi des vidéos déposées sur la plateforme).

    >> Le premier manager renseigné est également contact des flus RSS.

    @@ -439,49 +439,49 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> si la variable `CONTACT_US_EMAIL` n’est pas renseignée.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#managers)_
    * `PROXY_HOST` - > valeur par défaut : `` + > default value: `` >> Utilisation du proxy - host
    * `PROXY_PORT` - > valeur par défaut : `` + > default value: `` >> Utilisation du proxy - port
    * `SECRET_KEY` - > valeur par défaut : `A_CHANGER` + > default value: `A_CHANGER` >> La clé secrète d’une installation Django.

    >> Elle est utilisée dans le contexte de la signature cryptographique,
    >> et doit être définie à une valeur unique et non prédictible.

    >> Vous pouvez utiliser ce site pour en générer une : [djecrety.ir](https://djecrety.ir/)

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#secret-key)_
    * `SECURE_SSL_REDIRECT` - > valeur par défaut : `False` + > default value: `False` >> À moins que votre site ne doive être disponible sur des connexions SSL et non SSL,
    >> vous souhaiterez probablement définir ce paramètre sur True ou configurer un
    >> load balancer ou reverse-proxy pour rediriger toutes les connexions vers HTTPS.
    * `SESSION_COOKIE_AGE` - > valeur par défaut : `14400` + > default value: `14400` >> L’âge des cookies de sessions, en secondes (4h par défaut).
    * `SESSION_COOKIE_SAMESITE` - > valeur par défaut : `Lax` + > default value: `Lax` >> Cette option empêche le cookie d’être envoyé dans les requêtes inter-sites,
    >> ce qui prévient les attaques CSRF et rend impossible
    >> certaines méthodes de vol du cookie de session.
    >> Voir [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#std-setting-SESSION_COOKIE_SAMESITE)
    * `SESSION_COOKIE_SECURE` - > valeur par défaut : `not DEBUG` + > default value: `not DEBUG` >> * `SESSION_EXPIRE_AT_BROWSER_CLOSE` - > valeur par défaut : `True` + > default value: `True` >> Indique s’il faut que la session expire lorsque l’utilisateur ferme son navigateur.
    * `SITE_ID` - > valeur par défaut : `1` + > default value: `1` >> L’identifiant (nombre entier) du site actuel.
    >> Peut être utilisé pour mettre en place une instance multi-tenant
    >> et ainsi gérer dans une même base de données du contenu pour plusieurs sites.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#site-id)_
    * `TEST_SETTINGS` - > valeur par défaut : `False` + > default value: `False` >> Permet de vérifier si la configuration de la plateforme est en mode test.
    * `THIRD_PARTY_APPS` - > valeur par défaut : `[]` + > default value: `[]` >> Liste des applications tierces accessibles.
    >> >> ```python @@ -489,7 +489,7 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> ``` >> * `TIME_ZONE` - > valeur par défaut : `UTC` + > default value: `UTC` >> Une chaîne représentant le fuseau horaire pour cette installation.

    >> _ref : [docs.djangoproject.com](https://docs.djangoproject.com/fr/4.2/ref/settings/#std:setting-TIME_ZONE)_
    >> Liste des adresses destinataires des courriels de contact
    @@ -497,7 +497,7 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    ### Obsolescence * `ACCOMMODATION_YEARS` - > valeur par défaut : `{}` + > default value: `{}` >> Durée d’obsolescence personnalisée par Affiliation
    >> >> ```python @@ -507,13 +507,13 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> ``` >> * `ARCHIVE_OWNER_USERNAME` - > valeur par défaut : `"archive"` + > default value: `"archive"` >> Nom de l’utilisateur pour l’archivage des vidéos.
    * `ARCHIVE_HOW_MANY_DAYS` - > valeur par défaut : `365` + > default value: `365` >> Délai avant qu'une vidéo archivée ne soit déplacée vers archive_ROOT.
    * `POD_ARCHIVE_AFFILIATION` - > valeur par défaut : `[]` + > default value: `[]` >> Affiliations pour lesquelles on souhaite archiver la vidéo plutôt que de la supprimer.
    >> Si l’affiliation du propriétaire est dans cette variable,
    >> alors les vidéos sont affectées à un utilisateur précis
    @@ -538,7 +538,7 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> ``` >> * `WARN_DEADLINES` - > valeur par défaut : `[60, 30, 7]` + > default value: `[60, 30, 7]` >> Liste de jours de délais avant l’obsolescence de la vidéo.
    >> À chaque délai, le propriétaire reçoit un mail d’avertissement
    >> pour éventuellement changer la date d’obsolescence de sa vidéo.
    @@ -546,86 +546,86 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    ### Modèle * `COOKIE_LEARN_MORE` - > valeur par défaut : `` + > default value: `` >> Ce paramètre permet d’afficher un lien "En savoir plus"
    >> sur la boite de dialogue d’information sur l’usage des cookies dans Pod.
    >> On peut préciser un lien vers les mentions légales ou page DPO.
    * `DARKMODE_ENABLED` - > valeur par défaut : `True` + > default value: `True` >> Permet aux utilisateurs d’activer un mode sombre.
    * `DYSLEXIAMODE_ENABLED` - > valeur par défaut : `True` + > default value: `True` >> Permet d’utiliser une police de caractères plus adaptée
    >> aux personnes atteintes de dyslexie.
    * `HIDE_CHANNEL_TAB` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de cacher l’onglet chaine dans la barre de menu du haut.
    * `HIDE_CURSUS` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas afficher les cursus dans la colonne de droite.
    * `HIDE_DISCIPLINES` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas afficher les disciplines dans la colonne de droite.
    * `HIDE_LANGUAGE_SELECTOR` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de cacher le sélecteur de langue dans le menu du haut.
    * `HIDE_SHARE` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas afficher les liens de partage
    >> sur les réseaux sociaux dans la colonne de droite.
    * `HIDE_TAGS` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas afficher le nuage de mots clés dans la colonne de droite.
    * `HIDE_TYPES` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas afficher la liste des types dans la colonne de droite.
    * `HIDE_TYPES_TAB` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de cacher l’entrée 'type' dans le menu de navigation.
    * `HIDE_USERNAME` - > valeur par défaut : `False` + > default value: `False` >> Voir description dans authentification
    >> Si valeur vaut 'True', le username de l’utilisateur ne sera pas visible et
    >> si la valeur vaut 'False' le username sera affiché aux utilisateurs authentifiés.
    >> (pour respecter le RGPD)
    * `HIDE_USER_FILTER` - > valeur par défaut : `False` + > default value: `False` >> Si 'True', le filtre des vidéos par utilisateur ne sera plus visible
    >> si 'False' le filtre ne sera visible qu’aux personnes authentifiées.
    >> (pour respecter le RGPD)
    * `HIDE_USER_TAB` - > valeur par défaut : `False` + > default value: `False` >> Si valeur vaut 'True', l’onglet Utilisateur ne sera pas visible
    >> et si la valeur vaut 'False' l’onglet Utilisateur ne sera visible
    >> qu’aux personnes authentifiées.
    >> (pour respecter le RGPD)
    * `HOMEPAGE_NB_VIDEOS` - > valeur par défaut : `12` + > default value: `12` >> Nombre de vidéos à afficher sur la page d’accueil.
    * `HOMEPAGE_SHOWS_PASSWORDED` - > valeur par défaut : `False` + > default value: `False` >> Afficher les vidéos dont l’accès est protégé par mot de passe sur la page d’accueil.
    * `HOMEPAGE_SHOWS_RESTRICTED` - > valeur par défaut : `False` + > default value: `False` >> Afficher les vidéos dont l’accès est protégé par authentification sur la page d’accueil.
    * `MENUBAR_HIDE_INACTIVE_OWNERS` - > valeur par défaut : `True` + > default value: `True` >> Les utilisateurs inactifs ne sont plus affichés dans la barre de menu utilisateur.
    * `MENUBAR_SHOW_STAFF_OWNERS_ONLY` - > valeur par défaut : `False` + > default value: `False` >> Les utilisateurs non staff ne sont plus affichés dans la barre de menu utilisateur.
    * `SHIB_NAME` - > valeur par défaut : `Identify Federation` + > default value: `Identify Federation` >> Nom de la fédération d’identité utilisée
    >> Affiché sur le bouton de connexion si l’authentification Shibboleth est utilisée.
    * `SHOW_EVENTS_ON_HOMEPAGE` - > valeur par défaut : `False` + > default value: `False` >> Si True, affiche les prochains évènements sur la page d’accueil.
    * `SHOW_ONLY_PARENT_THEMES` - > valeur par défaut : `False` + > default value: `False` >> Si True, affiche uniquement les thèmes de premier niveau dans l’onglet 'Chaîne'.
    * `TEMPLATE_VISIBLE_SETTINGS` - > valeur par défaut : `{}` + > default value: `{}` >> >> ```python >> TEMPLATE_VISIBLE_SETTINGS = { @@ -698,10 +698,10 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    ### Transcodage * `TRANSCRIPTION_AUDIO_SPLIT_TIME` - > valeur par défaut : `600` + > default value: `600` >> Découpage de l’audio pour la transcription.
    * `TRANSCRIPTION_MODEL_PARAM` - > valeur par défaut : `` + > default value: `` >> Paramétrage des modèles pour la transcription
    >> Voir la documentation à cette adresse :
    >> [esupportail.github.io](https://esupportail.github.io/Esup-Pod/4.x/Installation/optional/auto-transcription-install_fr)
    @@ -730,26 +730,26 @@ Il faudra pour cela créer un fichier de langue et traduire chaque entrée.
    >> ``` >> * `TRANSCRIPTION_NORMALIZE` - > valeur par défaut : `False` + > default value: `False` >> Activation de la normalisation de l’audio avant sa transcription.
    * `TRANSCRIPTION_NORMALIZE_TARGET_LEVEL` - > valeur par défaut : `-16.0` + > default value: `-16.0` >> Niveau de normalisation de l’audio avant sa transcription.
    * `TRANSCRIPTION_STT_SENTENCE_BLANK_SPLIT_TIME` - > valeur par défaut : `0.5` + > default value: `0.5` >> Temps maximum en secondes des blancs entre chaque mot
    >> pour le decoupage des sous-titres avec l’outil STT.
    * `TRANSCRIPTION_STT_SENTENCE_MAX_LENGTH` - > valeur par défaut : `2` + > default value: `2` >> Temps en secondes maximum pour une phrase lors de la transcription avec l’outil STT.
    * `TRANSCRIPTION_TYPE` - > valeur par défaut : `WHISPER` + > default value: `WHISPER` >> Choix de l’outil pour la transcription : `VOSK`ou `WHISPER`.
    * `TRANSCRIPT_VIDEO` - > valeur par défaut : `start_transcript` + > default value: `start_transcript` >> Fonction appelée pour lancer la transcription des vidéos.
    * `USE_TRANSCRIPTION` - > valeur par défaut : `False` + > default value: `False` >> Activation de la transcription.
    ## Configuration des applications Esup_Pod @@ -760,137 +760,137 @@ Application AI Enhancement pour pouvoir utiliser les améliorations des vidéos Mettre `USE_AI_ENHANCEMENT` à True pour activer cette application.
    * `AI_ENHANCEMENT_API_URL` - > valeur par défaut : `` + > default value: `` >> L’URL de l’API pour l’IA d’amélioration des vidéos.
    >> Exemple : ''
    >> Lien du projet :
    * `AI_ENHANCEMENT_API_VERSION` - > valeur par défaut : `` + > default value: `` >> La version de l’API pour l’IA d’amélioration des vidéos.
    * `AI_ENHANCEMENT_CGU_URL` - > valeur par défaut : `` + > default value: `` >> L’URL des conditions générales d’utilisation de l’API pour l’IA d’amélioration des vidéos.
    >> Exemple : ''
    >> Lien du projet :
    * `AI_ENHANCEMENT_CLIENT_ID` - > valeur par défaut : `mocked_id` + > default value: `mocked_id` >> L’ID du client de l’IA d’amélioration des vidéos.
    >> Exemple : 'v1'
    * `AI_ENHANCEMENT_CLIENT_SECRET` - > valeur par défaut : `mocked_secret` + > default value: `mocked_secret` >> Le mot de passe secret du client de l’IA d’amélioration des vidéos.
    * `AI_ENHANCEMENT_FIELDS_HELP_TEXT` - > valeur par défaut : `` + > default value: `` >> Ensemble des textes d’aide affichés avec le formulaire d'amélioration d'une vidéo avec l'IA d'Aristote.
    * `USE_AI_ENHANCEMENT` - > valeur par défaut : `False` + > default value: `False` >> Activation des améliorations de l'intelligence artificielle. Permet aux utilisateurs de l'utiliser.
    * `AI_ENHANCEMENT_PROXY_URL` - > valeur par défaut : `` + > default value: `` >> L’URL du serveur proxy pour les requêtes venant d'Aristote.
    >> Exemple : ''
    ### Configuration de l’application authentification * `AFFILIATION` - > valeur par défaut : `` + > default value: `` >> Valeurs possibles pour l’affiliation du compte.
    * `AFFILIATION_EVENT` - > valeur par défaut : `` + > default value: `` >> Groupes ou affiliations des personnes autorisées à créer un évènement.
    * `AFFILIATION_STAFF` - > valeur par défaut : `` + > default value: `` >> Les personnes ayant pour affiliation les valeurs
    >> renseignées dans cette variable ont automatiquement
    >> la valeur staff de leur compte à True.
    * `ALLOWED_SUPERUSER_IPS` - > valeur par défaut : `[]` + > default value: `[]` >> Liste d’IP et/ou de plages depuis lesquelles le statut 'superuser'
    >> est autorisé.
    >> Laissez vide pour autoriser toutes les sources.
    * `AUTH_CAS_USER_SEARCH` - > valeur par défaut : `user` + > default value: `user` >> Variable utilisée pour trouver les informations de l’individu
    >> connecté dans le fichier renvoyé par le CAS lors de l’authentification.
    * `AUTH_LDAP_BIND_DN` - > valeur par défaut : `` + > default value: `` >> Identifiant (DN) du compte pour se connecter au serveur LDAP.
    * `AUTH_LDAP_BIND_PASSWORD` - > valeur par défaut : `` + > default value: `` >> Mot de passe du compte pour se connecter au serveur LDAP.
    * `AUTH_LDAP_USER_SEARCH` - > valeur par défaut : `` + > default value: `` >> Filtre LDAP permettant la recherche de l’individu dans le serveur LDAP.
    * `AUTH_TYPE` - > valeur par défaut : `` + > default value: `` >> Type d’authentification possible sur votre instance.
    >> Choix : local, CAS, OIDC, Shibboleth
    * `CAS_ADMIN_AUTH` - > valeur par défaut : `False` + > default value: `False` >> Permet d’activer l’authentification CAS pour la partie admin
    >> Voir : [pypi.org/project/django-cas-sso](https://pypi.org/project/django-cas-sso/)
    * `CAS_FORCE_LOWERCASE_USERNAME` - > valeur par défaut : `False` + > default value: `False` >> Forcer le passage en minuscule du nom d’utilisateur CAS
    >> (permet de prévenir des doubles créations de comptes dans certains cas).
    >> OBSOLÈTE à partir de Pod 4.0. Utilisez `CAS_FORCE_CHANGE_USERNAME_CASE`
    * `CAS_FORCE_CHANGE_USERNAME_CASE` - > valeur par défaut : `False` + > default value: `False` >> Forcer la casse (minuscules ou majuscules) du nom d’utilisateur CAS
    >> (permet de prévenir des doubles créations de comptes dans certains cas).
    >> Valeurs possibles : `lower`, `upper`, `False`.
    * `CAS_GATEWAY` - > valeur par défaut : `False` + > default value: `False` >> Si True, authentifie automatiquement l’individu
    >> si déjà authentifié sur le serveur CAS
    >> OBSOLÈTE à partir de Pod 4.0
    * `CAS_LOGOUT_COMPLETELY` - > valeur par défaut : `True` + > default value: `True` >> Voir [kstateome/django-cas](https://github.com/kstateome/django-cas)
    * `CAS_SERVER_URL` - > valeur par défaut : `sso_cas` + > default value: `sso_cas` >> Url du serveur CAS de l’établissement. Format `http://url_cas`
    * `CAS_MAP_AFFILIATIONS` - > valeur par défaut : `False` + > default value: `False` >> Si True, des `groupes` d’utilisateurs sont créés automatiquement
    >> à partir des affiliations CAS des individus qui se connectent sur la plateforme
    >> et l’individu qui se connecte est ajouté automatiquement à ces groupes.
    * `CREATE_GROUP_FROM_AFFILIATION` - > valeur par défaut : `False` + > default value: `False` >> Si True, des `groupes d’accès` sont créés automatiquement
    >> à partir des affiliations des individus qui se connectent sur la plateforme
    >> et l’individu qui se connecte est ajouté automatiquement à ces groupes.
    * `CREATE_GROUP_FROM_GROUPS` - > valeur par défaut : `False` + > default value: `False` >> Si True, des groupes sont créés automatiquement
    >> à partir des groupes (attribut groups à memberOf)
    >> des individus qui se connectent sur la plateforme
    >> et l’individu qui se connecte est ajouté automatiquement à ces groupes
    * `DEFAULT_AFFILIATION` - > valeur par défaut : `` + > default value: `` >> Affiliation par défaut d’un utilisateur authentifié par OIDC.
    >> Ce contenu sera comparé à la liste AFFILIATION_STAFF
    >> pour déterminer si l’utilisateur doit être admin Django
    * `ESTABLISHMENTS` - > valeur par défaut : `` + > default value: `` >> [TODO] À compléter
    * `GROUP_STAFF` - > valeur par défaut : `AFFILIATION_STAFF` + > default value: `AFFILIATION_STAFF` >> utilisé dans populatedCasbackend
    * `HIDE_LOCAL_LOGIN` - > valeur par défaut : `False` + > default value: `False` >> Si True, masque l’authentification locale
    * `HIDE_USERNAME` - > valeur par défaut : `False` + > default value: `False` >> Si valeur vaut `True`, le username de l’utilisateur
    >> ne sera pas visible sur la plate-forme Pod
    >> et si la valeur vaut `False` le username sera affiché aux utilisateurs authentifiés.
    >> (pour respecter le RGPD)
    * `LDAP` - > valeur par défaut : `` + > default value: `` >> Interroge le serveur LDAP pour renseigner les champs.
    * `LDAP_SERVER` - > valeur par défaut : `` + > default value: `` >> Information de connection au serveur LDAP.
    >> Le champ url peut contenir une ou plusieurs url
    >> pour ajouter des hôtes de référence, exemple :
    @@ -899,94 +899,94 @@ Mettre `USE_AI_ENHANCEMENT` à True pour activer cette application.
    >> Si plusieurs :
    >> `{'url': ("ldap.univ.fr'',"ldap2.univ.fr"), 'port': 389, 'use_ssl': False}`
    * `OIDC_CLAIM_FAMILY_NAME` - > valeur par défaut : `family_name` + > default value: `family_name` >> * `OIDC_CLAIM_PREFERRED_USERNAME` - > valeur par défaut : `preferred_username` + > default value: `preferred_username` >> Noms des Claim permettant de récupérer
    >> l’attribut login mais dépendant de l’attribut du client dans l’IDP.
    * `OIDC_CLAIM_GIVEN_NAME` - > valeur par défaut : `given_name` + > default value: `given_name` >> Noms des Claim permettant de récupérer les attributs nom, prénom, email
    * `OIDC_DEFAULT_ACCESS_GROUP_CODE_NAMES` - > valeur par défaut : `[]` + > default value: `[]` >> Groupes d’accès attribués par défaut à un nouvel utilisateur authentifié par OIDC
    * `OIDC_DEFAULT_AFFILIATION` - > valeur par défaut : `` + > default value: `` >> Affiliation par défaut d’un utilisateur authentifié par OIDC.
    >> Ce contenu sera comparé à la liste AFFILIATION_STAFF
    >> pour déterminer si l’utilisateur doit être admin Django.
    * `OIDC_NAME` - > valeur par défaut : `` + > default value: `` >> Nom du Service Provider OIDC
    * `OIDC_OP_AUTHORIZATION_ENDPOINT` - > valeur par défaut : `https` + > default value: `https` >> * `OIDC_OP_JWKS_ENDPOINT` - > valeur par défaut : `https` + > default value: `https` >> Différents paramètres pour OIDC
    >> tant que `mozilla_django_oidc` n’accepte pas le mécanisme de discovery
    >> _ref : [mozilla/mozilla-django-oidc](https://github.com/mozilla/mozilla-django-oidc/pull/309)_
    * `OIDC_OP_TOKEN_ENDPOINT` - > valeur par défaut : `https` + > default value: `https` >> * `OIDC_OP_USER_ENDPOINT` - > valeur par défaut : `https` + > default value: `https` >> * `OIDC_RP_CLIENT_ID` - > valeur par défaut : `os.environ` + > default value: `os.environ` >> * `OIDC_RP_CLIENT_SECRET` - > valeur par défaut : `os.environ` + > default value: `os.environ` >> `CLIENT_ID` et `CLIENT_SECRET` de OIDC sont plutôt à positionner
    >> à travers des variables d’environnement.
    * `OIDC_RP_SIGN_ALGO` - > valeur par défaut : `` + > default value: `` >> * `POPULATE_USER` - > valeur par défaut : `None` + > default value: `None` >> Si utilisation de la connection CAS, renseigne les champs du compte
    >> de la personne depuis une source externe.
    >> Valeurs possibles :
    >> * None (pas de renseignement),
    >> * CAS (renseigne les champs depuis les informations renvoyées par le CAS),
    * `REMOTE_USER_HEADER` - > valeur par défaut : `REMOTE_USER` + > default value: `REMOTE_USER` >> Nom de l’attribut dans les headers qui sert à identifier
    >> l’utilisateur connecté avec Shibboleth.
    * `SHIBBOLETH_ATTRIBUTE_MAP` - > valeur par défaut : `` + > default value: `` >> Mapping des attributs entre Shibboleth et la classe utilisateur
    * `SHIBBOLETH_STAFF_ALLOWED_DOMAINS` - > valeur par défaut : `` + > default value: `` >> Permettre à l’utilisateur d’un domaine d’être membre du personnel.
    >> Si vide, tous les domaines seront autorisés.
    * `SHIB_LOGOUT_URL` - > valeur par défaut : `` + > default value: `` >> URL de déconnexion à votre instance Shibboleth
    * `SHIB_NAME` - > valeur par défaut : `` + > default value: `` >> Nom de la fédération d’identité utilisée.
    * `SHIB_URL` - > valeur par défaut : `` + > default value: `` >> URL de connexion à votre instance Shibboleth.
    * `USER_CAS_MAPPING_ATTRIBUTES` - > valeur par défaut : `` + > default value: `` >> Liste de correspondance entre les champs d’un compte de Pod
    >> et les champs renvoyés par le CAS.
    >> OBSOLÈTE. Utilisez désormais `CAS_RENAME_ATTRIBUTES`.
    * `USER_LDAP_MAPPING_ATTRIBUTES` - > valeur par défaut : `` + > default value: `` >> Liste de correspondance entre les champs d’un compte de Pod
    >> et les champs renvoyés par le LDAP.
    * `USE_CAS` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’authentification CAS en plus de l’authentification locale.
    * `USE_OIDC` - > valeur par défaut : `False` + > default value: `False` >> Mettre à True pour utiliser l’authentification OpenID Connect.
    * `USE_SHIB` - > valeur par défaut : `False` + > default value: `False` >> Mettre à True pour utiliser l’authentification Shibboleth.
    ### Configuration de l’application chapter @@ -995,41 +995,41 @@ Mettre `USE_AI_ENHANCEMENT` à True pour activer cette application.
    ### Configuration de l’application completion * `ACTIVE_MODEL_ENRICH` - > valeur par défaut : `False` + > default value: `False` >> Définissez à True pour activer la case à cocher dans l’édition des sous-titres.
    * `ALL_LANG_CHOICES` - > valeur par défaut : `` + > default value: `` >> liste toutes les langues pour l’ajout de fichier de sous-titre
    >> voir le fichier `pod/main/lang_settings.py`.
    * `DEFAULT_LANG_TRACK` - > valeur par défaut : `fr` + > default value: `fr` >> langue par défaut pour l’ajout de piste à une vidéo.
    * `KIND_CHOICES` - > valeur par défaut : `` + > default value: `` >> Liste de types de piste possibles pour une vidéo (sous-titre, légende etc.)
    * `LANG_CHOICES` - > valeur par défaut : `` + > default value: `` >> Liste des langues proposées lors de l’ajout des vidéos.
    >> Affichés en dessous d’une vidéo, les choix sont aussi utilisés pour affiner la recherche.
    * `LINK_SUPERPOSITION` - > valeur par défaut : `False` + > default value: `False` >> Si valeur vaut 'True', les URLs contenues dans le texte de superposition
    >> seront transformées, à la lecture de la vidéo, en liens cliquables.
    * `MODEL_COMPILE_DIR` - > valeur par défaut : `/path/of/project/Esup-Pod/compile-model` + > default value: `/path/of/project/Esup-Pod/compile-model` >> Paramétrage des chemins du modèle pour la compilation
    >> Pour télécharger les modèles : [alphacephei.com/vosk](https://alphacephei.com/vosk/lm#update-process)
    >> Ajouter le modèle dans les sous-dossier de la langue correspondante
    >> Exemple pour le français : `/path/of/project/Esup-Pod/compile-model/fr/`
    * `PREF_LANG_CHOICES` - > valeur par défaut : `` + > default value: `` >> liste des langues à afficher en premier dans la liste des toutes les langues
    >> voir le fichier `pod/main/lang_settings.py`
    * `ROLE_CHOICES` - > valeur par défaut : `` + > default value: `` >> Liste de rôles possibles pour un contributeur.
    * `USE_ENRICH_READY` - > valeur par défaut : `False` + > default value: `False` >> voir `ACTIVE_MODEL_ENRICH`
    ### Configuration de l’application Cut @@ -1038,7 +1038,7 @@ Application Cut permettant de découper des vidéos.
    Mettre `USE_CUT` à True pour activer cette application.
    * `USE_CUT` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application Cut
    ### Configuration de l’application dressing @@ -1047,7 +1047,7 @@ Application Dressing pour customiser une vidéo avec un filigrane et des crédit Mettre `USE_DRESSING` à True pour activer cette application.
    * `USE_DRESSING` - > valeur par défaut : `False` + > default value: `False` >> Activation des habillages.
    >> Permet aux utilisateurs de customiser une vidéo avec un filigrane et des crédits.
    @@ -1057,7 +1057,7 @@ Application Duplicate pour créer une copie du formulaire d’une vidéo existan Mettre `USE_DUPLICATE` à True pour activer cette application.
    * `USE_DUPLICATE` - > valeur par défaut : `False` + > default value: `False` >> Activation de duplicate.
    >> Permet aux utilisateurs de dupliquer une vidéo
    @@ -1067,7 +1067,7 @@ Application Liens permettant d'ajouter des liens à la vidéo.
    Mettre `USE_HYPERLINKS` à True pour activer cette application.
    * `USE_HYPERLINKS` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application Liens
    ### Configuration de l’application enrichment @@ -1079,10 +1079,10 @@ Application Intervenant permettant d'ajouter des intervenants à la vidéo.
    Mettre `USE_SPEAKER` à True pour activer cette application.
    * `USE_SPEAKER` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application Intervenant
    * `REQUIRED_SPEAKER_FIRSTNAME` - > valeur par défaut : `True` + > default value: `True` >> Prénom obligatoire dans le formulaire d'ajout intervenant
    ### Configuration de l’application d’import vidéo @@ -1091,88 +1091,88 @@ Application Import_video permettant d’importer des vidéos externes dans Pod.< Mettre `USE_IMPORT_VIDEO` à True pour activer cette application.
    * `MAX_UPLOAD_SIZE_ON_IMPORT` - > valeur par défaut : `4` + > default value: `4` >> Taille maximum en Go des fichiers vidéos qui peuvent être importés sur la plateforme
    >> via l’application import_video (0 = pas de taille maximum).
    * `RESTRICT_EDIT_IMPORT_VIDEO_ACCESS_TO_STAFF_ONLY` - > valeur par défaut : `True` + > default value: `True` >> Seuls les utilisateurs "staff" pourront importer des vidéos
    * `USE_IMPORT_VIDEO` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application d’import des vidéos
    * `USE_IMPORT_VIDEO_BBB_RECORDER` - > valeur par défaut : `False` + > default value: `False` >> Utilisation du plugin bbb-recorder pour le module import-vidéo;
    >> utile pour convertir une présentation BigBlueButton en fichier vidéo.
    * `IMPORT_VIDEO_BBB_RECORDER_PLUGIN` - > valeur par défaut : `/home/pod/bbb-recorder/` + > default value: `/home/pod/bbb-recorder/` >> Répertoire du plugin bbb-recorder (voir la documentation [jibon57/bbb-recorder](https://github.com/jibon57/bbb-recorder)).
    >> bbb-recorder doit être installé dans ce répertoire, sur tous les serveurs d’encodage.
    >> bbb-recorder crée un répertoire Downloads, au même niveau, qui nécessite de l’espace disque.
    * `IMPORT_VIDEO_BBB_RECORDER_PATH` - > valeur par défaut : `/data/bbb-recorder/media/` + > default value: `/data/bbb-recorder/media/` >> Répertoire qui contiendra les fichiers vidéo générés par bbb-recorder.
    ### Configuration de l’application live * `AFFILIATION_EVENT` - > valeur par défaut : `['faculty', 'employee', 'staff']` + > default value: `['faculty', 'employee', 'staff']` >> Groupes ou affiliations des personnes autorisées à créer un évènement.
    * `BROADCASTER_PILOTING_SOFTWARE` - > valeur par défaut : `[]` + > default value: `[]` >> Types de logiciel de serveur de streaming utilisés.
    >> Actuellement disponible Wowza et SMP.
    >> Il faut préciser cette valeur pour l’activer `['Wowza', 'SMP']`
    >> Si vous utilisez une autre logiciel,
    >> il faut développer une interface dans `pod/live/pilotingInterface.py`
    * `DEFAULT_EVENT_PATH` - > valeur par défaut : `` + > default value: `` >> Chemin racine du répertoire où sont déposés temporairement
    >> les enregistrements des évènements éffectués depuis POD
    >> pour convertion en ressource vidéo (VOD)
    * `DEFAULT_EVENT_THUMBNAIL` - > valeur par défaut : `/img/default-event.svg` + > default value: `/img/default-event.svg` >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter l’évènement.
    >> Cette image doit se situer dans le répertoire `static`.
    * `DEFAULT_EVENT_TYPE_ID` - > valeur par défaut : `1` + > default value: `1` >> Type par défaut affecté à un évènement direct
    >> (en général, le type ayant pour identifiant '1' est 'Other')
    * `DEFAULT_THUMBNAIL` - > valeur par défaut : `img/default.svg` + > default value: `img/default.svg` >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la vidéo.
    >> Cette image doit se situer dans le répertoire static.
    * `EMAIL_ON_EVENT_SCHEDULING` - > valeur par défaut : `True` + > default value: `True` >> Si True, un courriel est envoyé aux managers et à l’auteur
    >> (si DEBUG est à False) à la création/modification d’un event.
    * `EVENT_ACTIVE_AUTO_START` - > valeur par défaut : `False` + > default value: `False` >> Permet de lancer automatiquement l’enregistrement sur l’interface utilisée
    >> (wowza, ) sur le broadcaster et spécifié par `BROADCASTER_PILOTING_SOFTWARE`.
    * `EVENT_CHECK_MAX_ATTEMPT` - > valeur par défaut : `10` + > default value: `10` >> Nombre de tentatives maximum pour vérifier la présence / taille d’un fichier sur le filesystem
    * `EVENT_GROUP_ADMIN` - > valeur par défaut : `event admin` + > default value: `event admin` >> Permet de préciser le nom du groupe dans lequel les utilisateurs
    >> peuvent planifier un évènement sur plusieurs jours.
    * `HEARTBEAT_DELAY` - > valeur par défaut : `45` + > default value: `45` >> Temps (en secondes) entre deux envois d’un signal au serveur,
    >> pour signaler la présence sur un live.
    >> Peut être augmenté en cas de perte de performance,
    >> mais au détriment de la qualité du comptage des valeurs.
    * `LIVE_CELERY_TRANSCRIPTION` - > valeur par défaut : `False` + > default value: `False` >> >> Activer la transcription déportée sur une machine distante.
    * `LIVE_TRANSCRIPTIONS_FOLDER` - > valeur par défaut : `` + > default value: `` >> >> Dossier contenat les fichiers de sous-titre au format vtt pour les directs
    * `LIVE_VOSK_MODEL` - > valeur par défaut : `{}` + > default value: `{}` >> >> Paramétrage des modèles pour la transcription des directs
    >> La documentation sera présente prochaînement
    @@ -1187,29 +1187,29 @@ Mettre `USE_IMPORT_VIDEO` à True pour activer cette application.
    >> ``` >> * `USE_BBB` - > valeur par défaut : `False` + > default value: `False` >> Utilisation de BigBlueButton
    >> Retiré à partir de la version 3.8.2 de Pod (remplacé par le module des réunions)
    * `USE_BBB_LIVE` - > valeur par défaut : `False` + > default value: `False` >> Utilisation du système de diffusion de Webinaires en lien avec BigBlueButton
    >> Retiré à partir de la version 3.8.2 de Pod (remplacé par le module des réunions)
    * `USE_LIVE_TRANSCRIPTION` - > valeur par défaut : `False` + > default value: `False` >> Activer l’auto-transcription pour les directs
    >> * `VIEW_EXPIRATION_DELAY` - > valeur par défaut : `60` + > default value: `60` >> Délai (en seconde) selon lequel une vue est considérée comme expirée
    >> si elle n’a pas renvoyé de signal depuis.
    ### Configuration de l’application LTI * `LTI_ENABLED` - > valeur par défaut : `False` + > default value: `False` >> Configuration / Activation du LTI voir pod/main/settings.py L.224
    * `PYLTI_CONFIG` - > valeur par défaut : `{}` + > default value: `{}` >> Cette variable permet de configurer l’application cliente et le secret partagé
    >> >> ```python @@ -1226,33 +1226,33 @@ Mettre `USE_IMPORT_VIDEO` à True pour activer cette application.
    ### Configuration de l’application main * `HOMEPAGE_VIEW_VIDEOS_FROM_NON_VISIBLE_CHANNELS` - > valeur par défaut : `False` + > default value: `False` >> Affiche les vidéos de chaines non visibles sur la page d’accueil
    * `USE_BBB` - > valeur par défaut : `True` + > default value: `True` >> Utilisation de BigBlueButton
    >> Module obsolète.
    * `USE_BBB_LIVE` - > valeur par défaut : `False` + > default value: `False` >> Utilisation du système de diffusion de Webinaires en lien avec BigBlueButton
    >> [TODO] À retirer dans les futures versions de Pod
    * `USE_IMPORT_VIDEO` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application d’import des vidéos
    * `USE_MEETING` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’application meeting
    * `USE_OPENCAST_STUDIO` - > valeur par défaut : `False` + > default value: `False` >> Activation du studio [Opencast](https://opencast.org/)
    * `VERSION` - > valeur par défaut : `` + > default value: `` >> Version courante du projet
    * `WEBTV_MODE` - > valeur par défaut : `False` + > default value: `False` >> Mode webtv permet de basculer POD en une application webtv ensupprimant les boutons de connexions par exemple
    * `SOCIAL_SHARE` - > valeur par défaut : `['X', 'FACEBOOK', 'LINKEDIN', 'BLUESKY', 'MASTODON']` + > default value: `['X', 'FACEBOOK', 'LINKEDIN', 'BLUESKY', 'MASTODON']` >> Choix d'affichage des liens de partage des réseaux sociaux
    ### Configuration de l’application meeting @@ -1262,13 +1262,13 @@ Mettre `USE_MEETING` à True pour activer cette application.
    `BBB_API_URL` et `BBB_SECRET_KEY` sont obligatoires pour faire fonctionner l’application
    * `BBB_API_URL` - > valeur par défaut : `` + > default value: `` >> Indiquer l’URL API de BBB par ex `https://webconf.univ.fr/bigbluebutton/api`.
    * `BBB_LOGOUT_URL` - > valeur par défaut : `` + > default value: `` >> Indiquer l’URL de retour au moment où vous quittez la réunion BBB. Ce champ est optionnel.
    * `BBB_MEETING_INFO` - > valeur par défaut : `{}` + > default value: `{}` >> Dictionnaire de `clé:valeur` permettant d’afficher les informations
    >> d’une session de réunion dans BBB
    >> Voici la liste par défaut
    @@ -1290,16 +1290,16 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `BBB_SECRET_KEY` - > valeur par défaut : `` + > default value: `` >> Clé de votre serveur BBB.
    >> Vous pouvez récupérer cette clé à l’aide de la commande
    >> `bbb-conf --secret` sur le serveur BBB.
    * `DEFAULT_MEETING_THUMBNAIL` - > valeur par défaut : `/img/default-meeting.svg` + > default value: `/img/default-meeting.svg` >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la réunion.
    >> Cette image doit se situer dans le répertoire `static`.
    * `MEETING_DATE_FIELDS` - > valeur par défaut : `()` + > default value: `()` >> liste des champs du formulaire de creation d’une reunion
    >> les champs sont regroupés dans un ensemble de champs
    >> @@ -1313,12 +1313,12 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `MEETING_DISABLE_RECORD` - > valeur par défaut : `True` + > default value: `True` >> Mettre à True pour désactiver les enregistrements de réunion
    >> Configuration de l’enregistrement des réunions.
    >> Ce champ n’est pas pris en compte si `MEETING_DISABLE_RECORD = True`.
    * `MEETING_MAIN_FIELDS` - > valeur par défaut : `()` + > default value: `()` >> Permet de définir les champs principaux du formulaire de création d’une réunion
    >> les champs principaux sont affichés directement dans la page de formulaire d’une réunion
    >> @@ -1335,18 +1335,18 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `MEETING_MAX_DURATION` - > valeur par défaut : `5` + > default value: `5` >> permet de définir la durée maximum pour une reunion
    >> (en heure)
    * `MEETING_PRE_UPLOAD_SLIDES` - > valeur par défaut : `` + > default value: `` >> >> Diaporama préchargé pour les réunions virtuelles.
    >> Un utilisateur peut remplacer cette valeur en choisissant un diaporama
    >> lors de la création d’une réunion virtuelle.
    >> Doit se trouver dans le répertoire statique.
    * `MEETING_RECORD_FIELDS` - > valeur par défaut : `()` + > default value: `()` >> ensemble des champs qui seront cachés si `MEETING_DISABLE_RECORD` est défini à true.
    >> >> ```python @@ -1354,7 +1354,7 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `MEETING_RECURRING_FIELDS` - > valeur par défaut : `()` + > default value: `()` >> Liste de tous les champs permettant de définir la récurrence d’une reunion
    >> tous ces champs sont regroupés dans un ensemble de champs affichés dans une modale
    >> @@ -1371,21 +1371,21 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `RESTRICT_EDIT_MEETING_ACCESS_TO_STAFF_ONLY` - > valeur par défaut : `False` + > default value: `False` >> Seuls les utilisateurs "staff" pourront éditer les réunions
    * `USE_MEETING_WEBINAR` - > valeur par défaut : `False` + > default value: `False` >> Activation du mode Webinaire pour le module des réunions
    * `MEETING_WEBINAR_SIPMEDIAGW_URL` - > valeur par défaut : `` + > default value: `` >> URL du serveur SIPMediaGW qui gère les webinaires (Ex: `https://sipmediagw.univ.fr`)
    >> Retiré à partir de la version 3.8.2 de Pod (remplacé par le module des réunions, cf. passerelle de live)
    * `MEETING_WEBINAR_SIPMEDIAGW_TOKEN` - > valeur par défaut : `` + > default value: `` >> Jeton bearer du serveur SIPMediaGW qui gère les webinaires
    >> Retiré à partir de la version 3.8.2 de Pod (cf. passerelle de live)
    * `MEETING_WEBINAR_FIELDS` - > valeur par défaut : `("is_webinar", "enable_chat")` + > default value: `("is_webinar", "enable_chat")` >> Permet de définir les champs complémentaires du formulaire de création d’un webinaire
    >> ces champs complémentaires sont affichés directement dans la page de formulaire d’un webinaire
    >> @@ -1398,13 +1398,13 @@ Mettre `USE_MEETING` à True pour activer cette application.
    >> ``` >> * `MEETING_WEBINAR_AFFILIATION` - > valeur par défaut : `['faculty', 'employee', 'staff']` + > default value: `['faculty', 'employee', 'staff']` >> Groupes d’accès ou affiliations des personnes autorisées à créer un webinaire
    * `MEETING_WEBINAR_GROUP_ADMIN` - > valeur par défaut : `webinar admin` + > default value: `webinar admin` >> Groupe des personnes autorisées à créer un webinaire
    * `USE_MEETING` - > valeur par défaut : `False` + > default value: `False` >> Activer l’application meeting
    ### Configuration de l’application playlist @@ -1413,57 +1413,57 @@ Application Playlist pour la gestion des playlists.
    Mettre `USE_PLAYLIST` à True pour activer cette application.
    * `COUNTDOWN_PLAYLIST_PLAYER` - > valeur par défaut : `0` + > default value: `0` >> Compte à rebours utilisé entre chaque vidéo lors de
    >> la lecture d’une playlist en lecture automatique.
    >> Le compte à rebours n’est pas présent s’il est à 0.
    * `DEFAULT_PLAYLIST_THUMBNAIL` - > valeur par défaut : `/static/playlist/img/default-playlist.svg` + > default value: `/static/playlist/img/default-playlist.svg` >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la playlist.
    >> Cette image doit se situer dans le répertoire `static`.
    * `RESTRICT_PROMOTED_PLAYLIST_ACCESS_TO_STAFF_ONLY` - > valeur par défaut : `True` + > default value: `True` >> Restreindre l’accès à la création de listes de lecture promues
    >> au staff uniquement.
    * `USE_FAVORITES` - > valeur par défaut : `False` + > default value: `False` >> Activation des vidéos favorites.
    >> Permet aux utilisateurs d’ajouter des vidéos dans leurs favoris.
    * `USE_PLAYLIST` - > valeur par défaut : `False` + > default value: `False` >> Activation des playlist. Permet aux utilisateurs d’ajouter des vidéos dans une playlist.
    * `USE_PROMOTED_PLAYLIST` - > valeur par défaut : `False` + > default value: `False` >> Activation des playlist promues.
    >> Permet aux utilisateurs d'utiliser les listes de lecture promues.
    ### Configuration de l’application podfile * `FILES_DIR` - > valeur par défaut : `files` + > default value: `files` >> Nom du répertoire racine où les fichiers "complémentaires"
    >> (hors vidéos etc.) sont téléversés. Notament utilisé par PODFILE
    >> À modifier principalement pour indiquer dans LOCATION votre serveur
    >> de cache si elle n’est pas sur la même machine que votre POD.
    * `FILE_ALLOWED_EXTENSIONS` - > valeur par défaut : `('doc', 'docx', 'odt', 'pdf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'txt', 'html', 'htm', 'vtt', 'srt')` + > default value: `('doc', 'docx', 'odt', 'pdf', 'xls', 'xlsx', 'ods', 'ppt', 'pptx', 'txt', 'html', 'htm', 'vtt', 'srt')` >> Extensions autorisées pour les documents téléversés
    >> dans le gestionnaire de fichier (en minuscules).
    * `FILE_MAX_UPLOAD_SIZE` - > valeur par défaut : `10` + > default value: `10` >> Poids maximum en Mo par fichier téléversé dans le gestionnaire de fichier
    * `IMAGE_ALLOWED_EXTENSIONS` - > valeur par défaut : `('jpg', 'jpeg', 'bmp', 'png', 'gif', 'tiff', 'webp')` + > default value: `('jpg', 'jpeg', 'bmp', 'png', 'gif', 'tiff', 'webp')` >> Extensions autorisées pour les images téléversées
    >> dans le gestionnaire de fichier. (en minuscules)
    ### Configuration de l’application progressive_web_app * `USE_NOTIFICATIONS` - > valeur par défaut : `False` + > default value: `False` >> Activation des notifications, attention, elles sont actives par défaut.
    * `WEBPUSH_SETTINGS` - > valeur par défaut : + > default value: ```python { @@ -1482,34 +1482,34 @@ Application Quiz pour ajouter des questions sur les vidéos.
    Mettre `USE_QUIZ` à True pour activer cette application.
    * `USE_QUIZ` - > valeur par défaut : `False` + > default value: `False` >> Activation des quiz. Permet aux utilisateurs de créer, répondre et utiliser des quiz dans les vidéos.
    ### Configuration de l’application recorder * `ALLOW_MANUAL_RECORDING_CLAIMING` - > valeur par défaut : `False` + > default value: `False` >> Si True, active un lien dans le menu de l’utilisateur permettant de réclamer un enregistrement.
    * `ALLOW_RECORDER_MANAGER_CHOICE_VID_OWNER` - > valeur par défaut : `True` + > default value: `True` >> Si True, le manager de l’enregistreur pourra choisir un propriétaire de l’enregistrement.
    * `DEFAULT_RECORDER_ID` - > valeur par défaut : `1` + > default value: `1` >> Ajoute un enregistreur par défaut à un enregistrement non identifiable
    >> (mauvais chemin dans le dépôt FTP).
    * `DEFAULT_RECORDER_PATH` - > valeur par défaut : `/data/ftp-pod/ftp/` + > default value: `/data/ftp-pod/ftp/` >> Chemin racine du répertoire où sont déposés les enregistrements
    >> (chemin du serveur FTP).
    * `DEFAULT_RECORDER_TYPE_ID` - > valeur par défaut : `1` + > default value: `1` >> Identifiant du type de vidéo par défaut (si non spécifié).
    >> (Exemple : 3 pour Colloque/conférence, 4 pour Cours…)
    * `DEFAULT_RECORDER_USER_ID` - > valeur par défaut : `1` + > default value: `1` >> Identifiant du propriétaire par défaut (si non spécifié) des enregistrements déposés.
    * `OPENCAST_DEFAULT_PRESENTER` - > valeur par défaut : `mid` + > default value: `mid` >> Permet de spécifier la valeur par défaut du placement de la vidéo du
    >> presenteur par rapport à la vidéo de présentation (écran)
    >> les valeurs possibles sont :
    @@ -1520,10 +1520,10 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> Ce fichier va contenir toutes les spécificités de l’enregistrement
    >> (source, cutting, title, presenter etc.)
    * `OPENCAST_FILES_DIR` - > valeur par défaut : `opencast-files` + > default value: `opencast-files` >> Permet de spécifier le dossier de stockage des enregistrements du studio avant traitement.
    * `OPENCAST_MEDIAPACKAGE` - > valeur par défaut : `-> see xml content` + > default value: `-> see xml content` >> Contenu par défaut du fichier xml pour créer le mediapackage pour le studio.
    >> Ce fichier va contenir toutes les spécificités de l’enregistrement
    >> (source, cutting, title, presenter etc.)
    @@ -1539,11 +1539,11 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `PUBLIC_RECORD_DIR` - > valeur par défaut : `records` + > default value: `records` >> Chemin d’accès web (public) au répertoire de dépot des enregistrements (`DEFAULT_RECORDER_PATH`).
    >> Attention : penser à modifier la conf de NGINX.
    * `RECORDER_ADDITIONAL_FIELDS` - > valeur par défaut : `()` + > default value: `()` >> Liste des champs supplémentaires pour le formulaire des enregistreurs.
    >> Cette liste reprend le nom des champs correspondants aux paramètres d’édition d’une vidéo
    >> (Discipline, Chaine, Theme, mots clés...).
    @@ -1552,50 +1552,50 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> Les vidéos seront alors générées avec les valeurs des champs supplémentaires
    >> telles que définies dans leur enregistreur.
    * `RECORDER_ALLOW_INSECURE_REQUESTS` - > valeur par défaut : `False` + > default value: `False` >> Autorise la requête sur l’application en elle-même sans vérifier le certificat SSL
    * `RECORDER_BASE_URL` - > valeur par défaut : `https://pod.univ.fr` + > default value: `https://pod.univ.fr` >> url racine de l’instance permettant l’envoi de notification lors de la réception d’enregistrement.
    * `RECORDER_SELF_REQUESTS_PROXIES` - > valeur par défaut : `{"http": None, "https": None}` + > default value: `{"http": None, "https": None}` >> Précise les proxy à utiliser pour une requête vers l’application elle même
    >> dans le cadre d’enregistrement par défaut force la non utilisation de proxy.
    * `RECORDER_SKIP_FIRST_IMAGE` - > valeur par défaut : `False` + > default value: `False` >> Si True, permet de ne pas prendre en compte la 1ère image lors du traitement
    >> d’un fichier d’enregistrement de type AudioVideoCast.
    * `RECORDER_TYPE` - > valeur par défaut : `(('video', _('Video')), ('audiovideocast', _('Audiovideocast')), ('studio', _('Studio')))` + > default value: `(('video', _('Video')), ('audiovideocast', _('Audiovideocast')), ('studio', _('Studio')))` >> Type d’enregistrement géré par la plateforme.
    >> Un enregistreur ne peut déposer que des fichiers de type proposé par la plateforme.
    >> Le traitement se fait en fonction du type de fichier déposé.
    * `USE_OPENCAST_STUDIO` - > valeur par défaut : `False` + > default value: `False` >> Activer l’utilisation du studio Opencast.
    * `USE_RECORD_PREVIEW` - > valeur par défaut : `False` + > default value: `False` >> Si True, affiche l’icone de prévisualisation des vidéos dans la page "Revendiquer un enregistrement".
    ### Configuration de l’application vidéo * `ACTIVE_VIDEO_COMMENT` - > valeur par défaut : `False` + > default value: `False` >> Activer les commentaires au niveau de la plateforme
    * `CACHE_VIDEO_DEFAULT_TIMEOUT` - > valeur par défaut : `600` + > default value: `600` >> >> Temps en seconde de conservation des données de l’application video
    * `CHANNEL_FORM_FIELDS_HELP_TEXT` - > valeur par défaut : `` + > default value: `` >> Ensemble des textes d’aide affichés avec le formulaire d’édition de chaine.
    >> voir pod/video/forms.py
    * `CHUNK_SIZE` - > valeur par défaut : `1000000` + > default value: `1000000` >> Taille d’un fragment lors de l’envoi d’une vidéo
    >> le fichier sera mis en ligne par fragment de cette taille.
    * `CURSUS_CODES` - > valeur par défaut : `()` + > default value: `()` >> Liste des cursus proposés lors de l’ajout des vidéos.
    >> Affichés en dessous d’une vidéos, ils sont aussi utilisés pour affiner la recherche.
    >> @@ -1610,32 +1610,32 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `DEFAULT_DC_COVERAGE` - > valeur par défaut : `TITLE_ETB + " - Town - Country"` + > default value: `TITLE_ETB + " - Town - Country"` >> couverture du droit pour chaque vidéo
    * `DEFAULT_DC_RIGHTS` - > valeur par défaut : `BY-NC-SA` + > default value: `BY-NC-SA` >> droit par défaut affichés dans le flux RSS si non renseigné
    * `DEFAULT_THUMBNAIL` - > valeur par défaut : `img/default.svg` + > default value: `img/default.svg` >> Image par défaut affichée comme poster ou vignette, utilisée pour présenter la vidéo.
    >> Cette image doit se situer dans le répertoire static.
    * `DEFAULT_TYPE_ID` - > valeur par défaut : `1` + > default value: `1` >> Les vidéos créées sans type (par importation par exemple)
    >> seront affectées au type par défaut
    >> (en général, le type ayant pour identifiant '1' est 'Other')
    * `DEFAULT_YEAR_DATE_DELETE` - > valeur par défaut : `2` + > default value: `2` >> Durée d’obsolescence par défaut (en années après la date d’ajout).
    * `FORCE_LOWERCASE_TAGS` - > valeur par défaut : `True` + > default value: `True` >> Les mots clés saisis lors de l’ajout de vidéo sont convertis automatiquement en minuscule.
    * `LANG_CHOICES` - > valeur par défaut : `` + > default value: `` >> Liste des langues proposées lors de l’ajout des vidéos.
    >> Affichés en dessous d’une vidéos, les choix sont aussi utilisés pour affiner la recherche.
    * `LICENCE_CHOICES` - > valeur par défaut : `()` + > default value: `()` >> Licence proposées pour les vidéos en creative commons :
    >> >> ```python @@ -1654,18 +1654,18 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `MAX_DURATION_DATE_DELETE` - > valeur par défaut : `10` + > default value: `10` >> Fixe une durée maximale que la date de suppression d’une vidéo ne peut dépasser.
    >> Par défaut : 10 (Année courante + 10 ans).
    * `MAX_TAG_LENGTH` - > valeur par défaut : `50` + > default value: `50` >> Les mots-clés saisis lors de l’ajout de vidéo ne peuvent dépasser cette longueur.
    * `NUMBER_TAGS_CLOUD` - > valeur par défaut : `20` + > default value: `20` >> Nombre de mots-clés les plus importants affichés dans le nuage de la page d'accueil.
    >> Les paramètres TAGULOUS_WEIGHT_MIN et TAGULOUS_WEIGHT_MAX ne sont pas utilisés.
    * `NOTES_STATUS` - > valeur par défaut : `()` + > default value: `()` >> Valeurs possible pour l’accès à une note.
    >> >> ```python @@ -1677,17 +1677,17 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `OEMBED` - > valeur par défaut : `False` + > default value: `False` >> Permettre l’usage du oembed, partage dans Moodle, Facebook, Twitter etc.
    * `ORGANIZE_BY_THEME` - > valeur par défaut : `False` + > default value: `False` >> Affichage uniquement des vidéos de la chaîne ou du thème actuel(le).
    >> Affichage des sous-thèmes directs de la chaîne ou du thème actuel(le)
    * `RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY` - > valeur par défaut : `False` + > default value: `False` >> Si True, seule les personnes "Staff" peuvent déposer des vidéos
    * `THEME_FORM_FIELDS_HELP_TEXT` - > valeur par défaut : `""` + > default value: `""` >> Ensemble des textes d’aide affichés avec le formulaire d’édition de theme.
    >> voir pod/video/forms.py
    >> @@ -1722,17 +1722,17 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `USER_VIDEO_CATEGORY` - > valeur par défaut : `False` + > default value: `False` >> Permet d’activer le fonctionnement de categorie au niveau de ses vidéos.
    >> Vous pouvez créer des catégories pour pouvoir ranger vos propres vidéos.
    >> Les catégories sont liées à l’utilisateur.
    * `USE_OBSOLESCENCE` - > valeur par défaut : `False` + > default value: `False` >> Activation de l’obsolescence des video.
    >> Permet d’afficher la date de suppression de la video
    >> dans le formulaire d’edition et dans la partie admin.
    * `USE_STATS_VIEW` - > valeur par défaut : `False` + > default value: `False` >> Permet d’activer la possibilité de voir en details le nombre de visualisation
    >> d’une vidéo durant un jour donné ou mois,
    >> année ou encore le nombre de vue total depuis la création de la vidéo.
    @@ -1740,7 +1740,7 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> un lien est rajouté dans la page de visualisation d’une chaîne ou un theme
    >> ou encore toutes les vidéos présentes sur la plateforme.
    * `USE_VIDEO_EVENT_TRACKING` - > valeur par défaut : `False` + > default value: `False` >> Ce paramètre permet d’activer l’envoi d’évènements sur le lecteur vidéo à Matomo.
    >> N’est utile que si le code piwik / matomo est présent dans l’instance de Esup-Pod.
    >> Les évènements envoyés sont :
    @@ -1751,12 +1751,12 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> de préciser dans la variable `TEMPLATE_VISIBLE_SETTINGS`:
    >> `'TRACKING_TEMPLATE': 'custom/tracking.html'`
    * `USE_XAPI_VIDEO` - > valeur par défaut : `False` + > default value: `False` >> >> Active l‘envoi d’instructions xAPI pour le lecteur vidéo.
    >> Attention, il faut mettre USE_XAPI à True pour que les instructions soient envoyées.
    * `VIDEO_ALLOWED_EXTENSIONS` - > valeur par défaut : `()` + > default value: `()` >> Extensions autorisées pour le téléversement vidéo sur la plateforme (en minuscules).
    >> >> ```python @@ -1784,14 +1784,14 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `VIDEO_FEED_NB_ITEMS` - > valeur par défaut : `100` + > default value: `100` >> >> nombre d’item renvoyé par le flux rss
    * `VIDEO_FORM_FIELDS` - > valeur par défaut : `__all__` + > default value: `__all__` >> Liste des champs du formulaire d’édition de vidéos affichées.
    * `VIDEO_FORM_FIELDS_HELP_TEXT` - > valeur par défaut : `` + > default value: `` >> Ensemble des textes d’aide affichés avec le formulaire d’envoi de vidéo.
    >> >> ```python @@ -2018,61 +2018,73 @@ Mettre `USE_QUIZ` à True pour activer cette application.
    >> ``` >> * `VIDEO_MAX_UPLOAD_SIZE` - > valeur par défaut : `1` + > default value: `1` >> Taille maximum en Go des fichiers téléversés sur la plateforme.
    * `VIDEO_PLAYBACKRATES` - > valeur par défaut : `[0.5, 1, 1.5, 2]` + > default value: `[0.5, 1, 1.5, 2]` >> Configuration des choix de vitesse de lecture pour le lecteur vidéo.
    * `VIDEO_RECENT_VIEWCOUNT` - > valeur par défaut : `180` + > default value: `180` >> Durée (en nombre de jours) sur laquelle on souhaite compter le nombre de vues récentes.
    * `VIDEO_REQUIRED_FIELDS` - > valeur par défaut : `[]` + > default value: `[]` >> Permet d’ajouter l’attribut obligatoire dans
    >> le formulaire d’edition et d’ajout d’une video :
    >> Exemple de valeur : `["discipline", "tags"]`
    >> NB : les champs cachés et suivant ne sont pas pris en compte :
    >> `(video, title, type, owner, date_added, cursus, main_lang)`
    * `VIEW_STATS_AUTH` - > valeur par défaut : `False` + > default value: `False` >> Réserve l’accès aux statistiques des vidéos aux personnes authentifiées.
    ### Configuration de l’application encodage et transcription de vidéo Application pour l’encodage et la transcription de vidéo.
    Il est possible d’encoder en local ou en distant.
    -Attention, il faut configurer Celery pour l’envoi des instructions pour l’encodage distant.
    - +Pour l’encodage distant, il est préférable d’utiliser le système d’externalisation des encodages et transcriptions,
    +Esup-Runner, avec utilisation de runner managers. Sinon, il est aussi possible d’utiliser Celery dans ce cas là.
    + +* `USE_RUNNER_MANAGER` + > default value: `False` + >> Si True, Pod utilise le système d’externalisation des encodages et transcriptions,
    + >> Esup-Runner, avec utilisation de runner managers. Les encodages et transcriptions
    + >> sont totalement délégués à ce système. Cf. https://github.com/EsupPortail/esup-runner.
    + >> Dans ce cas là, il n'est plus utile de configurer Celery pour l’encodage et la transcription à distance.
    +* `RM_TASKS_DELETED_AFTER_DAYS` + > default value: `0` + >> Paramètre utilisé seulement quand USE_RUNNER_MANAGER = True.
    + >> Il correspond au nombre de jours après lesquels les tâches terminées sont supprimées
    + >> dans la base de données (0 signifiant que les tâches terminées sont conservées indéfiniment).
    * `CAPTIONS_STRICT_ACCESSIBILITY` - > valeur par défaut : `False` + > default value: `False` >> Si True, les sous-titres seront générés en respectant strictement les normes
    >> d’accessibilité. L'apparition d'un message d’avertissement sera affiché si les
    >> sous-titres ne respectent pas ces normes, même si la valeur est à False.
    * `CELERY_BROKER_URL` - > valeur par défaut : `redis://redis.localhost:6379/5` + > default value: `redis://redis.localhost:6379/5` >> URL du courtier de messages où Celery stocke les ordres d’encodage et de transcription.
    * `CELERY_TO_ENCODE` - > valeur par défaut : `False` + > default value: `False` >> Utilisation de Celery pour la gestion des taches d’encodage
    * `DEFAULT_LANG_TRACK` - > valeur par défaut : `fr` + > default value: `fr` >> langue par défaut pour l’ajout de piste à une vidéo.
    * `EMAIL_ON_ENCODING_COMPLETION` - > valeur par défaut : `True` + > default value: `True` >> Si True, un courriel est envoyé aux managers
    >> et à l’auteur (si DEBUG est à False) à la fin de l’encodage.
    * `EMAIL_ON_TRANSCRIPTING_COMPLETION` - > valeur par défaut : `True` + > default value: `True` >> Si True, un courriel est envoyé aux managers
    >> et à l’auteur (si DEBUG est à False) à la fin de la transcription.
    * `ENCODE_STUDIO` - > valeur par défaut : `start_encode_studio` + > default value: `start_encode_studio` >> Fonction appelée pour lancer l’encodage du studio (merge and cut).
    * `ENCODE_VIDEO` - > valeur par défaut : `start_encode` + > default value: `start_encode` >> Fonction appelée pour lancer l’encodage des vidéos direct par thread ou distant par celery
    * `ENCODING_CHOICES` - > valeur par défaut : `()` + > default value: `()` >> Encodage possible sur la plateforme. Associé à un rendu dans le cas d’une vidéo.
    >> >> ```python @@ -2087,13 +2099,13 @@ Attention, il faut configurer Celery pour l’envoi des instructions pour l’en >> ``` >> * `ENCODING_TRANSCODING_CELERY_BROKER_URL` - > valeur par défaut : `False` + > default value: `False` >> >> Il faut renseigner l’url du redis sur lequel Celery
    >> va chercher les ordres d’encodage et de transcription
    >> par exemple : "redis://redis.localhost:6379/7"
    * `FORMAT_CHOICES` - > valeur par défaut : `()` + > default value: `()` >> Format d’encodage réalisé sur la plateforme.
    >> >> ```python @@ -2108,21 +2120,21 @@ Attention, il faut configurer Celery pour l’envoi des instructions pour l’en >> ``` >> * `USE_REMOTE_ENCODING_TRANSCODING` - > valeur par défaut : `False` + > default value: `False` >> >> Si True, active l’encodage et la transcription sur un environnement distant via redis+celery
    * `POD_API_URL` - > valeur par défaut : `` + > default value: `` >> Adresse de l’API rest à appeler en fin d’encodage
    >> distant ou de transcription à distance.
    >> Exemple : `https://pod.univ.fr/rest/`
    * `POD_API_TOKEN` - > valeur par défaut : `` + > default value: `` >> Token d’authentification utilisé pour l’appel
    >> en fin d’encodage distant ou de transcription à distance.
    >> Pour le créer, il faut aller dans la partie Admin > Jeton d’authentification > token.
    * `VIDEO_RENDITIONS` - > valeur par défaut : `[]` + > default value: `[]` >> Rendu serializé pour l’encodage des videos.
    >> Cela permet de pouvoir encoder les vidéos sans l’environnement de Pod.
    >> @@ -2163,20 +2175,20 @@ Attention, il faut configurer Celery pour l’envoi des instructions pour l’en ### Configuration de l’application search * `ES_INDEX` - > valeur par défaut : `pod` + > default value: `pod` >> Valeur pour l’index de ElasticSearch
    * `ES_MAX_RETRIES` - > valeur par défaut : `10` + > default value: `10` >> Valeur max de tentatives pour ElasticSearch.
    * `ES_TIMEOUT` - > valeur par défaut : `30` + > default value: `30` >> Valeur de timeout pour ElasticSearch.
    * `ES_URL` - > valeur par défaut : `["http://elasticsearch.localhost:9200/"]` + > default value: `["http://elasticsearch.localhost:9200/"]` >> Adresse du ou des instances d’Elasticsearch utilisées pour
    >> l’indexation et la recherche de vidéo.
    * `ES_VERSION` - > valeur par défaut : `8` + > default value: `8` >> Version d’ElasticSearch.
    >> valeurs possibles : `8`, correspondant à la version du serveur Elasticsearch utilisé.
    >> Attention, le paquet elasticsearch-py doit correspondre à la version du serveur.
    @@ -2184,7 +2196,7 @@ Attention, il faut configurer Celery pour l’envoi des instructions pour l’en >> Voir [elasticsearch-py.readthedocs.io](https://elasticsearch-py.readthedocs.io/)
    >> pour plus d’information.
    * `ES_OPTIONS` - > valeur par défaut : `{}` + > default value: `{}` >> Options d’ElasticSearch, notamment utilisées pour ES8 en SSL et avec un user en paramètre
    >> Voir [www.elastic.co](https://www.elastic.co/guide/en/elasticsearch/client/python-api/current/config.html)
    >> pour plus d’informations.
    @@ -2196,23 +2208,23 @@ Aucune instruction ne persiste dans Pod, elles sont toutes envoyées au LRS para Attention, il faut configurer Celery pour l’envoi des instructions.
    * `USE_XAPI` - > valeur par défaut : `False` + > default value: `False` >> >> Activation de l’application xAPI
    * `XAPI_ANONYMIZE_ACTOR` - > valeur par défaut : `True` + > default value: `True` >> >> Si False, le nom de l’utilisateur sera stocké en clair dans les statements xAPI,
    >> si True, son nom d’utilisateur sera anonymisé
    * `XAPI_LRS_LOGIN` - > valeur par défaut : `` + > default value: `` >> >> identifiant de connexion du LRS pour l’envoi des statements
    * `XAPI_LRS_PWD` - > valeur par défaut : `` + > default value: `` >> >> mot de passe de connexion du LRS pour l’envoi des statements
    * `XAPI_LRS_URL` - > valeur par défaut : `` + > default value: `` >> >> URL de destination pour l’envoi des statements. I.E. : `https://ralph.univ.fr/xAPI/statements`
    From 7be45f8fefc28052ae90e469370ec7ae027e8368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20B=2E?= <56730254+LoicBonavent@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:00:02 +0100 Subject: [PATCH 11/15] Enhance dashboard filtering and runner manager administration with i18n updates (#1406) ## Summary This PR improves dashboard filtering behavior, strengthens runner manager administration and task routing, and updates translation catalogs. ## What changed - Added support for dashboard filters using slugs, numeric IDs, and comma-separated query values. - Improved filter synchronization between URL parameters, session storage, dashboard chips, and the category aside. - Added a clear category filter action in the aside and kept UI state aligned with current query params. - Hardened dashboard refresh JavaScript with defensive checks for missing globals and DOM elements. - Added an `is_active` flag on runner managers to enable/disable managers without deleting configuration. - Updated task dispatching and background processing to use active runner managers only. - Kept tasks in pending state when no active runner manager is available for retry later. - Improved runner admin UX with status badges and direct links to remote runner administration. - Enhanced remote thumbnail import to support multiple generated thumbnails and avoid duplicate attachment. - Added/updated tests for category CSV filtering, ID/CSV filters, runner admin UI behavior, and inactive-manager exclusion. - Updated French translation catalogs. ## Impact - More robust and predictable dashboard filtering UX. - Safer and clearer runner manager operations in admin and processing flows. - Translation coverage updated for newly introduced admin and filter labels. --- pod/locale/fr/LC_MESSAGES/django.po | 75 +++++-- pod/video/admin.py | 4 +- pod/video/static/js/FilterManager.js | 190 +++++++++++++++-- pod/video/static/js/filter.js | 23 +- .../js/filter_aside_video_list_refresh.js | 65 ++++-- pod/video/static/js/video_category.js | 111 +++++++++- pod/video/templates/videos/dashboard.html | 2 +- .../videos/filter_aside_category.html | 5 + pod/video/tests/test_category.py | 15 ++ pod/video/tests/test_views.py | 50 ++++- pod/video/utils.py | 33 ++- pod/video/views.py | 200 ++++++++++++++---- pod/video_encode_transcript/admin.py | 50 ++++- .../management/commands/process_tasks.py | 43 +++- pod/video_encode_transcript/models.py | 16 +- pod/video_encode_transcript/runner_manager.py | 20 +- .../runner_manager_utils.py | 174 ++++++++++----- .../templates/admin_test_connection.html | 36 ++++ .../tests/test_runner_manager_admin.py | 33 +++ .../tests/test_runner_manager_round_robin.py | 23 ++ .../tests/test_studio_integration.py | 2 +- .../tests/test_views_helpers.py | 40 ++++ pod/video_encode_transcript/views.py | 21 +- 23 files changed, 1029 insertions(+), 202 deletions(-) diff --git a/pod/locale/fr/LC_MESSAGES/django.po b/pod/locale/fr/LC_MESSAGES/django.po index 61a3aff00c..ad5245b6cb 100644 --- a/pod/locale/fr/LC_MESSAGES/django.po +++ b/pod/locale/fr/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Pod\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-13 09:52+0100\n" +"POT-Creation-Date: 2026-02-27 14:05+0100\n" "PO-Revision-Date: \n" "Last-Translator: obado \n" "Language-Team: Pod Team cotech-esup-pod@esup-portail.org\n" @@ -193,7 +193,8 @@ msgstr "Enrichissements AI" #: pod/quiz/templates/quiz/create_edit_quiz.html #: pod/quiz/templates/quiz/video_quiz.html pod/recorder/models.py #: pod/video/models.py pod/video/templates/channel/channel.html -#: pod/video/templates/videos/videos.html pod/video_encode_transcript/models.py +#: pod/video/templates/videos/videos.html pod/video_encode_transcript/admin.py +#: pod/video_encode_transcript/models.py msgid "Video" msgstr "Vidéo" @@ -234,8 +235,8 @@ msgid "Enter the ID of the enhancement in Aristote" msgstr "Entrez l’identifiant de l’enrichissement dans Aristote" #: pod/ai_enhancement/templates/choose_video_element.html -#: pod/dressing/models.py pod/video/apps.py pod/video/models.py -#: pod/video/templates/videos/video_breadcrumbs.html +#: pod/dressing/models.py pod/video/admin.py pod/video/apps.py +#: pod/video/models.py pod/video/templates/videos/video_breadcrumbs.html #: pod/video/templates/videos/videos.html msgid "Videos" msgstr "Vidéos" @@ -433,9 +434,9 @@ msgid "" "Something went wrong with AI improvement on “%(content_title)s”. Suggestions " "for improvement can’t be available on %(site_title)s." msgstr "" -"Une erreur s’est produite sur le traitement par l’IA de « %(content_title)s " -"». Les suggestions d’amélioration ne peuvent pas être disponibles sur " -"%(site_title)s." +"Une erreur s’est produite sur le traitement par l’IA de " +"« %(content_title)s ». Les suggestions d’amélioration ne peuvent pas être " +"disponibles sur %(site_title)s." #: pod/ai_enhancement/utils.py #, python-format @@ -457,8 +458,8 @@ msgid "" "Something went wrong with AI improvement on “%(content_title)s” on " "%(site_title)s." msgstr "" -"Une erreur s’est produite sur l’amélioration par l’IA de « %(content_title)s " -"» sur %(site_title)s." +"Une erreur s’est produite sur l’amélioration par l’IA de " +"« %(content_title)s » sur %(site_title)s." #: pod/ai_enhancement/utils.py pod/live/utils.py pod/meeting/utils.py #: pod/video_encode_transcript/utils.py @@ -759,8 +760,8 @@ msgid "The file must be in VTT format." msgstr "Le fichier doit être au format VTT." #: pod/chapter/models.py pod/completion/models.py pod/enrichment/models.py -#: pod/playlist/templates/playlist/playlist_card.html pod/video/models.py -#: pod/video_encode_transcript/utils.py +#: pod/playlist/templates/playlist/playlist_card.html pod/video/admin.py +#: pod/video/models.py pod/video_encode_transcript/utils.py msgid "video" msgstr "vidéo" @@ -1355,7 +1356,8 @@ msgstr "" #: pod/completion/permissions/video.py #, python-brace-format msgid "{func.__name__}: Permission denied for user {current_user.pk}." -msgstr "{func.__name__} : Permission refusée pour l’utilisateur {current_user.pk}." +msgstr "" +"{func.__name__} : Permission refusée pour l’utilisateur {current_user.pk}." #: pod/completion/templates/contributor/form_contributor.html #: pod/completion/templates/document/form_document.html @@ -6048,7 +6050,7 @@ msgstr "Journal des sessions des réunions" msgid "Meeting session logs" msgstr "Journaux des sessions des réunions" -#: pod/meeting/models.py +#: pod/meeting/models.py pod/video_encode_transcript/admin.py msgid "Recording ID" msgstr "Identifiant de l’enregistrement" @@ -6191,6 +6193,7 @@ msgstr "" "Pour supprimer la réunion, veuillez cocher la case et cliquer sur supprimer." #: pod/meeting/templates/meeting/filter_aside.html pod/video/views.py +#: pod/video_encode_transcript/admin.py msgid "Status" msgstr "Statut" @@ -6408,7 +6411,8 @@ msgstr "La réunion n’a pas encore commencé." msgid "" "It is scheduled for %(start_date)s at %(start_time)s." msgstr "" -"Elle est planifiée le %(start_date)s à %(start_time)s." +"Elle est planifiée le %(start_date)s à %(start_time)s." #: pod/meeting/templates/meeting/join.html msgid "You will be redirected right after the meeting starts." @@ -6583,7 +6587,7 @@ msgid "Has user joined?" msgstr "Utilisateurs connectés ?" #: pod/meeting/views.py pod/recorder/models.py -#: pod/video_encode_transcript/models.py +#: pod/video_encode_transcript/admin.py pod/video_encode_transcript/models.py msgid "Recording" msgstr "Enregistrement" @@ -9914,6 +9918,7 @@ msgid "Clear all filters" msgstr "Effacer tous les filtres" #: pod/video/templates/videos/dashboard.html +#: pod/video/templates/videos/filter_aside_category.html msgid "Clear filters" msgstr "Effacer les filtres" @@ -10629,6 +10634,7 @@ msgstr "La fiche ne contient pas de vidéo." msgid "Queue rank:" msgstr "Rang dans la file d’attente :" +#: pod/video/templates/videos/video_queue_runner_manager.html msgid "Total number of videos in the queue:" msgstr "Nombre total de vidéos dans la file d’attente :" @@ -10994,6 +11000,23 @@ msgstr "Erreur serveur lors du traitement du filtre." msgid "resolution" msgstr "résolution" +#: pod/video_encode_transcript/admin.py pod/video_encode_transcript/models.py +msgid "Active" +msgstr "Actif" + +#: pod/video_encode_transcript/admin.py +msgid "Inactive" +msgstr "Inactif" + +#: pod/video_encode_transcript/admin.py +#: pod/video_encode_transcript/templates/admin_test_connection.html +msgid "Runner administration" +msgstr "Administration des runners" + +#: pod/video_encode_transcript/admin.py +msgid "Open runner administration" +msgstr "Ouvrir l’administration des runners" + #: pod/video_encode_transcript/admin.py msgid "Runner manager not found." msgstr "Runner manager non trouvé." @@ -11013,8 +11036,8 @@ msgid "" "Runner manager '%(name)s' responded but rejected authentication (HTTP " "%(status)s). Check the bearer token." msgstr "" -"Le runner manager « %(name)s » a répondu mais a rejeté l’authentification (HTTP " -"%(status)s). Vérifiez le jeton porteur." +"Le runner manager « %(name)s » a répondu mais a rejeté l’authentification " +"(HTTP %(status)s). Vérifiez le jeton porteur." #: pod/video_encode_transcript/admin.py #, python-format @@ -11027,8 +11050,8 @@ msgid "" "Runner manager '%(name)s' is reachable but endpoint %(url)s was not found " "(HTTP 404). Check the configured URL." msgstr "" -"Le runner manager « %(name)s » est accessible, mais le point de terminaison %(url)s est introuvable " -"(HTTP 404). Vérifiez l’URL configurée." +"Le runner manager « %(name)s » est accessible, mais le point de terminaison " +"%(url)s est introuvable (HTTP 404). Vérifiez l’URL configurée." #: pod/video_encode_transcript/admin.py #, python-format @@ -11036,8 +11059,12 @@ msgid "" "Runner manager '%(name)s' is reachable but returned an unexpected response " "(HTTP %(status)s)." msgstr "" -"Le runner manager « %(name)s » est accessible, mais a renvoyé une réponse inattendue " -"(HTTP %(status)s)." +"Le runner manager « %(name)s » est accessible, mais a renvoyé une réponse " +"inattendue (HTTP %(status)s)." + +#: pod/video_encode_transcript/admin.py +msgid "Video ID" +msgstr "Identifiant de la vidéo" #: pod/video_encode_transcript/admin.py msgid "Restart selected tasks" @@ -11171,6 +11198,12 @@ msgstr "" "Priorité du runner manager. Les valeurs les plus basses indiquent une " "priorité plus élevée." +#: pod/video_encode_transcript/models.py +msgid "If checked, this runner manager can be used to process tasks." +msgstr "" +"Si cette option est cochée, ce runner manager peut être utilisé pour traiter " +"des tâches." + #: pod/video_encode_transcript/models.py msgid "URL of the runner manager" msgstr "URL du runner manager" diff --git a/pod/video/admin.py b/pod/video/admin.py index dd01936249..b28b5379ea 100644 --- a/pod/video/admin.py +++ b/pod/video/admin.py @@ -642,7 +642,7 @@ class VideoToDeleteAdmin(admin.ModelAdmin): list_filter = ["date_deletion"] autocomplete_fields = ["video"] - @admin.display(description="video") + @admin.display(description=_("video")) def get_videos(self, obj): return obj.video.count() @@ -667,7 +667,7 @@ class CategoryAdmin(admin.ModelAdmin): readonly_fields = ("slug",) # list_filter = ["owner"] - @admin.display(description="Videos") + @admin.display(description=_("Videos")) def videos_count(self, obj) -> int: return obj.video.all().count() diff --git a/pod/video/static/js/FilterManager.js b/pod/video/static/js/FilterManager.js index f1513c904f..210fbc85ee 100644 --- a/pod/video/static/js/FilterManager.js +++ b/pod/video/static/js/FilterManager.js @@ -72,6 +72,7 @@ class FilterManager { addFilter({ name, param, searchCallback, itemLabel, itemKey }) { try { this.filters[param] = { + param, name, searchCallback, itemLabel, @@ -201,16 +202,7 @@ class FilterManager { */ removeFilter(currentFilter, key) { if (currentFilter) { - const filterElement = document.getElementById(`${slugify(key)}-tag`); - const closeButton = document.getElementById( - `remove-filter-${slugify(key)}`, - ); - if (closeButton) { - const tooltip = bootstrap.Tooltip.getInstance(closeButton); - if (tooltip) tooltip.dispose(); - } - const data = currentFilter.selectedItems.get(key); - if (filterElement) filterElement.remove(); + this._removeFilterTag(key); currentFilter.selectedItems.delete(key); sessionStorage.setItem( `filter-${currentFilter.param}`, @@ -224,10 +216,29 @@ class FilterManager { * Initializes the filters from the session. */ async initializeFilters() { + let shouldRefresh = false; + for (const param of Object.keys(this.filters)) { const filter = this.filters[param]; + const selectedKeysFromSession = ( + JSON.parse(sessionStorage.getItem(`filter-${param}`)) || [] + ) + .map((value) => slugify(String(value))) + .filter((value) => value !== ""); + const selectedKeysFromUrl = this.getFilterValuesFromUrl(param).map((value) => + slugify(String(value)), + ); const selectedKeys = - JSON.parse(sessionStorage.getItem(`filter-${param}`)) || []; + selectedKeysFromUrl.length > 0 + ? selectedKeysFromUrl + : selectedKeysFromSession; + + // Refresh only when we apply filters coming from sessionStorage + // that are not already present in the URL/server-rendered page. + if (selectedKeysFromUrl.length === 0 && selectedKeys.length > 0) { + shouldRefresh = true; + } + let allItems = []; try { allItems = await filter.searchCallback(""); @@ -251,7 +262,135 @@ class FilterManager { } }); } - this.update(); + this.update({ refresh: shouldRefresh }); + } + + /** + * Returns selected values from URL for one filter key. + * Supports repeated and comma-separated values. + * @param {string} param - Parameter key. + * @returns {Array} + */ + getFilterValuesFromUrl(param) { + const searchParams = new URLSearchParams(window.location.search); + return searchParams + .getAll(param) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value) => value !== ""); + } + + /** + * Synchronize one filter with an explicit list of values. + * @param {string} param - Parameter key. + * @param {Array} values - Values to set. + * @param {Object} [options] + * @param {boolean} [options.refresh=true] - Trigger videos refresh. + * @param {boolean} [options.emitEvent=true] - Emit dashboard filters event. + * @param {boolean} [options.updateUrl=true] - Update browser URL. + */ + syncFilterSelection( + param, + values = [], + { refresh = true, emitEvent = true, updateUrl = true } = {}, + ) { + const filter = this.filters[param]; + if (!filter) return; + + Array.from(filter.selectedItems.keys()).forEach((key) => { + this._removeFilterTag(key); + }); + filter.selectedItems.clear(); + + const availableItems = this.currentResults[param] || []; + const uniqueEntries = []; + const seenKeys = new Set(); + + values.forEach((entry) => { + const rawValue = + typeof entry === "object" && entry !== null ? entry.value : entry; + if (!rawValue) return; + + const value = String(rawValue).trim(); + if (!value) return; + + const key = slugify(value); + if (seenKeys.has(key)) return; + seenKeys.add(key); + + const providedLabel = + typeof entry === "object" && entry !== null ? entry.label : null; + const matchedItem = availableItems.find( + (item) => slugify(filter.itemKey(item)) === key, + ); + const label = + providedLabel || + (matchedItem ? filter.itemLabel(matchedItem) : value); + + uniqueEntries.push({ key, value, label }); + }); + + uniqueEntries.forEach(({ key, value, label }) => { + filter.selectedItems.set(key, { value, label }); + this.renderActiveFilter(filter, key); + }); + + const listContainer = document.getElementById( + `collapseFilter${capitalize(param)}`, + ); + if (listContainer) { + this.createCheckboxesForFilter(param, this.currentResults[param] || []); + } + + this.update({ refresh, emitEvent, updateUrl }); + } + + /** + * Synchronize one filter from URL query values. + * @param {string} param - Parameter key. + * @param {Object} [options] + * @param {boolean} [options.refresh=true] - Trigger videos refresh. + * @param {boolean} [options.emitEvent=true] - Emit dashboard filters event. + * @param {boolean} [options.updateUrl=true] - Update browser URL. + */ + syncFilterSelectionFromUrl( + param, + { refresh = true, emitEvent = true, updateUrl = true } = {}, + ) { + this.syncFilterSelection(param, this.getFilterValuesFromUrl(param), { + refresh, + emitEvent, + updateUrl, + }); + } + + /** + * Returns a snapshot of selected filters. + * @returns {Object>} + */ + getSelectedFiltersSnapshot() { + const selectedFilters = {}; + Object.keys(this.filters).forEach((param) => { + selectedFilters[param] = Array.from(this.filters[param].selectedItems.keys()); + }); + return selectedFilters; + } + + /** + * Removes one filter tag from active filters UI. + * @param {string} key - Selected key. + */ + _removeFilterTag(key) { + const normalizedKey = slugify(key); + const filterElement = document.getElementById(`${normalizedKey}-tag`); + const closeButton = document.getElementById( + `remove-filter-${normalizedKey}`, + ); + if (closeButton) { + const tooltip = bootstrap.Tooltip.getInstance(closeButton); + if (tooltip) tooltip.dispose(); + } + if (filterElement) filterElement.remove(); } /** @@ -401,8 +540,8 @@ class FilterManager { /** * Updates the sessionStorage and URL with the selected filters. */ - update() { - const query = new URLSearchParams(); + update({ refresh = true, emitEvent = true, updateUrl = true } = {}) { + const query = new URLSearchParams(window.location.search); Object.keys(this.filters).forEach((param) => { const filter = this.filters[param]; @@ -415,10 +554,27 @@ class FilterManager { sessionStorage.removeItem(`filter-${param}`); } }); - const newUrl = `${window.location.pathname}?${query.toString()}`; - window.history.replaceState(null, "", newUrl); + query.delete("page"); + if (updateUrl) { + const queryString = query.toString(); + const newUrl = queryString + ? `${window.location.pathname}?${queryString}` + : window.location.pathname; + window.history.replaceState(null, "", newUrl); + } this._syncResetLink(); - refreshVideosSearch(); + if (emitEvent) { + document.dispatchEvent( + new CustomEvent("pod:dashboard-filters-updated", { + detail: { + selectedFilters: this.getSelectedFiltersSnapshot(), + }, + }), + ); + } + if (refresh) { + refreshVideosSearch(); + } } /** diff --git a/pod/video/static/js/filter.js b/pod/video/static/js/filter.js index 29dbf295be..628434f129 100644 --- a/pod/video/static/js/filter.js +++ b/pod/video/static/js/filter.js @@ -52,6 +52,7 @@ const filtersConfig = [ itemKey: (categories) => categories.value, }, ]; +globalThis.filtersConfig = filtersConfig; /** * Retrieves the list of video owners based on a search term. @@ -89,7 +90,25 @@ const filterManager = new FilterManager({ // Inject filter configuration into the manager filtersConfig.forEach((cfg) => filterManager.addFilter(cfg)); -filterManager.initializeFilters(); +// Wait for async filter initialization before applying URL params to avoid a race condition +filterManager.initializeFilters().then(() => { + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.has("categories")) { + filterManager.syncFilterSelectionFromUrl("categories", { + refresh: false, + emitEvent: false, + updateUrl: false, + }); + } +}); + +document.addEventListener("pod:aside-category-filter-updated", (event) => { + filterManager.syncFilterSelection("categories", event.detail?.categories || [], { + refresh: false, + emitEvent: false, + updateUrl: false, + }); +}); /** * Initializes filters by fetching data from the statistics API and preparing @@ -152,7 +171,7 @@ async function fetchSingleFilter(filterName, searchTerm = "") { // Map the received data to the { label, value } format expected by FilterManager return data[key].map((item) => ({ label: item.title || item.name || item.label || "unknown", - value: item.id || item.slug || item.value || "unknown", + value: item.slug || item.id || item.value || "unknown", })); } catch (error) { console.error( diff --git a/pod/video/static/js/filter_aside_video_list_refresh.js b/pod/video/static/js/filter_aside_video_list_refresh.js index 5c52d53dec..0b21f2dbe1 100644 --- a/pod/video/static/js/filter_aside_video_list_refresh.js +++ b/pod/video/static/js/filter_aside_video_list_refresh.js @@ -20,17 +20,25 @@ var infinite; let infiniteLoading = document.querySelector(".infinite-loading"); function onBeforePageLoad() { - infiniteLoading.style.display = "block"; + if (infiniteLoading) { + infiniteLoading.style.display = "block"; + } } function onAfterPageLoad() { if ( + typeof urlVideos !== "undefined" && urlVideos === "/video/dashboard/" && + typeof selectedVideos !== "undefined" && + typeof videosListContainerId !== "undefined" && + typeof setSelectedVideos === "function" && selectedVideos[videosListContainerId] && selectedVideos[videosListContainerId].length !== 0 ) { setSelectedVideos(videosListContainerId); } - infiniteLoading.style.display = "none"; + if (infiniteLoading) { + infiniteLoading.style.display = "none"; + } let footer = document.querySelector("footer.static-pod"); if (!footer) return; footer.classList.add("small"); @@ -43,12 +51,12 @@ function onAfterPageLoad() { document.scrollHeight, document.offsetHeight, ); - document.querySelector("footer.static-pod .hidden-pod").style.display = - "none"; + const hiddenFooter = document.querySelector("footer.static-pod .hidden-pod"); + if (!hiddenFooter) return; + hiddenFooter.style.display = "none"; window.addEventListener("scroll", function () { - if (window.innerHeight + window.scrollTop() === docHeight) { - document.querySelector("footer.static-pod .hidden-pod").style.display = - "block"; + if (window.innerHeight + window.scrollY >= docHeight) { + hiddenFooter.style.display = "block"; footer.setAttribute("style", "height:auto;"); footer.classList.remove("fixed-bottom"); } @@ -61,6 +69,7 @@ function onAfterPageLoad() { * @param nextPage */ function refreshInfiniteLoader(url, nextPage) { + if (typeof InfiniteLoader !== "function") return; if (infinite !== undefined) { infinite.removeLoader(); } @@ -84,8 +93,13 @@ function replaceCountVideos(newCount) { newCount, ); videoFoundStr = interpolate(videoFoundStr, { count: newCount }, true); - document.getElementById("video_count").textContent = videoFoundStr; - resetDashboardElements(); + const videoCount = document.getElementById("video_count"); + if (videoCount) { + videoCount.textContent = videoFoundStr; + } + if (typeof resetDashboardElements === "function") { + resetDashboardElements(); + } } /** @@ -110,7 +124,7 @@ function handleSearch(e) { refreshVideosSearch(); } -document.getElementById("searchForm").addEventListener("submit", handleSearch); +document.getElementById("searchForm")?.addEventListener("submit", handleSearch); document .getElementById("titleSearchBtn") @@ -167,7 +181,11 @@ function refreshVideosSearch() { refreshInfiniteLoader(url, pageNext); } if ( + typeof urlVideos !== "undefined" && urlVideos === "/video/dashboard/" && + typeof selectedVideos !== "undefined" && + typeof videosListContainerId !== "undefined" && + typeof setSelectedVideos === "function" && selectedVideos[videosListContainerId] && selectedVideos[videosListContainerId].length !== 0 ) { @@ -210,8 +228,11 @@ function getUrlForRefresh() { let urlParams = new URLSearchParams(window.location.search); // Normalize multi-value parameters + const safeFiltersConfig = Array.isArray(globalThis.filtersConfig) + ? globalThis.filtersConfig + : []; const multiFilters = [ - ...new Set(filtersConfig.map((filter) => filter.param)), + ...new Set(safeFiltersConfig.map((filter) => filter.param)), ]; urlParams = normalizeMultiValues(urlParams, multiFilters); @@ -274,17 +295,23 @@ function disabledInputs(value) { } // First launch of the infinite scroll -infinite = new InfiniteLoader( - getUrlForRefresh(), - onBeforePageLoad, - onAfterPageLoad, - nextPage, - (page = 2), -); +if (typeof InfiniteLoader === "function") { + infinite = new InfiniteLoader( + getUrlForRefresh(), + onBeforePageLoad, + onAfterPageLoad, + typeof nextPage !== "undefined" ? nextPage : true, + 2, + ); +} // Check and clean url to avoid owner parameter if not authorized var urlParams = new URLSearchParams(window.location.search); -if (urlParams.has("owner") && !ownerFilter) { +if ( + urlParams.has("owner") && + typeof ownerFilter !== "undefined" && + !ownerFilter +) { urlParams.delete("owner"); window.history.pushState( null, diff --git a/pod/video/static/js/video_category.js b/pod/video/static/js/video_category.js index a8bee5d462..f65ae73e97 100644 --- a/pod/video/static/js/video_category.js +++ b/pod/video/static/js/video_category.js @@ -45,11 +45,17 @@ function manageCategoriesLinks() { * Manage search category input in filter aside */ let searchCategoriesInput = document.getElementById("search-categories-input"); +const clearCategoryFilterButton = document.getElementById( + "clear-category-filter-btn", +); if (searchCategoriesInput) { searchCategoriesInput.addEventListener("input", () => { manageSearchCategories(searchCategoriesInput.value.trim()); }); } +if (clearCategoryFilterButton) { + clearCategoryFilterButton.addEventListener("click", clearCategoryFilter); +} /** * Manage search category input to display chosen ones @@ -80,10 +86,107 @@ function manageSearchCategories(search) { * @param {HTMLElement} el - Category link clicked */ function toggleCategoryLink(el) { - el.parentNode.classList.toggle("active"); + const categorySlug = el.dataset.slug; + // Keep category filter as a single selection from the aside list. + updateCategoriesQueryParam([categorySlug]); + syncCategoryLinksWithUrl(); + dispatchAsideCategoryFilterUpdated([ + { + value: categorySlug, + label: el.textContent.trim(), + }, + ]); refreshVideosSearch(); } +/** + * Clear selected category filter. + */ +function clearCategoryFilter() { + updateCategoriesQueryParam([]); + syncCategoryLinksWithUrl(); + dispatchAsideCategoryFilterUpdated([]); + refreshVideosSearch(); +} + +/** + * Notify dashboard filter manager that category selection changed from aside. + * + * @param {Array<{value: string, label: string}>} categories - Selected categories. + */ +function dispatchAsideCategoryFilterUpdated(categories) { + document.dispatchEvent( + new CustomEvent("pod:aside-category-filter-updated", { + detail: { categories }, + }), + ); +} + +/** + * Return selected category slugs from current URL query. + * Supports repeated params and comma-separated values. + * + * @returns {Array} - selected category slugs + */ +function getSelectedCategoriesFromUrl() { + const searchParams = new URLSearchParams(window.location.search); + return searchParams + .getAll("categories") + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter((value) => value !== ""); +} + +/** + * Update categories query parameter in browser URL. + * + * @param {Array} selectedCategories - categories to persist in URL + */ +function updateCategoriesQueryParam(selectedCategories) { + const currentParams = new URLSearchParams(window.location.search); + const orderedParams = new URLSearchParams(); + + selectedCategories.forEach((slug) => { + orderedParams.append("categories", slug); + }); + + currentParams.forEach((value, key) => { + if (key === "categories" || key === "page") return; + orderedParams.append(key, value); + }); + + const queryString = orderedParams.toString(); + const newUrl = queryString + ? `${window.location.pathname}?${queryString}` + : window.location.pathname; + window.history.replaceState({}, "", newUrl); +} + +/** + * Sync active category links in aside from current URL. + */ +function syncCategoryLinksWithUrl() { + const selectedCategories = new Set(getSelectedCategoriesFromUrl()); + + document.querySelectorAll(".categories-list-item").forEach((item) => { + const button = item.querySelector(".cat-title"); + if (!button) return; + + if (selectedCategories.has(button.dataset.slug)) { + item.classList.add("active"); + } else { + item.classList.remove("active"); + } + }); + + if (clearCategoryFilterButton) { + clearCategoryFilterButton.classList.toggle( + "d-none", + selectedCategories.size === 0, + ); + } +} + /** * Build and return url for Get or Post categories methods * @@ -295,6 +398,7 @@ function refreshCategoriesLinks() { let html = parser.parseFromString(data, "text/html").body; categoriesListContainer.innerHTML = html.innerHTML; manageCategoriesLinks(); + syncCategoryLinksWithUrl(); }) .catch(() => { showalert( @@ -305,5 +409,10 @@ function refreshCategoriesLinks() { }); } +document.addEventListener("pod:dashboard-filters-updated", () => { + syncCategoryLinksWithUrl(); +}); + // Add event listeners on categories list buttons for the first time manageCategoriesLinks(); +syncCategoryLinksWithUrl(); diff --git a/pod/video/templates/videos/dashboard.html b/pod/video/templates/videos/dashboard.html index d5bb049ce3..359177f972 100644 --- a/pod/video/templates/videos/dashboard.html +++ b/pod/video/templates/videos/dashboard.html @@ -82,8 +82,8 @@

    {% trans 'Multiple actions' %}

    {% if request.user.is_superuser %} - {% endif %} + {% if request.user.is_superuser %} diff --git a/pod/video/templates/videos/filter_aside_category.html b/pod/video/templates/videos/filter_aside_category.html index ad0069e4ce..ebf320998f 100644 --- a/pod/video/templates/videos/filter_aside_category.html +++ b/pod/video/templates/videos/filter_aside_category.html @@ -13,6 +13,11 @@ +
    + +
    - - -{% if video.is_draft == False or video.owner == request.user or request.user in video.additional_owners.all%} -