diff --git a/apps/video/models.py b/apps/video/models.py index 1d3e88b..2cffb60 100644 --- a/apps/video/models.py +++ b/apps/video/models.py @@ -107,6 +107,26 @@ class Video(LowercaseSlugMixin, models.Model): return f"{settings.DOVODI_DOMAIN}/videos/{self.slug}" return None + @property + def stream_url(self): + """ + Get direct playable stream URL for video. + For YouTube link types, extracts direct media URL via yt-dlp (cached). + For Video file types, returns video file URL. + """ + if self.video_type == self.VedioTypeChoices.YOUTUBE_LINK: + if self.video_url: + from utils.youtube import get_youtube_stream_url + extracted = get_youtube_stream_url(self.video_url) + return extracted or self.video_url + return None + elif self.video_type == self.VedioTypeChoices.VIDEO_FILE: + if self.video_file: + return self.video_file.url + return None + return self.video_url + + def increment_view_count(self): """Increment the view count for this video""" self.view_count += 1 diff --git a/apps/video/serializers.py b/apps/video/serializers.py index 16b305f..86f6ff0 100644 --- a/apps/video/serializers.py +++ b/apps/video/serializers.py @@ -19,6 +19,7 @@ class VideoCategoryListSerializer(serializers.ModelSerializer): class VideoListSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() video_file = serializers.SerializerMethodField() + stream_url = serializers.SerializerMethodField() share_link = serializers.CharField(read_only=True) video_time = serializers.SerializerMethodField() total_time_formatted = serializers.SerializerMethodField() @@ -26,7 +27,7 @@ class VideoListSerializer(serializers.ModelSerializer): class Meta: model = Video fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', - 'video_file', 'video_url', 'video_time', 'view_count', 'created_at', 'share_link', 'total_time_formatted'] + 'video_file', 'video_url', 'stream_url', 'video_time', 'view_count', 'created_at', 'share_link', 'total_time_formatted'] def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) @@ -43,10 +44,27 @@ class VideoListSerializer(serializers.ModelSerializer): return obj.video_file.url return None + def get_stream_url(self, obj): + if obj.video_type == Video.VedioTypeChoices.YOUTUBE_LINK: + if obj.video_url: + from utils.youtube import get_youtube_stream_url + extracted = get_youtube_stream_url(obj.video_url) + return extracted or obj.video_url + return None + elif obj.video_type == Video.VedioTypeChoices.VIDEO_FILE: + if obj.video_file: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.video_file.url) + return obj.video_file.url + return None + return obj.video_url + def get_video_time(self, obj): return format_media_time(obj.video_time) + class VideoPlaylistListSerializer(serializers.ModelSerializer): thumbnail = serializers.SerializerMethodField() video_time = serializers.SerializerMethodField() @@ -158,21 +176,39 @@ class VideoDetailSerializer(serializers.ModelSerializer): is_in_playlist = serializers.SerializerMethodField() playlist_videos = serializers.SerializerMethodField() share_link = serializers.CharField(read_only=True) + stream_url = serializers.SerializerMethodField() video_time = serializers.SerializerMethodField() total_time_formatted = serializers.SerializerMethodField() class Meta: model = Video fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', - 'video_file', 'video_url', 'video_time', 'view_count', + 'video_file', 'video_url', 'stream_url', 'video_time', 'view_count', 'categories', 'created_at', 'user_rate', 'average_rate', 'bookmark', 'is_in_playlist', 'playlist_videos', 'share_link', 'video_time', 'total_time_formatted'] + def get_stream_url(self, obj): + if obj.video_type == Video.VedioTypeChoices.YOUTUBE_LINK: + if obj.video_url: + from utils.youtube import get_youtube_stream_url + extracted = get_youtube_stream_url(obj.video_url) + return extracted or obj.video_url + return None + elif obj.video_type == Video.VedioTypeChoices.VIDEO_FILE: + if obj.video_file: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.video_file.url) + return obj.video_file.url + return None + return obj.video_url + def get_video_time(self, obj): return format_media_time(obj.video_time) def get_total_time_formatted(self, obj): - return format_media_time(obj.video_time) + return format_media_time(obj.video_time) + def get_thumbnail(self, obj): return get_thumbs(obj.thumbnail, self.context.get('request')) diff --git a/apps/video/serializers_dovodi.py b/apps/video/serializers_dovodi.py index f4f137d..eff73a3 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) + 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) playlist = serializers.IntegerField(required=False, allow_null=True, write_only=True) @@ -63,6 +64,7 @@ class DovodiVideoItemSerializer(serializers.ModelSerializer): "video_type", "video_file", "video_url", + "stream_url", "video_time", "status", "view_count", @@ -75,10 +77,27 @@ class DovodiVideoItemSerializer(serializers.ModelSerializer): ] read_only_fields = ["id", "view_count", "playlist_id", "created_at", "updated_at"] + def get_stream_url(self, obj): + if obj.video_type == Video.VedioTypeChoices.YOUTUBE_LINK: + if obj.video_url: + from utils.youtube import get_youtube_stream_url + extracted = get_youtube_stream_url(obj.video_url) + return extracted or obj.video_url + return None + elif obj.video_type == Video.VedioTypeChoices.VIDEO_FILE: + if obj.video_file: + request = self.context.get('request') + if request: + return request.build_absolute_uri(obj.video_file.url) + return obj.video_file.url + return None + return obj.video_url + def get_playlist_id(self, obj): appearance = obj.playlist_appearances.first() return appearance.playlist_id if appearance else None + def validate(self, attrs): video_type = attrs.get("video_type", getattr(self.instance, "video_type", None)) video_url = attrs.get("video_url", getattr(self.instance, "video_url", None)) diff --git a/apps/video/urls.py b/apps/video/urls.py index b1d286b..e3b3d80 100644 --- a/apps/video/urls.py +++ b/apps/video/urls.py @@ -33,4 +33,6 @@ urlpatterns = [ # Keep old video endpoints for backward compatibility if needed path('list/', VideoListAPIView.as_view(), name='video-list'), re_path(r'detail/(?P[\w-]+)/$', VideoDetailAPIView.as_view(), name='video-detail'), + path('resolve-youtube/', ResolveYouTubeStreamURLAPIView.as_view(), name='resolve-youtube'), ] + diff --git a/apps/video/views.py b/apps/video/views.py index 72d163f..8641286 100644 --- a/apps/video/views.py +++ b/apps/video/views.py @@ -500,3 +500,50 @@ class DovodiVideoItemViewSet(ModelViewSet): ) return queryset + +class ResolveYouTubeStreamURLAPIView(generics.GenericAPIView): + """ + API view to extract direct stream URL for a YouTube URL using yt-dlp. + """ + permission_classes = (IsAuthenticated,) + authentication_classes = [TokenAuthentication] + + @swagger_auto_schema( + operation_description="Extract direct playable stream URL from YouTube URL", + tags=["Dobodbi - Video"], + manual_parameters=[ + openapi.Parameter( + name='url', + in_=openapi.IN_QUERY, + description='YouTube video URL', + type=openapi.TYPE_STRING, + required=True + ), + ], + responses={ + 200: openapi.Response( + description="Resolved direct stream URL", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'youtube_url': openapi.Schema(type=openapi.TYPE_STRING), + 'stream_url': openapi.Schema(type=openapi.TYPE_STRING), + } + ) + ), + 400: "Bad Request" + } + ) + def get(self, request, *args, **kwargs): + youtube_url = request.query_params.get('url') + if not youtube_url: + return Response({'error': 'URL parameter is required.'}, status=status.HTTP_400_BAD_REQUEST) + + from utils.youtube import get_youtube_stream_url + stream_url = get_youtube_stream_url(youtube_url) + return Response({ + 'youtube_url': youtube_url, + 'stream_url': stream_url or youtube_url + }, status=status.HTTP_200_OK) + + diff --git a/requirements.txt b/requirements.txt index da25226..f971243 100644 --- a/requirements.txt +++ b/requirements.txt @@ -134,6 +134,8 @@ google-auth==2.6.0 pyjwt cryptography>=41.0.0 django-celery-beat==2.5.0 +yt-dlp>=2024.3.10 + https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip diff --git a/utils/youtube.py b/utils/youtube.py new file mode 100644 index 0000000..cf1b76b --- /dev/null +++ b/utils/youtube.py @@ -0,0 +1,94 @@ +import hashlib +import logging +import re +from typing import Optional +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +# Cache duration for YouTube stream URLs (3 hours = 10,800 seconds) +YOUTUBE_STREAM_CACHE_TTL = 3 * 3600 + + +def normalize_youtube_url(url: str) -> str: + """ + Extract YouTube 11-character video ID and return clean canonical URL. + Strips tracking parameters (like ?si=...) and converts short/embed links to standard watch URL. + """ + if not url: + return url + + match = re.search(r'(?:v=|\/|embed\/|v\/|^)([0-9A-Za-z_-]{11})(?:[\?&/]|$)', url) + if match: + video_id = match.group(1) + return f"https://www.youtube.com/watch?v={video_id}" + return url + + +def _get_cache_key(youtube_url: str) -> str: + url_hash = hashlib.md5(youtube_url.strip().encode('utf-8')).hexdigest() + return f"yt_stream_url_{url_hash}" + + +def get_youtube_stream_url(youtube_url: str) -> Optional[str]: + """ + Extract direct playable stream URL from a YouTube video URL using yt-dlp. + Caches the extracted URL in Django cache for 3 hours to minimize calls to YouTube. + + Returns direct stream URL string if successful, or None if extraction fails. + """ + if not youtube_url or not isinstance(youtube_url, str): + return None + + youtube_url = youtube_url.strip() + if not youtube_url: + return None + + cache_key = _get_cache_key(youtube_url) + cached_url = cache.get(cache_key) + if cached_url: + return cached_url + + clean_url = normalize_youtube_url(youtube_url) + + try: + import yt_dlp + + ydl_opts = { + 'format': 'best[ext=mp4]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', + '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 not info: + return None + + if 'entries' in info: + info = info['entries'][0] + + stream_url = info.get('url') + if stream_url: + cache.set(cache_key, stream_url, YOUTUBE_STREAM_CACHE_TTL) + return stream_url + except Exception as e: + logger.error(f"Failed to extract YouTube stream URL for '{youtube_url}': {e}") + + return None + + +def clear_youtube_stream_cache(youtube_url: str) -> None: + """ + Clear cached stream URL for a given YouTube URL. + """ + if youtube_url: + cache_key = _get_cache_key(youtube_url) + cache.delete(cache_key)