Browse Source

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
master
Mohsen Taba 3 weeks ago
parent
commit
a291290c42
  1. 7
      apps/hadis/serializers/reference_v2.py
  2. 9
      apps/podcast/serializers.py
  3. 10
      apps/podcast/views.py
  4. 39
      apps/video/models.py
  5. 7
      apps/video/serializers.py
  6. 1
      apps/video/serializers_admin.py
  7. 1
      apps/video/serializers_dovodi.py
  8. 14
      apps/video/views.py
  9. 116
      utils/youtube.py

7
apps/hadis/serializers/reference_v2.py

@ -102,6 +102,7 @@ class BookEditionV2Serializer(serializers.ModelSerializer):
publication_place = serializers.SerializerMethodField() publication_place = serializers.SerializerMethodField()
researchers = BookResearcherV2Serializer(many=True, read_only=True) researchers = BookResearcherV2Serializer(many=True, read_only=True)
editors = BookEditorV2Serializer(many=True, read_only=True) editors = BookEditorV2Serializer(many=True, read_only=True)
number_of_volumes = serializers.SerializerMethodField()
class Meta: class Meta:
model = BookEdition model = BookEdition
@ -111,6 +112,12 @@ class BookEditionV2Serializer(serializers.ModelSerializer):
'researchers', 'editors' '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): def get_publication_place(self, obj):
request = self.context.get("request") request = self.context.get("request")
city = get_localized_text(obj.city_of_publication, request) if obj.city_of_publication else "" city = get_localized_text(obj.city_of_publication, request) if obj.city_of_publication else ""

9
apps/podcast/serializers.py

