From a291290c420cdf287485cf725c04190951c6129d Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 1 Sep 2026 11:45:14 +0330 Subject: [PATCH] feat(media): auto-fetch YouTube metadata, filter empty playlists, and dynamic edition volume counts - auto-extract YouTube duration and thumbnail on Video save using yt-dlp - filter out empty playlists and playlists with inactive items in video and podcast public/app views - dynamically calculate number_of_volumes for BookEditionV2 based on assigned volumes --- apps/hadis/serializers/reference_v2.py | 7 ++ apps/podcast/serializers.py | 9 +- apps/podcast/views.py | 10 +-- apps/video/models.py | 39 +++++++++ apps/video/serializers.py | 7 +- apps/video/serializers_admin.py | 1 + apps/video/serializers_dovodi.py | 1 + apps/video/views.py | 14 +-- utils/youtube.py | 116 +++++++++++++++++++++++++ 9 files changed, 185 insertions(+), 19 deletions(-) diff --git a/apps/hadis/serializers/reference_v2.py b/apps/hadis/serializers/reference_v2.py index 01ec847..978f63f 100644 --- a/apps/hadis/serializers/reference_v2.py +++ b/apps/hadis/serializers/reference_v2.py @@ -102,6 +102,7 @@ class BookEditionV2Serializer(serializers.ModelSerializer): publication_place = serializers.SerializerMethodField() researchers = BookResearcherV2Serializer(many=True, read_only=True) editors = BookEditorV2Serializer(many=True, read_only=True) + number_of_volumes = serializers.SerializerMethodField() class Meta: model = BookEdition @@ -111,6 +112,12 @@ class BookEditionV2Serializer(serializers.ModelSerializer): 'researchers', 'editors' ] + def get_number_of_volumes(self, obj): + count = obj.volumes.count() + if count > 0: + return count + return obj.number_of_volumes or 0 + def get_publication_place(self, obj): request = self.context.get("request") city = get_localized_text(obj.city_of_publication, request) if obj.city_of_publication else "" diff --git a/apps/podcast/serializers.py b/apps/podcast/serializers.py index a9b9f6f..9642fa3 100755 --- a/apps/podcast/serializers.py +++ b/apps/podcast/serializers.py @@ -13,7 +13,7 @@ class PodcastCategoryListSerializer(serializers.ModelSerializer): fields = ['id', 'title', 'slug', 'playlist_count'] def get_playlist_count(self, obj): - return obj.playlists.filter(status=True, playlist_items__isnull=False).distinct().count() + return obj.playlists.filter(status=True, playlist_items__podcast__status=True).distinct().count() class PodcastListSerializer(serializers.ModelSerializer): @@ -212,7 +212,7 @@ class PodcastPlaylistListSerializer(serializers.ModelSerializer): def get_episodes_count(self, obj): """Return the number of episodes (podcasts) in this playlist""" - return obj.playlist_items.count() + return obj.playlist_items.filter(podcast__status=True).count() class PodcastPlaylistDetailSerializer(serializers.ModelSerializer): @@ -280,7 +280,8 @@ class PodcastPlaylistDetailSerializer(serializers.ModelSerializer): def get_podcasts(self, obj): """Get all podcasts in this playlist ordered by priority.""" podcasts = Podcast.objects.filter( - playlist_appearances__playlist=obj + playlist_appearances__playlist=obj, + status=True ).distinct().order_by('playlist_appearances__priority') return PodcastListSerializer( @@ -300,7 +301,7 @@ class MiddlePodcastCollectionSerializer(serializers.ModelSerializer): def get_playlists(self, obj): playlists = obj.related_playlists.filter( status=True, - playlist_items__isnull=False + playlist_items__podcast__status=True ).distinct().order_by('order', '-created_at') return PodcastPlaylistListSerializer(playlists, many=True, context=self.context).data diff --git a/apps/podcast/views.py b/apps/podcast/views.py index 8655381..aa87fcb 100644 --- a/apps/podcast/views.py +++ b/apps/podcast/views.py @@ -48,7 +48,7 @@ class PodcastCategoryListAPIView(generics.ListAPIView): return PodcastCategory.objects.filter(status=True).prefetch_related( Prefetch( 'playlists', - queryset=PodcastPlaylist.objects.filter(status=True, playlist_items__isnull=False).distinct() + queryset=PodcastPlaylist.objects.filter(status=True, playlist_items__podcast__status=True).distinct() ) ).order_by('order') @@ -78,7 +78,7 @@ class PinnedPodcastCollectionListView(generics.ListAPIView): status=True, display_position=PodcastCollection.DisplayPosition.PINNED, related_playlists__status=True, - related_playlists__playlist_items__isnull=False + related_playlists__playlist_items__podcast__status=True ).distinct().order_by('order', '-created_at') def list(self, request, *args, **kwargs): @@ -130,7 +130,7 @@ class MiddlePodcastCollectionListView(generics.ListAPIView): status=True, display_position=PodcastCollection.DisplayPosition.MIDDLE, related_playlists__status=True, - related_playlists__playlist_items__isnull=False + related_playlists__playlist_items__podcast__status=True ).distinct().order_by('order', '-created_at') @@ -196,7 +196,7 @@ class PodcastListAPIView(generics.ListAPIView): def get_queryset(self): queryset = PodcastPlaylist.objects.filter( status=True, - playlist_items__isnull=False + playlist_items__podcast__status=True ).distinct() # Search by title if search parameter is provided @@ -272,7 +272,7 @@ class PodcastDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics return super().get(request, *args, **kwargs) def get_queryset(self): - return PodcastPlaylist.objects.filter(status=True, playlist_items__isnull=False).distinct() + return PodcastPlaylist.objects.filter(status=True, playlist_items__podcast__status=True).distinct() def retrieve(self, request, *args, **kwargs): instance = self.get_object() diff --git a/apps/video/models.py b/apps/video/models.py index 0b3c179..aeb8766 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -147,16 +147,46 @@ class Video(LowercaseSlugMixin, models.Model): def save(self, *args, **kwargs): is_new = self.pk is None file_changed = False + url_changed = False if not is_new: try: old_instance = Video.objects.get(pk=self.pk) if old_instance.video_file != self.video_file: file_changed = True + if old_instance.video_url != self.video_url: + url_changed = True except Video.DoesNotExist: pass else: file_changed = True + url_changed = True + + # Handle YouTube link automatic metadata extraction (duration & thumbnail) + if self.video_type == self.VedioTypeChoices.YOUTUBE_LINK and self.video_url: + needs_thumbnail = not bool(self.thumbnail) + needs_time = not self.video_time or self.video_time == datetime.time(0, 0, 0) + + if needs_thumbnail or needs_time or url_changed: + try: + from utils.youtube import get_youtube_video_metadata, download_youtube_thumbnail + from django.core.files.base import ContentFile + + meta = get_youtube_video_metadata(self.video_url) + if meta: + # Auto set duration + if (needs_time or url_changed) and meta.get('duration_time'): + self.video_time = meta['duration_time'] + + # Auto set thumbnail if missing + if (needs_thumbnail or (url_changed and not self.thumbnail)) and meta.get('thumbnail_url'): + thumb_bytes = download_youtube_thumbnail(meta['thumbnail_url']) + if thumb_bytes: + vid_id = meta.get('video_id') or 'yt_thumb' + self.thumbnail.save(f"yt_{vid_id}.jpg", ContentFile(thumb_bytes), save=False) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to auto-populate YouTube video info: {e}") if not self.video_time: self.video_time = datetime.time(0, 0, 0) @@ -179,6 +209,15 @@ class Video(LowercaseSlugMixin, models.Model): except Exception as e: pass + if self.video_type == self.VedioTypeChoices.YOUTUBE_LINK: + try: + for appearance in self.playlist_appearances.all(): + playlist = appearance.playlist + playlist.total_time = playlist.calculate_total_time() + playlist.save(update_fields=['total_time']) + except Exception: + pass + class VideoPlaylist(LowercaseSlugMixin, models.Model): diff --git a/apps/video/serializers.py b/apps/video/serializers.py index 758d064..526fece 100644 --- a/apps/video/serializers.py +++ b/apps/video/serializers.py @@ -13,7 +13,7 @@ class VideoCategoryListSerializer(serializers.ModelSerializer): fields = ['id', 'title', 'slug', 'playlist_count'] def get_playlist_count(self, obj): - return obj.playlists.filter(status=True, playlist_items__isnull=False).distinct().count() + return obj.playlists.filter(status=True, playlist_items__video__status=True).distinct().count() class VideoListSerializer(serializers.ModelSerializer): @@ -157,7 +157,8 @@ class VideoPlaylistDetailSerializer(serializers.ModelSerializer): def get_videos(self, obj): """Get all videos in this playlist ordered by priority.""" videos = Video.objects.filter( - playlist_appearances__playlist=obj + playlist_appearances__playlist=obj, + status=True ).distinct().order_by('playlist_appearances__priority') return VideoListSerializer( @@ -324,7 +325,7 @@ class MiddleVideoCollectionSerializer(serializers.ModelSerializer): def get_playlists(self, obj): playlists = obj.related_playlists.filter( status=True, - playlist_items__isnull=False + playlist_items__video__status=True ).distinct().order_by('order', '-created_at') # Filter by bookmarks if requested in context diff --git a/apps/video/serializers_admin.py b/apps/video/serializers_admin.py index 140257e..23d87b0 100644 --- a/apps/video/serializers_admin.py +++ b/apps/video/serializers_admin.py @@ -101,6 +101,7 @@ class AdminVideoListSerializer(serializers.ModelSerializer): class AdminVideoDetailSerializer(serializers.ModelSerializer): thumbnail = AbsoluteImageField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True) + video_time = serializers.TimeField(required=False, allow_null=True) slug = serializers.CharField(required=False, allow_blank=True, allow_null=True) remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False) diff --git a/apps/video/serializers_dovodi.py b/apps/video/serializers_dovodi.py index 2af0a69..0c4b412 100644 --- a/apps/video/serializers_dovodi.py +++ b/apps/video/serializers_dovodi.py @@ -47,6 +47,7 @@ class DovodiVideoItemSerializer(serializers.ModelSerializer): slug = serializers.CharField(required=False, allow_blank=True) thumbnail = AbsoluteImageField(required=False, allow_null=True) video_file = AbsoluteFileField(required=False, allow_null=True) + video_time = serializers.TimeField(required=False, allow_null=True) stream_url = serializers.SerializerMethodField(read_only=True) remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False) diff --git a/apps/video/views.py b/apps/video/views.py index 7417bfe..fef3b10 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -50,7 +50,7 @@ class VideoCategoryListAPIView(generics.ListAPIView): return VideoCategory.objects.filter(status=True).prefetch_related( Prefetch( 'playlists', - queryset=VideoPlaylist.objects.filter(status=True, playlist_items__isnull=False).distinct() + queryset=VideoPlaylist.objects.filter(status=True, playlist_items__video__status=True).distinct() ) ).order_by('order') @@ -89,7 +89,7 @@ class PinnedVideoCollectionListView(generics.ListAPIView): status=True, display_position=VideoCollection.DisplayPosition.PINNED, related_playlists__status=True, - related_playlists__playlist_items__isnull=False + related_playlists__playlist_items__video__status=True ).distinct().order_by('order', '-created_at') # Filter by bookmarks if requested @@ -161,7 +161,7 @@ class MiddleVideoCollectionListView(generics.ListAPIView): status=True, display_position=VideoCollection.DisplayPosition.MIDDLE, related_playlists__status=True, - related_playlists__playlist_items__isnull=False + related_playlists__playlist_items__video__status=True ).distinct().order_by('order', '-created_at') @@ -243,7 +243,7 @@ class VideoPlaylistListAPIView(generics.ListAPIView): def get_queryset(self): queryset = VideoPlaylist.objects.filter( status=True, - playlist_items__isnull=False + playlist_items__video__status=True ).distinct().order_by('order', '-created_at') # Search by title if search parameter is provided @@ -355,7 +355,7 @@ class VideoListAPIView(generics.ListAPIView): def get_queryset(self): queryset = VideoPlaylist.objects.filter( status=True, - playlist_items__isnull=False + playlist_items__video__status=True ).distinct().order_by('order', '-created_at') # Search by title if search parameter is provided @@ -425,7 +425,7 @@ class VideoPlaylistDetailAPIView(generics.RetrieveAPIView): return super().get(request, *args, **kwargs) def get_queryset(self): - return VideoPlaylist.objects.filter(status=True, playlist_items__isnull=False).distinct() + return VideoPlaylist.objects.filter(status=True, playlist_items__video__status=True).distinct() def retrieve(self, request, *args, **kwargs): instance = self.get_object() @@ -457,7 +457,7 @@ class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.R return super().get(request, *args, **kwargs) def get_queryset(self): - return VideoPlaylist.objects.all() + return VideoPlaylist.objects.filter(status=True, playlist_items__video__status=True).distinct() def retrieve(self, request, *args, **kwargs): instance = self.get_object() diff --git a/utils/youtube.py b/utils/youtube.py index cf1b76b..9e63ee9 100644 --- a/utils/youtube.py +++ b/utils/youtube.py @@ -92,3 +92,119 @@ def clear_youtube_stream_cache(youtube_url: str) -> None: if youtube_url: cache_key = _get_cache_key(youtube_url) cache.delete(cache_key) + + +def seconds_to_time(seconds) -> Optional[object]: + """ + Convert seconds into datetime.time(hours, minutes, seconds). + """ + if seconds is None: + return None + try: + import datetime + total_seconds = int(seconds) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + secs = total_seconds % 60 + return datetime.time(min(hours, 23), minutes, secs) + except Exception: + return None + + +def get_youtube_video_metadata(youtube_url: str) -> Optional[dict]: + """ + Extract metadata (duration, thumbnail URL, title, etc.) from a YouTube video URL using yt-dlp. + Falls back to regex-based video ID and thumbnail URL if yt-dlp extraction encounters network/restriction issues. + """ + if not youtube_url or not isinstance(youtube_url, str): + return None + + clean_url = normalize_youtube_url(youtube_url.strip()) + match = re.search(r'(?:v=|\/|embed\/|v\/|^)([0-9A-Za-z_-]{11})(?:[\?&/]|$)', clean_url) + video_id = match.group(1) if match else None + + fallback_thumbnail = f"https://img.youtube.com/vi/{video_id}/maxresdefault.jpg" if video_id else None + + try: + import yt_dlp + + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + 'nocheckcertificate': True, + 'extractor_args': { + 'youtube': { + 'player_client': ['android', 'ios', 'web', 'mweb'], + } + } + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(clean_url, download=False) + if info: + if 'entries' in info: + info = info['entries'][0] + + duration_secs = info.get('duration') + thumbnail = info.get('thumbnail') or fallback_thumbnail + title = info.get('title') + + duration_time = seconds_to_time(duration_secs) if duration_secs is not None else None + + return { + 'video_id': video_id, + 'duration_seconds': duration_secs, + 'duration_time': duration_time, + 'thumbnail_url': thumbnail, + 'title': title, + } + except Exception as e: + logger.error(f"Failed to extract YouTube metadata for '{youtube_url}': {e}") + + if video_id: + return { + 'video_id': video_id, + 'duration_seconds': None, + 'duration_time': None, + 'thumbnail_url': fallback_thumbnail, + 'title': None, + } + + return None + + +def download_youtube_thumbnail(thumbnail_url: str) -> Optional[bytes]: + """ + Download image data from a thumbnail URL. + Attempts maxresdefault first, falls back to hqdefault on 404 or failure. + """ + if not thumbnail_url: + return None + + import urllib.request + + def _fetch_bytes(url: str) -> Optional[bytes]: + try: + req = urllib.request.Request( + url, + headers={'User-Agent': 'Mozilla/5.0'} + ) + with urllib.request.urlopen(req, timeout=10) as response: + if response.status == 200: + return response.read() + except Exception: + pass + return None + + data = _fetch_bytes(thumbnail_url) + if data: + return data + + if 'maxresdefault' in thumbnail_url: + hq_url = thumbnail_url.replace('maxresdefault', 'hqdefault') + data = _fetch_bytes(hq_url) + if data: + return data + + return None