From 81767f2587ab964681abac0e45628d572e5079d2 Mon Sep 17 00:00:00 2001 From: Loan Robert Date: Mon, 24 Jun 2024 16:57:17 +0200 Subject: [PATCH] Use ActivityPub models for external videos --- pod/activitypub/README.md | 2 +- pod/activitypub/__init__.py | 1 - pod/activitypub/admin.py | 33 ++++- pod/activitypub/apps.py | 5 +- pod/activitypub/context_processors.py | 12 ++ pod/activitypub/models.py | 76 ++++++++++- pod/activitypub/network.py | 4 +- pod/activitypub/serialization/video.py | 66 ++++++++-- pod/activitypub/signals.py | 124 +++++++++++++++++- pod/activitypub/tests/test_video_discovery.py | 6 +- pod/activitypub/urls.py | 4 + pod/activitypub/views.py | 63 ++++++--- pod/main/configuration.json | 14 ++ pod/main/static/img/default.png | Bin 0 -> 4398 bytes pod/playlist/models.py | 8 +- pod/playlist/utils.py | 10 +- pod/video/models.py | 115 +++++++++------- pod/video/templates/videos/video-info.html | 8 +- pod/video/templates/videos/video_aside.html | 2 +- .../templates/videos/video_opengraph.html | 15 ++- pod/video/urls.py | 6 + pod/video/views.py | 22 +++- 22 files changed, 485 insertions(+), 111 deletions(-) create mode 100644 pod/activitypub/context_processors.py create mode 100644 pod/main/static/img/default.png diff --git a/pod/activitypub/README.md b/pod/activitypub/README.md index 2d08326bd1..48cbcd2222 100644 --- a/pod/activitypub/README.md +++ b/pod/activitypub/README.md @@ -137,5 +137,5 @@ curl -H "Accept: application/activity+json, application/ld+json" -s "http://pod. ### Unit tests ```shell -python manage.py test --settings=pod.main.test_settings pod.activitypub.test_settings +python manage.py test --settings=pod.main.test_settings pod.activitypub.tests ``` diff --git a/pod/activitypub/__init__.py b/pod/activitypub/__init__.py index 8b13789179..e69de29bb2 100644 --- a/pod/activitypub/__init__.py +++ b/pod/activitypub/__init__.py @@ -1 +0,0 @@ - diff --git a/pod/activitypub/admin.py b/pod/activitypub/admin.py index 122d395009..e15843ee47 100644 --- a/pod/activitypub/admin.py +++ b/pod/activitypub/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from .models import Follower, Following +from .models import Follower, Following, ExternalVideo from .tasks import task_follow, task_index_videos @@ -27,4 +27,33 @@ def reindex_videos(modeladmin, request, queryset): @admin.register(Following) class FollowingAdmin(admin.ModelAdmin): actions = [send_federation_request, reindex_videos] - list_display = ("object", "status") + list_display = ( + "object", + "status", + ) + + +# TODO External video admin +@admin.register(ExternalVideo) +class ExternalVideoAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "source_instance", + "date_added", + "viewcount", + "duration_in_time", + "get_thumbnail_admin", + ) + list_display_links = ("id", "title") + list_filter = ( + "date_added", + ) + + search_fields = [ + "id", + "title", + "video", + "source_instance__object", + ] + list_per_page = 20 diff --git a/pod/activitypub/apps.py b/pod/activitypub/apps.py index 5a2fb8314f..f90d6ba56d 100644 --- a/pod/activitypub/apps.py +++ b/pod/activitypub/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, pre_save, post_save class ActivitypubConfig(AppConfig): @@ -9,7 +9,8 @@ class ActivitypubConfig(AppConfig): def ready(self): from pod.video.models import Video - from .signals import on_video_delete, on_video_save + from .signals import on_video_delete, on_video_save, on_video_pre_save + pre_save.connect(on_video_pre_save, sender=Video) post_save.connect(on_video_save, sender=Video) post_delete.connect(on_video_delete, sender=Video) diff --git a/pod/activitypub/context_processors.py b/pod/activitypub/context_processors.py new file mode 100644 index 0000000000..bd836cdc79 --- /dev/null +++ b/pod/activitypub/context_processors.py @@ -0,0 +1,12 @@ +from pod.activitypub.models import ExternalVideo + + +def get_available_external_videos_filter(request=None): + """Return the base filter to get the available external videos of the site.""" + + return ExternalVideo.objects.filter() + + +def get_available_external_videos(request=None): + """Get all external videos available.""" + return get_available_external_videos_filter(request).distinct() diff --git a/pod/activitypub/models.py b/pod/activitypub/models.py index 6dc333db9a..8d194dfeee 100644 --- a/pod/activitypub/models.py +++ b/pod/activitypub/models.py @@ -1,7 +1,12 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from django.template.defaultfilters import slugify +from django.utils.html import format_html +from django.urls import reverse -from pod.video.models import Video + +from pod.video.models import BaseVideo +from pod.main.models import get_nextautoincrement class Follower(models.Model): @@ -31,8 +36,11 @@ class Status(models.IntegerChoices): default=Status.NONE, ) + def __str__(self) -> str: + return self.object + -class ExternalVideo(Video): +class ExternalVideo(BaseVideo): source_instance = models.ForeignKey( Following, on_delete=models.CASCADE, @@ -43,4 +51,68 @@ class ExternalVideo(Video): _("Video identifier"), max_length=255, help_text=_("Video identifier URL"), + unique=True, + ) + video = models.CharField( + _("Video source"), + max_length=255, + help_text=_("Video source URL"), ) + thumbnail = models.CharField( + _("Thumbnails"), + max_length=255, + blank=True, + null=True, + ) + viewcount = models.IntegerField(_("Number of view"), default=0) + + def save(self, *args, **kwargs) -> None: + """Store an external video object in db.""" + newid = -1 + + # In case of creating new Video + if not self.id: + # previous_video_state = None + try: + newid = get_nextautoincrement(ExternalVideo) + except Exception: + try: + newid = ExternalVideo.objects.latest("id").id + newid += 1 + except Exception: + newid = 1 + else: + # previous_video_state = Video.objects.get(id=self.id) + newid = self.id + newid = "%04d" % newid + self.slug = "%s-%s" % (newid, slugify(self.title)) + self.is_external = True + super(ExternalVideo, self).save(*args, **kwargs) + + @property + def get_thumbnail_admin(self): + return format_html( + '%s' + % ( + self.thumbnail, + self.title, + ) + ) + + def get_thumbnail_card(self) -> str: + """Return thumbnail image card of current external video.""" + return ( + '%s' + % (self.thumbnail, self.title) + ) + + get_thumbnail_admin.fget.short_description = _("Thumbnails") + + def get_absolute_url(self) -> str: + """Get the external video absolute URL.""" + return reverse("activitypub:external_video", args=[str(self.slug)]) + + def get_marker_time_for_user(video, user): # TODO: Check usage + return 0 diff --git a/pod/activitypub/network.py b/pod/activitypub/network.py index 053d0995fb..dcddabfdbc 100644 --- a/pod/activitypub/network.py +++ b/pod/activitypub/network.py @@ -98,9 +98,7 @@ def index_video(following: Following, video_url): """Read a video payload and create an ExternalVideo object""" ap_video = ap_object(video_url) logger.warning(f"TODO: Deal with video indexation {ap_video}") - extvideo = ap_video_to_external_video(ap_video) - extvideo.source_instance = following - extvideo.save() + ap_video_to_external_video(payload=ap_video, source_instance=following) def external_video_added_by_actor(ap_video, ap_actor): diff --git a/pod/activitypub/serialization/video.py b/pod/activitypub/serialization/video.py index 095665bd0e..6db6accdbf 100644 --- a/pod/activitypub/serialization/video.py +++ b/pod/activitypub/serialization/video.py @@ -5,11 +5,56 @@ from pod.activitypub.constants import AP_LICENSE_MAPPING from pod.activitypub.models import ExternalVideo from pod.activitypub.utils import ap_url, make_magnet_url, stable_uuid +from pod.video.models import LANG_CHOICES +import logging +logger = logging.getLogger(__name__) -def ap_video_to_external_video(payload): + +def ap_video_to_external_video(payload, source_instance): """Create an ExternalVideo object from an AP Video payload.""" - return ExternalVideo.objects.create() + + video_source_links = [link for link in payload["url"] if "mediaType" in link and link["mediaType"] == "video/mp4"] + if not video_source_links: + tags = [] + for link in payload["url"]: + if "tag" in link: + tags.extend(link["tag"]) + video_source_links = [link for link in tags if "mediaType" in link and link["mediaType"] == "video/mp4"] + + external_video_attributes = { + "ap_id": payload["id"], + "video": video_source_links[0]["href"], + "title": payload["name"], + "date_added": payload["published"], + "thumbnail": [icon for icon in payload["icon"] if "thumbnails" in icon["url"]][0]["url"], + "duration": int(payload["duration"].lstrip("PT").rstrip("S")), + "viewcount": payload["views"], + "source_instance": source_instance, + } + + if ( + "language" in payload + and "identifier" in payload["language"] + and (identifier := payload["language"]["identifier"]) + and identifier in LANG_CHOICES + ): + external_video_attributes["main_lang"] = identifier + + if "content" in payload and (content := payload["content"]): + external_video_attributes["description"] = content + + external_video, created = ExternalVideo.objects.update_or_create( + ap_id=external_video_attributes["ap_id"], + defaults=external_video_attributes, + ) + + if created: + logger.info("ActivityPub external video %s created from %s instance", external_video, source_instance) + else: + logger.info("ActivityPub external video %s updated from %s instance", external_video, source_instance) + + return external_video def video_to_ap_video(video): @@ -125,7 +170,6 @@ def video_urls(video): magnets may become fully optional someday https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 """ - return { "url": ( [ @@ -141,7 +185,11 @@ def video_urls(video): { "type": "Link", "mediaType": mp4.encoding_format, - "href": ap_url(mp4.source_file.url), + # "href": ap_url(mp4.source_file.url), + "href": ap_url(reverse( + "video:video_mp4", + kwargs={"id": video.id, "mp4_id": mp4.id}, + )), "height": mp4.height, "width": mp4.width, "size": mp4.source_file.size, @@ -370,16 +418,16 @@ def video_icon(video): # only image/jpeg is supported on peertube # https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L192 """ - if not video.thumbnail: - return {} + # if not video.thumbnail: + # return {} return { "icon": [ { "type": "Image", - "url": video.get_thumbnail_url(scheme=True), - "width": video.thumbnail.file.width, - "height": video.thumbnail.file.height, + "url": video.get_thumbnail_url(scheme=True, is_activity_pub=True), + "width": video.thumbnail.file.width if video.thumbnail else 640, + "height": video.thumbnail.file.height if video.thumbnail else 360, # TODO: use the real media type when peertub supports JPEG # "mediaType": video.thumbnail.file_type, "mediaType": "image/jpeg", diff --git a/pod/activitypub/signals.py b/pod/activitypub/signals.py index 1a0df1dde2..d818772f89 100644 --- a/pod/activitypub/signals.py +++ b/pod/activitypub/signals.py @@ -1,5 +1,6 @@ """Signal callbacks.""" +import logging from django.db import transaction from .tasks import ( task_broadcast_local_video_creation, @@ -7,8 +8,99 @@ task_broadcast_local_video_update, ) +logger = logging.getLogger(__name__) -def on_video_save(instance, created, **kwargs): + +def is_new_and_visible(current_state, previous_state): + return ( + not previous_state + and current_state + and not current_state.is_draft + and current_state.encoded + and not current_state.encoding_in_progress + and not current_state.is_restricted + and not current_state.password + ) + + +def has_changed_to_visible(current_state, previous_state): + return ( + previous_state + and current_state + and ( + ( + previous_state.is_draft + and not current_state.is_draft + and current_state.encoded + and not current_state.encoding_in_progress + and not current_state.is_restricted + and not current_state.password + ) + or ( + previous_state.encoding_in_progress + and not current_state.is_draft + and current_state.encoded + and not current_state.encoding_in_progress + and not current_state.is_restricted + and not current_state.password + ) + or ( + previous_state.is_restricted + and not current_state.is_draft + and current_state.encoded + and not current_state.encoding_in_progress + and not current_state.is_restricted + and not current_state.password + ) + or ( + previous_state.password + and not current_state.is_draft + and current_state.encoded + and not current_state.encoding_in_progress + and not current_state.is_restricted + and not current_state.password + ) + ) + ) + + +def has_changed_to_invisible(current_state, previous_state): + return ( + previous_state + and current_state + and ( + (not previous_state.is_draft and current_state.is_draft) + or (not previous_state.is_restricted and current_state.is_restricted) + or (not previous_state.password and current_state.password) + ) + ) + + +def is_still_visible(current_state, previous_state): + return ( + previous_state + and current_state + and not previous_state.is_draft + and not current_state.is_draft + and previous_state.encoded + and not previous_state.encoding_in_progress + and current_state.encoded + and not current_state.encoding_in_progress + and not previous_state.is_restricted + and not current_state.is_restricted + and not previous_state.password + and not current_state.password + ) + + +def on_video_pre_save(instance, sender, **kwargs): + try: + instance._pre_save_instance = sender.objects.get(id=instance.id) + except sender.DoesNotExist: + instance._pre_save_instance = None + + +def on_video_save(instance, sender, **kwargs): """Celery tasks are triggered after commits and not just after .save() calls, so we are sure the database is really up to date at the moment we send data accross the network, and that any federated instance will be able to read the updated data. @@ -18,12 +110,38 @@ def on_video_save(instance, created, **kwargs): """ def trigger_save_task(): - if created: + previous_state = instance._pre_save_instance + + if is_new_and_visible( + current_state=instance, previous_state=previous_state + ) or has_changed_to_visible( + current_state=instance, previous_state=previous_state + ): + logger.info( + "Save publicly visible %s and broadcast a creation ActivityPub task", + instance, + ) task_broadcast_local_video_creation.delay(instance.id) - else: + elif has_changed_to_invisible( + current_state=instance, previous_state=previous_state + ): + logger.info( + "Save publicly invisible %s and broadcast a deletion ActivityPub task", + instance, + ) + task_broadcast_local_video_deletion.delay( + video_id=instance.id, owner_username=instance.owner.username + ) + elif is_still_visible(current_state=instance, previous_state=previous_state): + logger.info( + "Save publicly visible %s and broadcast an update ActivityPub task", + instance, + ) task_broadcast_local_video_update.delay(instance.id) + del instance._pre_save_instance + transaction.on_commit(trigger_save_task) diff --git a/pod/activitypub/tests/test_video_discovery.py b/pod/activitypub/tests/test_video_discovery.py index dd243f258f..3ac96bd2f3 100644 --- a/pod/activitypub/tests/test_video_discovery.py +++ b/pod/activitypub/tests/test_video_discovery.py @@ -1,4 +1,4 @@ -import json +# import json from . import ActivityPubTestCase @@ -7,8 +7,8 @@ class VideoDiscoveryTest(ActivityPubTestCase): def test_video_deserialization(self): """Test ExternalVideo creation from a AP Video.""" - with open("pod/activitypub/tests/fixtures/peertube_video.json") as fd: - payload = json.load(fd) + # with open("pod/activitypub/tests/fixtures/peertube_video.json") as fd: + # payload = json.load(fd) # video = ap_video_to_external_video(payload) diff --git a/pod/activitypub/urls.py b/pod/activitypub/urls.py index 35e57fbfc0..82246ae81e 100644 --- a/pod/activitypub/urls.py +++ b/pod/activitypub/urls.py @@ -31,3 +31,7 @@ path("ap/video//chapters", views.chapters, name="chapters"), path("ap/channel/", views.channel, name="channel"), ] + +urlpatterns += [ + path("external_video/", views.external_video, name="external_video"), +] diff --git a/pod/activitypub/views.py b/pod/activitypub/views.py index aeadb02391..d542716592 100644 --- a/pod/activitypub/views.py +++ b/pod/activitypub/views.py @@ -8,8 +8,11 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import render +from django.core.exceptions import SuspiciousOperation from pod.video.models import Channel, Video +from pod.activitypub.models import ExternalVideo from .constants import ( AP_DEFAULT_CONTEXT, @@ -38,6 +41,17 @@ AP_PAGE_SIZE = 25 +TYPE_TASK = { + "Follow": task_handle_inbox_follow, + "Accept": task_handle_inbox_accept, + "Reject": task_handle_inbox_reject, + "Announce": task_handle_inbox_announce, + "Update": task_handle_inbox_update, + "Delete": task_handle_inbox_delete, + "Undo": task_handle_inbox_undo, +} + + def nodeinfo(request): """ Nodeinfo endpoint. This is the entrypoint for ActivityPub federation. @@ -125,27 +139,8 @@ def inbox(request, username=None): logger.warning("inbox query: %s", json.dumps(data, indent=True)) # TODO: test HTTP signature - if not username and data["type"] == "Follow": - task_handle_inbox_follow.delay(username, data) - - elif not username and data["type"] == "Accept": - task_handle_inbox_accept.delay(username, data) - - elif not username and data["type"] == "Reject": - task_handle_inbox_reject.delay(username, data) - - elif not username and data["type"] == "Announce": - task_handle_inbox_announce.delay(username, data) - - elif not username and data["type"] == "Update": - task_handle_inbox_update.delay(username, data) - - elif not username and data["type"] == "Delete": - task_handle_inbox_delete.delay(username, data) - - elif not username and data["type"] == "Undo": - task_handle_inbox_undo.delay(username, data) - + if (activitypub_task := TYPE_TASK.get(data["type"], None)): + activitypub_task.delay(username, data) else: logger.debug("Ignoring inbox action: %s", data["type"]) @@ -416,3 +411,29 @@ def chapters(request, id): ], } return JsonResponse(response, status=200) + + +def render_external_video(request, id): + external_video = get_object_or_404(ExternalVideo, id=id) + return render( + request, + "videos/video.html", + { + "channel": None, + "video": external_video, + "theme": None, + "listNotes": None, + "owner_filter": False, + "playlist": None, + }, + ) + + +def external_video(request, slug): + try: + id = int(slug[: slug.find("-")]) + except ValueError: + raise SuspiciousOperation("Invalid external video id") + + get_object_or_404(ExternalVideo, id=id) + return render_external_video(request, id) diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 5d53cf2845..0b013602cc 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -2659,6 +2659,20 @@ "pod_version_end": "", "pod_version_init": "3.1.0" }, + "DEFAULT_AP_THUMBNAIL": { + "default_value": "img/default_ap.png", + "description": { + "en": [ + "" + ], + "fr": [ + "Image par défaut envoyée comme vignette, utilisée pour communiquer via ActivityPub", + "Cette image doit être au format png et se situer dans le répertoire static." + ] + }, + "pod_version_end": "", + "pod_version_init": "XXX" + }, "DEFAULT_TYPE_ID": { "default_value": 1, "description": { diff --git a/pod/main/static/img/default.png b/pod/main/static/img/default.png new file mode 100644 index 0000000000000000000000000000000000000000..a364a1e2df3e97730bb08863df120f15b5f00a4b GIT binary patch literal 4398 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 None: self.rank = last_rank + 1 if last_rank is not None else 1 except Exception: ... + if not self.video and not self.external_video: + raise ValidationError( + _("PlaylistContent needs a Video or an ExternalVideo to be created.") + ) return super().save(*args, **kwargs) def __str__(self) -> str: diff --git a/pod/playlist/utils.py b/pod/playlist/utils.py index 4c6d027022..037e8c1b08 100644 --- a/pod/playlist/utils.py +++ b/pod/playlist/utils.py @@ -1,5 +1,7 @@ """Esup-Pod playlist utilities.""" +from typing import Union + from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.db.models.functions import Lower @@ -8,6 +10,7 @@ from django.core.handlers.wsgi import WSGIRequest from pod.video.models import Video +from pod.activitypub.models import ExternalVideo from django.conf import settings from .apps import FAVORITE_PLAYLIST_NAME @@ -16,7 +19,7 @@ import hashlib -def check_video_in_playlist(playlist: Playlist, video: Video) -> bool: +def check_video_in_playlist(playlist: Playlist, video: Union[Video, ExternalVideo]) -> bool: """ Verify if a video is present in a playlist. @@ -27,7 +30,10 @@ def check_video_in_playlist(playlist: Playlist, video: Video) -> bool: Returns: bool: True if the video is on the playlist, False otherwise """ - return PlaylistContent.objects.filter(playlist=playlist, video=video).exists() + if isinstance(video, Video): + return PlaylistContent.objects.filter(playlist=playlist, video=video).exists() + elif isinstance(video, ExternalVideo): + return PlaylistContent.objects.filter(playlist=playlist, external_video=video).exists() def user_add_video_in_playlist(playlist: Playlist, video: Video) -> str: diff --git a/pod/video/models.py b/pod/video/models.py index cf6ffe7a93..cbba2faf62 100644 --- a/pod/video/models.py +++ b/pod/video/models.py @@ -47,6 +47,7 @@ from django.db.models.functions import Concat from os.path import splitext + USE_PODFILE = getattr(settings, "USE_PODFILE", False) if USE_PODFILE: from pod.podfile.models import CustomImageModel @@ -146,6 +147,7 @@ ), ) DEFAULT_THUMBNAIL = getattr(settings, "DEFAULT_THUMBNAIL", "img/default.svg") +DEFAULT_AP_THUMBNAIL = getattr(settings, "DEFAULT_AP_THUMBNAIL", "img/default.png") SECRET_KEY = getattr(settings, "SECRET_KEY", "") NOTES_STATUS = getattr( @@ -676,15 +678,9 @@ def default_site_discipline(sender, instance, **kwargs) -> None: instance.site = Site.objects.get_current() -class Video(models.Model): +class BaseVideo(models.Model): """Class describing video objects.""" - video = models.FileField( - _("Video"), - upload_to=get_storage_path_video, - max_length=255, - help_text=_("You can send an audio or video file."), - ) title = models.CharField( _("Title"), max_length=250, @@ -706,6 +702,67 @@ class Video(models.Model): editable=False, ) sites = models.ManyToManyField(Site) + description = RichTextField( + _("Description"), + config_name="complete", + blank=True, + help_text=_( + "In this field you can describe your content, " + + "add all needed related information, " + + "and format the result using the toolbar." + ), + ) + date_added = models.DateTimeField(_("Date added"), default=timezone.now) + date_evt = models.DateField( + _("Date of event"), default=date.today, blank=True, null=True + ) + main_lang = models.CharField( + _("Main language"), + max_length=2, + choices=LANG_CHOICES, + default=get_language(), + help_text=_("Select the main language used in the content."), + ) + tags = TagField( + help_text=_( + "Separate tags with spaces, " + "enclose the tags consist of several words in quotation marks." + ), + verbose_name=_("Tags"), + ) + licence = models.CharField( + _("Licence"), max_length=8, choices=LICENCE_CHOICES, blank=True, null=True + ) + duration = models.IntegerField(_("Duration"), default=0, editable=False, blank=True) + is_video = models.BooleanField(_("Is Video"), default=True, editable=False) + is_external = models.BooleanField(_("Is External Video"), default=False) + + class Meta: + abstract = True + + def __str__(self) -> str: + """Display a video object as string.""" + if self.id: + return "%s - %s" % ("%04d" % self.id, self.title) + else: + return "None" + + @property + def duration_in_time(self) -> str: + """Get the duration of a video.""" + return time.strftime("%H:%M:%S", time.gmtime(self.duration)) + + duration_in_time.fget.short_description = _("Duration") + + +class Video(BaseVideo): + """Class describing video objects.""" + video = models.FileField( + _("Video"), + upload_to=get_storage_path_video, + max_length=255, + help_text=_("You can send an audio or video file."), + ) type = models.ForeignKey( Type, verbose_name=_("Type"), @@ -724,20 +781,6 @@ class Video(models.Model): + "that they can’t delete this video." ), ) - description = RichTextField( - _("Description"), - config_name="complete", - blank=True, - help_text=_( - "In this field you can describe your content, " - + "add all needed related information, " - + "and format the result using the toolbar." - ), - ) - date_added = models.DateTimeField(_("Date added"), default=timezone.now) - date_evt = models.DateField( - _("Date of event"), default=date.today, blank=True, null=True - ) cursus = models.CharField( _("University course"), max_length=1, @@ -745,13 +788,6 @@ class Video(models.Model): default="0", help_text=_("Select an university course as audience target of the content."), ) - main_lang = models.CharField( - _("Main language"), - max_length=2, - choices=LANG_CHOICES, - default=get_language(), - help_text=_("Select the main language used in the content."), - ) transcript = models.CharField( _("Transcript"), max_length=2, @@ -759,19 +795,9 @@ class Video(models.Model): blank=True, help_text=_("Select an available language to transcribe the audio."), ) - tags = TagField( - help_text=_( - "Separate tags with spaces, " - "enclose the tags consist of several words in quotation marks." - ), - verbose_name=_("Tags"), - ) discipline = models.ManyToManyField( Discipline, blank=True, verbose_name=_("Disciplines") ) - licence = models.CharField( - _("Licence"), max_length=8, choices=LICENCE_CHOICES, blank=True, null=True - ) channel = models.ManyToManyField(Channel, verbose_name=_("Channels"), blank=True) theme = models.ManyToManyField( Theme, @@ -830,7 +856,6 @@ class Video(models.Model): verbose_name=_("Thumbnails"), related_name="videos", ) - duration = models.IntegerField(_("Duration"), default=0, editable=False, blank=True) overview = models.ImageField( _("Overview"), null=True, @@ -842,7 +867,6 @@ class Video(models.Model): encoding_in_progress = models.BooleanField( _("Encoding in progress"), default=False, editable=False ) - is_video = models.BooleanField(_("Is Video"), default=True, editable=False) date_delete = models.DateField(_("Date to delete"), default=default_date_delete) @@ -986,7 +1010,7 @@ def get_player_height(self) -> int: """ return 360 if self.is_video else 244 - def get_thumbnail_url(self, scheme=False) -> str: + def get_thumbnail_url(self, scheme=False, is_activity_pub=False) -> str: """Get a thumbnail url for the video.""" request = None if self.thumbnail and self.thumbnail.file_exist(): @@ -1002,7 +1026,7 @@ def get_thumbnail_url(self, scheme=False) -> str: [ "//", get_current_site(request).domain, - static(DEFAULT_THUMBNAIL), + static(DEFAULT_AP_THUMBNAIL if is_activity_pub else DEFAULT_THUMBNAIL), ] ) @@ -1051,13 +1075,6 @@ def get_thumbnail_card(self) -> str: % (thumbnail_url, self.title) ) - @property - def duration_in_time(self) -> str: - """Get the duration of a video.""" - return time.strftime("%H:%M:%S", time.gmtime(self.duration)) - - duration_in_time.fget.short_description = _("Duration") - @property def encoded(self) -> bool: """Get the encoded status of a video.""" diff --git a/pod/video/templates/videos/video-info.html b/pod/video/templates/videos/video-info.html index 1e7934ffcc..d965d71756 100644 --- a/pod/video/templates/videos/video-info.html +++ b/pod/video/templates/videos/video-info.html @@ -21,7 +21,7 @@ {{ video.get_viewcount }} {% endif %} - {% if USE_PLAYLIST %} + {% if USE_PLAYLIST and not video.is_external %}
{% trans 'Addition in a playlist' %} {% if USE_STATS_VIEW and not video.encoding_in_progress %} @@ -31,7 +31,7 @@ {% endif %}
{% endif %} - {% if USE_PLAYLIST and USE_FAVORITES %} + {% if USE_PLAYLIST and not video.is_external and USE_FAVORITES %} {% endif %} {% endif %} -{% if USE_QUIZ %} +{% if USE_QUIZ and not video.is_external %} {% is_quiz_accessible video as is_quiz_accessible %} {% if is_quiz_accessible or video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all %}
diff --git a/pod/video/templates/videos/video_opengraph.html b/pod/video/templates/videos/video_opengraph.html index 1e056aa8dd..371ca05e22 100644 --- a/pod/video/templates/videos/video_opengraph.html +++ b/pod/video/templates/videos/video_opengraph.html @@ -7,7 +7,6 @@ {% endif %} - @@ -18,23 +17,27 @@ - - - - {% if video.is_draft == True %} -{% endif %} \ No newline at end of file +{% endif %} + +{% if not video.is_external %} + + + + + +{% endif %} diff --git a/pod/video/urls.py b/pod/video/urls.py index ed1bb6d83b..48cdcad48c 100644 --- a/pod/video/urls.py +++ b/pod/video/urls.py @@ -35,6 +35,7 @@ vote_get, vote_post, video_edit_access_tokens, + video_mp4_filename, ) @@ -211,3 +212,8 @@ name="video_private", ), ] + +# MP4 FILENAME FOR ACTIVITYPUB PURPOSE +urlpatterns += [ + url(r"^mp4/(?P[\d]+)_(?P[\d]+)\.mp4$", video_mp4_filename, name="video_mp4"), +] diff --git a/pod/video/views.py b/pod/video/views.py index b477d42a9d..bc6d8971f5 100644 --- a/pod/video/views.py +++ b/pod/video/views.py @@ -91,6 +91,8 @@ from ..ai_enhancement.utils import enhancement_is_already_asked +from pod.activitypub.context_processors import get_available_external_videos + RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY = getattr( settings, "RESTRICT_EDIT_VIDEO_ACCESS_TO_STAFF_ONLY", False ) @@ -905,6 +907,15 @@ def get_filtered_videos_list(request, videos_list): return videos_list.distinct() +def get_filtered_external_videos_list(request, external_videos_list): + """Return filtered external videos list by get parameters.""" + if request.GET.getlist("source_instance"): + external_videos_list = external_videos_list.filter( + Q(source_instance__object__in=request.GET.getlist("source_instance")) + ) + return external_videos_list.distinct() + + def get_owners_has_instances(owners: list) -> list: """Return the list of owners who has instances in User.objects.""" ownersInstances = [] @@ -935,10 +946,11 @@ def owner_is_searchable(user: User) -> bool: def videos(request): """Render the main list of videos.""" videos_list = get_filtered_videos_list(request, get_available_videos()) + external_video_list = get_filtered_external_videos_list(request, get_available_external_videos()) sort_field = request.GET.get("sort") if request.GET.get("sort") else "date_added" sort_direction = request.GET.get("sort_direction") - videos_list = sort_videos_list(videos_list, sort_field, sort_direction) + videos_list = list(sort_videos_list(videos_list, sort_field, sort_direction)) + list(external_video_list) # TODO: Check performance if not sort_field: # Get the default Video ordering @@ -3406,6 +3418,14 @@ def get_theme_list_for_specific_channel(request: WSGIRequest, slug: str) -> Json return JsonResponse(json.loads(channel.get_all_theme_json()), safe=False) +def video_mp4_filename(request, mp4_id, id): + video = get_object_or_404(Video, id=id, sites=get_current_site(request)) + + mp4s = [v for v in video.get_video_mp4() if str(v.id) == mp4_id] + if len(mp4s) == 1: + return redirect(mp4s[0].source_file.url) + + """ # check access to video # change template to fix height and breadcrumbs