@ -13,7 +13,7 @@ class PodcastCategoryListSerializer(serializers.ModelSerializer):
fields = ['id', 'title', 'slug', 'playlist_count'] fields = ['id', 'title', 'slug', 'playlist_count']
def get_playlist_count(self, obj): 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): class PodcastListSerializer(serializers.ModelSerializer):
@ -212,7 +212,7 @@ class PodcastPlaylistListSerializer(serializers.ModelSerializer):
def get_episodes_count(self, obj): def get_episodes_count(self, obj):
"""Return the number of episodes (podcasts) in this playlist""" """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): class PodcastPlaylistDetailSerializer(serializers.ModelSerializer):
@ -280,7 +280,8 @@ class PodcastPlaylistDetailSerializer(serializers.ModelSerializer):
def get_podcasts(self, obj): def get_podcasts(self, obj):
"""Get all podcasts in this playlist ordered by priority.""" """Get all podcasts in this playlist ordered by priority."""
podcasts = Podcast.objects.filter( podcasts = Podcast.objects.filter(
playlist_appearances__playlist=obj
playlist_appearances__playlist=obj,
status=True
).distinct().order_by('playlist_appearances__priority') ).distinct().order_by('playlist_appearances__priority')
return PodcastListSerializer( return PodcastListSerializer(
@ -300,7 +301,7 @@ class MiddlePodcastCollectionSerializer(serializers.ModelSerializer):
def get_playlists(self, obj): def get_playlists(self, obj):
playlists = obj.related_playlists.filter( playlists = obj.related_playlists.filter(
status=True, status=True,
playlist_items__isnull=False
playlist_items__podcast__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
return PodcastPlaylistListSerializer(playlists, many=True, context=self.context).data return PodcastPlaylistListSerializer(playlists, many=True, context=self.context).data

10
apps/podcast/views.py

@ -48,7 +48,7 @@ class PodcastCategoryListAPIView(generics.ListAPIView):
return PodcastCategory.objects.filter(status=True).prefetch_related( return PodcastCategory.objects.filter(status=True).prefetch_related(
Prefetch( Prefetch(
'playlists', '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') ).order_by('order')
@ -78,7 +78,7 @@ class PinnedPodcastCollectionListView(generics.ListAPIView):
status=True, status=True,
display_position=PodcastCollection.DisplayPosition.PINNED, display_position=PodcastCollection.DisplayPosition.PINNED,
related_playlists__status=True, related_playlists__status=True,
related_playlists__playlist_items__isnull=False
related_playlists__playlist_items__podcast__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
@ -130,7 +130,7 @@ class MiddlePodcastCollectionListView(generics.ListAPIView):
status=True, status=True,
display_position=PodcastCollection.DisplayPosition.MIDDLE, display_position=PodcastCollection.DisplayPosition.MIDDLE,
related_playlists__status=True, related_playlists__status=True,
related_playlists__playlist_items__isnull=False
related_playlists__playlist_items__podcast__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
@ -196,7 +196,7 @@ class PodcastListAPIView(generics.ListAPIView):
def get_queryset(self): def get_queryset(self):
queryset = PodcastPlaylist.objects.filter( queryset = PodcastPlaylist.objects.filter(
status=True, status=True,
playlist_items__isnull=False
playlist_items__podcast__status=True
).distinct() ).distinct()
# Search by title if search parameter is provided # Search by title if search parameter is provided
@ -272,7 +272,7 @@ class PodcastDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): 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): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()

39
apps/video/models.py

@ -147,16 +147,46 @@ class Video(LowercaseSlugMixin, models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None
file_changed = False file_changed = False
url_changed = False
if not is_new: if not is_new:
try: try:
old_instance = Video.objects.get(pk=self.pk) old_instance = Video.objects.get(pk=self.pk)
if old_instance.video_file != self.video_file: if old_instance.video_file != self.video_file:
file_changed = True file_changed = True
if old_instance.video_url != self.video_url:
url_changed = True
except Video.DoesNotExist: except Video.DoesNotExist:
pass pass
else: else:
file_changed = True 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: if not self.video_time:
self.video_time = datetime.time(0, 0, 0) self.video_time = datetime.time(0, 0, 0)
@ -179,6 +209,15 @@ class Video(LowercaseSlugMixin, models.Model):
except Exception as e: except Exception as e:
pass 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): class VideoPlaylist(LowercaseSlugMixin, models.Model):

7
apps/video/serializers.py

@ -13,7 +13,7 @@ class VideoCategoryListSerializer(serializers.ModelSerializer):
fields = ['id', 'title', 'slug', 'playlist_count'] fields = ['id', 'title', 'slug', 'playlist_count']
def get_playlist_count(self, obj): 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): class VideoListSerializer(serializers.ModelSerializer):
@ -157,7 +157,8 @@ class VideoPlaylistDetailSerializer(serializers.ModelSerializer):
def get_videos(self, obj): def get_videos(self, obj):
"""Get all videos in this playlist ordered by priority.""" """Get all videos in this playlist ordered by priority."""
videos = Video.objects.filter( videos = Video.objects.filter(
playlist_appearances__playlist=obj
playlist_appearances__playlist=obj,
status=True
).distinct().order_by('playlist_appearances__priority') ).distinct().order_by('playlist_appearances__priority')
return VideoListSerializer( return VideoListSerializer(
@ -324,7 +325,7 @@ class MiddleVideoCollectionSerializer(serializers.ModelSerializer):
def get_playlists(self, obj): def get_playlists(self, obj):
playlists = obj.related_playlists.filter( playlists = obj.related_playlists.filter(
status=True, status=True,
playlist_items__isnull=False
playlist_items__video__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
# Filter by bookmarks if requested in context # Filter by bookmarks if requested in context

1
apps/video/serializers_admin.py

@ -101,6 +101,7 @@ class AdminVideoListSerializer(serializers.ModelSerializer):
class AdminVideoDetailSerializer(serializers.ModelSerializer): class AdminVideoDetailSerializer(serializers.ModelSerializer):
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
video_file = AbsoluteFileField(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) slug = serializers.CharField(required=False, allow_blank=True, allow_null=True)
remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False)
remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False) remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False)

1
apps/video/serializers_dovodi.py

@ -47,6 +47,7 @@ class DovodiVideoItemSerializer(serializers.ModelSerializer):
slug = serializers.CharField(required=False, allow_blank=True) slug = serializers.CharField(required=False, allow_blank=True)
thumbnail = AbsoluteImageField(required=False, allow_null=True) thumbnail = AbsoluteImageField(required=False, allow_null=True)
video_file = AbsoluteFileField(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) stream_url = serializers.SerializerMethodField(read_only=True)
remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) remove_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False)
remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False) remove_video_file = serializers.BooleanField(write_only=True, required=False, default=False)

14
apps/video/views.py

@ -50,7 +50,7 @@ class VideoCategoryListAPIView(generics.ListAPIView):
return VideoCategory.objects.filter(status=True).prefetch_related( return VideoCategory.objects.filter(status=True).prefetch_related(
Prefetch( Prefetch(
'playlists', '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') ).order_by('order')
@ -89,7 +89,7 @@ class PinnedVideoCollectionListView(generics.ListAPIView):
status=True, status=True,
display_position=VideoCollection.DisplayPosition.PINNED, display_position=VideoCollection.DisplayPosition.PINNED,
related_playlists__status=True, related_playlists__status=True,
related_playlists__playlist_items__isnull=False
related_playlists__playlist_items__video__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
# Filter by bookmarks if requested # Filter by bookmarks if requested
@ -161,7 +161,7 @@ class MiddleVideoCollectionListView(generics.ListAPIView):
status=True, status=True,
display_position=VideoCollection.DisplayPosition.MIDDLE, display_position=VideoCollection.DisplayPosition.MIDDLE,
related_playlists__status=True, related_playlists__status=True,
related_playlists__playlist_items__isnull=False
related_playlists__playlist_items__video__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
@ -243,7 +243,7 @@ class VideoPlaylistListAPIView(generics.ListAPIView):
def get_queryset(self): def get_queryset(self):
queryset = VideoPlaylist.objects.filter( queryset = VideoPlaylist.objects.filter(
status=True, status=True,
playlist_items__isnull=False
playlist_items__video__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
# Search by title if search parameter is provided # Search by title if search parameter is provided
@ -355,7 +355,7 @@ class VideoListAPIView(generics.ListAPIView):
def get_queryset(self): def get_queryset(self):
queryset = VideoPlaylist.objects.filter( queryset = VideoPlaylist.objects.filter(
status=True, status=True,
playlist_items__isnull=False
playlist_items__video__status=True
).distinct().order_by('order', '-created_at') ).distinct().order_by('order', '-created_at')
# Search by title if search parameter is provided # Search by title if search parameter is provided
@ -425,7 +425,7 @@ class VideoPlaylistDetailAPIView(generics.RetrieveAPIView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): 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): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
@ -457,7 +457,7 @@ class VideoDetailAPIView(Gone410ViewMixin, CanonicalSlugViewSetMixin, generics.R
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): 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): def retrieve(self, request, *args, **kwargs):
instance = self.get_object() instance = self.get_object()

116
utils/youtube.py

@ -92,3 +92,119 @@ def clear_youtube_stream_cache(youtube_url: str) -> None:
if youtube_url: if youtube_url:
cache_key = _get_cache_key(youtube_url) cache_key = _get_cache_key(youtube_url)
cache.delete(cache_key) 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
Loading…
Cancel
Save