import re from django.core.exceptions import ValidationError from django.db import models from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content VIDEO_SOURCE_YOUTUBE = "youtube" VIDEO_SOURCE_UPLOAD = "upload" VIDEO_SOURCE_CHOICES = [ (VIDEO_SOURCE_YOUTUBE, "YouTube / external link"), (VIDEO_SOURCE_UPLOAD, "Uploaded file"), ] VIDEO_SIZE_SMALL = "sm" VIDEO_SIZE_MEDIUM = "md" VIDEO_SIZE_LARGE = "lg" VIDEO_SIZE_FULL = "full" VIDEO_SIZE_CHOICES = [ (VIDEO_SIZE_SMALL, "Small (480px)"), (VIDEO_SIZE_MEDIUM, "Medium (720px)"), (VIDEO_SIZE_LARGE, "Large (960px)"), (VIDEO_SIZE_FULL, "Full width"), ] VIDEO_ASPECT_16_9 = "16/9" VIDEO_ASPECT_4_3 = "4/3" VIDEO_ASPECT_1_1 = "1/1" VIDEO_ASPECT_CHOICES = [ (VIDEO_ASPECT_16_9, "16:9 (widescreen)"), (VIDEO_ASPECT_4_3, "4:3 (standard)"), (VIDEO_ASPECT_1_1, "1:1 (square)"), ] VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"}) VIDEO_ADMIN_FIELDS = ( "video_source", "video_url", "video_file", "video_poster", "video_size", "video_aspect_ratio", "video_styled_background", ) VIDEO_ADMIN_FIELDSET = ( "Video", { "fields": VIDEO_ADMIN_FIELDS + ("video_preview",), "description": ( "Choose YouTube link or uploaded file. Set display size and aspect ratio " "to control how the preview appears on the site." ), }, ) YOUTUBE_ID_PATTERNS = ( re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"), re.compile(r"^([\w-]{11})$"), ) def parse_youtube_video_id(url): if not url: return "" value = url.strip() for pattern in YOUTUBE_ID_PATTERNS: match = pattern.search(value) if match: return match.group(1) return "" def youtube_embed_url(url): video_id = parse_youtube_video_id(url) if not video_id: return "" return f"https://www.youtube-nocookie.com/embed/{video_id}" class VideoBlockMixin(models.Model): video_source = models.CharField( max_length=20, choices=VIDEO_SOURCE_CHOICES, default=VIDEO_SOURCE_YOUTUBE, blank=True, ) video_url = models.CharField( max_length=500, blank=True, help_text="YouTube watch, embed, or youtu.be link.", ) video_file = models.FileField( upload_to="videos/", blank=True, help_text="MP4, WebM, OGG, or MOV file.", ) video_poster = models.ImageField( upload_to="videos/posters/", blank=True, null=True, help_text="Optional thumbnail shown before an uploaded video plays.", ) video_size = models.CharField( max_length=10, choices=VIDEO_SIZE_CHOICES, default=VIDEO_SIZE_MEDIUM, ) video_aspect_ratio = models.CharField( max_length=10, choices=VIDEO_ASPECT_CHOICES, default=VIDEO_ASPECT_16_9, ) video_styled_background = models.BooleanField( default=False, help_text="Glass panel with ambient glow (similar to the Downloads section).", ) description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN, ) class Meta: abstract = True @property def youtube_embed_url(self): return youtube_embed_url(self.video_url) @property def has_video(self): if self.video_source == VIDEO_SOURCE_UPLOAD: return bool(self.video_file) return bool(self.youtube_embed_url) @property def video_size_class(self): return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}" @property def video_aspect_class(self): ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-") return f"video-block--ratio-{ratio}" @property def rendered_description(self): description = getattr(self, "description", "") or "" return render_content(description, self.description_format) def clean_video_fields(self, require=False): if not require and not self.video_url and not self.video_file: return if self.video_source == VIDEO_SOURCE_YOUTUBE: if not self.video_url.strip(): raise ValidationError({"video_url": "Enter a YouTube link."}) if not self.youtube_embed_url: raise ValidationError({"video_url": "Enter a valid YouTube link."}) elif self.video_source == VIDEO_SOURCE_UPLOAD: if not self.video_file: raise ValidationError({"video_file": "Upload a video file."}) extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else "" if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS: raise ValidationError( { "video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.", } )