From 3590265836eb8abb359a4db52817d3dd9f61c485 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 21 Aug 2025 15:04:47 -0500 Subject: [PATCH] Show all available options for VODs and their corresponding quality. --- apps/proxy/vod_proxy/views.py | 55 +++++- apps/vod/serializers.py | 136 +++++++++++++++ frontend/src/pages/VODs.jsx | 309 +++++++++++++++++++++++++++------- 3 files changed, 430 insertions(+), 70 deletions(-) diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index ce13bebb..60472403 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -142,8 +142,10 @@ class VODStreamView(View): headers={'Location': redirect_url} ) - # Extract preferred M3U account ID from query parameters + # Extract preferred M3U account ID and stream ID from query parameters preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + if preferred_m3u_account_id: try: preferred_m3u_account_id = int(preferred_m3u_account_id) @@ -151,8 +153,11 @@ class VODStreamView(View): logger.warning(f"[VOD-PARAM] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") preferred_m3u_account_id = None + if preferred_stream_id: + logger.info(f"[VOD-PARAM] Preferred stream ID: {preferred_stream_id}") + # Get the content object and its relation - content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id) + content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id) if not content_obj or not relation: logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}") raise Http404(f"Content not found: {content_type} {content_id}") @@ -248,8 +253,10 @@ class VODStreamView(View): session_url = request.path logger.info(f"[VOD-HEAD] Using existing session: {session_id}") - # Extract preferred M3U account ID from query parameters + # Extract preferred M3U account ID and stream ID from query parameters preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + if preferred_m3u_account_id: try: preferred_m3u_account_id = int(preferred_m3u_account_id) @@ -257,8 +264,11 @@ class VODStreamView(View): logger.warning(f"[VOD-HEAD] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") preferred_m3u_account_id = None + if preferred_stream_id: + logger.info(f"[VOD-HEAD] Preferred stream ID: {preferred_stream_id}") + # Get content and relation (same as GET) - content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id) + content_obj, relation = self._get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id) if not content_obj or not relation: logger.error(f"[VOD-HEAD] Content or relation not found: {content_type} {content_id}") return HttpResponse("Content not found", status=404) @@ -354,19 +364,30 @@ class VODStreamView(View): logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) return HttpResponse(f"HEAD error: {str(e)}", status=500) - def _get_content_and_relation(self, content_type, content_id, preferred_m3u_account_id=None): + def _get_content_and_relation(self, content_type, content_id, preferred_m3u_account_id=None, preferred_stream_id=None): """Get the content object and its M3U relation""" try: logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}") if preferred_m3u_account_id: logger.info(f"[CONTENT-LOOKUP] Preferred M3U account ID: {preferred_m3u_account_id}") + if preferred_stream_id: + logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}") if content_type == 'movie': content_obj = get_object_or_404(Movie, uuid=content_id) logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})") - # Filter by preferred M3U account if specified + # Filter by preferred stream ID first (most specific) relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified if preferred_m3u_account_id: specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() if specific_relation: @@ -387,8 +408,17 @@ class VODStreamView(View): content_obj = get_object_or_404(Episode, uuid=content_id) logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})") - # Filter by preferred M3U account if specified + # Filter by preferred stream ID first (most specific) relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified if preferred_m3u_account_id: specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() if specific_relation: @@ -416,8 +446,17 @@ class VODStreamView(View): logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") - # Filter by preferred M3U account if specified + # Filter by preferred stream ID first (most specific) relations_query = episode.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified if preferred_m3u_account_id: specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() if specific_relation: diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index 00d9f328..c8063190 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -57,20 +57,156 @@ class M3UMovieRelationSerializer(serializers.ModelSerializer): movie = MovieSerializer(read_only=True) category = VODCategorySerializer(read_only=True) m3u_account = M3UAccountSerializer(read_only=True) + quality_info = serializers.SerializerMethodField() class Meta: model = M3UMovieRelation fields = '__all__' + def get_quality_info(self, obj): + """Extract quality information from various sources""" + quality_info = {} + + # 1. Check custom_properties first + if obj.custom_properties: + if obj.custom_properties.get('quality'): + quality_info['quality'] = obj.custom_properties['quality'] + return quality_info + elif obj.custom_properties.get('resolution'): + quality_info['resolution'] = obj.custom_properties['resolution'] + return quality_info + + # 2. Try to get detailed info from the movie if available + movie = obj.movie + if hasattr(movie, 'video') and movie.video: + video_data = movie.video + if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data: + width = video_data['width'] + height = video_data['height'] + quality_info['resolution'] = f"{width}x{height}" + + # Convert to common quality names (prioritize width for ultrawide/cinematic content) + if width >= 3840: + quality_info['quality'] = '4K' + elif width >= 1920: + quality_info['quality'] = '1080p' + elif width >= 1280: + quality_info['quality'] = '720p' + elif width >= 854: + quality_info['quality'] = '480p' + else: + quality_info['quality'] = f"{width}x{height}" + return quality_info + + # 3. Extract from movie name/title + if movie and movie.name: + name = movie.name + if '4K' in name or '2160p' in name: + quality_info['quality'] = '4K' + return quality_info + elif '1080p' in name or 'FHD' in name: + quality_info['quality'] = '1080p' + return quality_info + elif '720p' in name or 'HD' in name: + quality_info['quality'] = '720p' + return quality_info + elif '480p' in name: + quality_info['quality'] = '480p' + return quality_info + + # 4. Try bitrate as last resort + if hasattr(movie, 'bitrate') and movie.bitrate and movie.bitrate > 0: + bitrate = movie.bitrate + if bitrate >= 6000: + quality_info['quality'] = '4K' + elif bitrate >= 3000: + quality_info['quality'] = '1080p' + elif bitrate >= 1500: + quality_info['quality'] = '720p' + else: + quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps" + return quality_info + + # 5. Fallback - no quality info available + return None + class M3UEpisodeRelationSerializer(serializers.ModelSerializer): episode = EpisodeSerializer(read_only=True) m3u_account = M3UAccountSerializer(read_only=True) + quality_info = serializers.SerializerMethodField() class Meta: model = M3UEpisodeRelation fields = '__all__' + def get_quality_info(self, obj): + """Extract quality information from various sources""" + quality_info = {} + + # 1. Check custom_properties first + if obj.custom_properties: + if obj.custom_properties.get('quality'): + quality_info['quality'] = obj.custom_properties['quality'] + return quality_info + elif obj.custom_properties.get('resolution'): + quality_info['resolution'] = obj.custom_properties['resolution'] + return quality_info + + # 2. Try to get detailed info from the episode if available + episode = obj.episode + if hasattr(episode, 'video') and episode.video: + video_data = episode.video + if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data: + width = video_data['width'] + height = video_data['height'] + quality_info['resolution'] = f"{width}x{height}" + + # Convert to common quality names (prioritize width for ultrawide/cinematic content) + if width >= 3840: + quality_info['quality'] = '4K' + elif width >= 1920: + quality_info['quality'] = '1080p' + elif width >= 1280: + quality_info['quality'] = '720p' + elif width >= 854: + quality_info['quality'] = '480p' + else: + quality_info['quality'] = f"{width}x{height}" + return quality_info + + # 3. Extract from episode name/title + if episode and episode.name: + name = episode.name + if '4K' in name or '2160p' in name: + quality_info['quality'] = '4K' + return quality_info + elif '1080p' in name or 'FHD' in name: + quality_info['quality'] = '1080p' + return quality_info + elif '720p' in name or 'HD' in name: + quality_info['quality'] = '720p' + return quality_info + elif '480p' in name: + quality_info['quality'] = '480p' + return quality_info + + # 4. Try bitrate as last resort + if hasattr(episode, 'bitrate') and episode.bitrate and episode.bitrate > 0: + bitrate = episode.bitrate + if bitrate >= 6000: + quality_info['quality'] = '4K' + elif bitrate >= 3000: + quality_info['quality'] = '1080p' + elif bitrate >= 1500: + quality_info['quality'] = '720p' + else: + quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps" + return quality_info + + # 5. Fallback - no quality info available + return None + class EnhancedSeriesSerializer(serializers.ModelSerializer): """Enhanced serializer for series with provider information""" diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index 47751103..8bc1bbb8 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -37,6 +37,159 @@ const formatDuration = (seconds) => { const secs = seconds % 60; return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; }; + +const formatStreamLabel = (relation) => { + // Create a label for the stream that includes provider name and stream-specific info + const provider = relation.m3u_account.name; + const streamId = relation.stream_id; + + // Try to extract quality info - prioritizing the new quality_info field from backend + let qualityInfo = ''; + + // 1. Check the new quality_info field from backend (PRIMARY) + if (relation.quality_info) { + if (relation.quality_info.quality) { + qualityInfo = ` - ${relation.quality_info.quality}`; + } else if (relation.quality_info.resolution) { + qualityInfo = ` - ${relation.quality_info.resolution}`; + } else if (relation.quality_info.bitrate) { + qualityInfo = ` - ${relation.quality_info.bitrate}`; + } + } + + // 2. Fallback: Check custom_properties detailed info structure + if (qualityInfo === '' && relation.custom_properties) { + const props = relation.custom_properties; + + // Check detailed_info structure (where the real data is!) + if (qualityInfo === '' && props.detailed_info) { + const detailedInfo = props.detailed_info; + + // Extract from video resolution + if (detailedInfo.video && detailedInfo.video.width && detailedInfo.video.height) { + const width = detailedInfo.video.width; + const height = detailedInfo.video.height; + + // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) + if (width >= 3840) { + qualityInfo = ' - 4K'; + } else if (width >= 1920) { + qualityInfo = ' - 1080p'; + } else if (width >= 1280) { + qualityInfo = ' - 720p'; + } else if (width >= 854) { + qualityInfo = ' - 480p'; + } else { + qualityInfo = ` - ${width}x${height}`; + } + } + + // Extract from movie name in detailed_info + if (qualityInfo === '' && detailedInfo.name) { + const name = detailedInfo.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } + } + + // Extract from bitrate in detailed_info + if (qualityInfo === '' && detailedInfo.bitrate && detailedInfo.bitrate > 0) { + const bitrate = detailedInfo.bitrate; + if (bitrate >= 6000) { + qualityInfo = ' - 4K'; + } else if (bitrate >= 3000) { + qualityInfo = ' - 1080p'; + } else if (bitrate >= 1500) { + qualityInfo = ' - 720p'; + } else { + qualityInfo = ` - ${Math.round(bitrate / 1000)}Mbps`; + } + } + } + + // Check basic_data structure as another fallback + if (qualityInfo === '' && props.basic_data && props.basic_data.name) { + const name = props.basic_data.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } + } + } + + // 3. Final fallback: Try to extract from movie/episode name + if (qualityInfo === '') { + const content = relation.movie || relation.episode; + if (content && content.name) { + const name = content.name; + if (name.includes('4K') || name.includes('2160p')) { + qualityInfo = ' - 4K'; + } else if (name.includes('1080p') || name.includes('FHD')) { + qualityInfo = ' - 1080p'; + } else if (name.includes('720p') || name.includes('HD')) { + qualityInfo = ' - 720p'; + } else if (name.includes('480p')) { + qualityInfo = ' - 480p'; + } + } + } + + // If no quality info and multiple streams from same provider, show stream ID + const finalLabel = `${provider}${qualityInfo}${qualityInfo === '' && streamId ? ` - Stream ${streamId}` : ''}`; + return finalLabel; +}; + +// Helper function to get technical details from selected provider or fallback to default VOD +const getTechnicalDetails = (selectedProvider, defaultVOD) => { + let source = defaultVOD; // Default fallback + + // If a provider is selected, try to get technical details from various locations + if (selectedProvider) { + // 1. First try the movie/episode relation content + const content = selectedProvider.movie || selectedProvider.episode; + + if (content && (content.bitrate || content.video || content.audio)) { + source = content; + } + // 2. Try technical details directly on the relation object + else if (selectedProvider.bitrate || selectedProvider.video || selectedProvider.audio) { + source = selectedProvider; + } + // 3. Try to extract from custom_properties detailed_info (where quality data is stored) + else if (selectedProvider.custom_properties?.detailed_info) { + const detailedInfo = selectedProvider.custom_properties.detailed_info; + + // Create a synthetic source from detailed_info + const syntheticSource = { + bitrate: detailedInfo.bitrate || null, + video: detailedInfo.video || null, + audio: detailedInfo.audio || null + }; + + if (syntheticSource.bitrate || syntheticSource.video || syntheticSource.audio) { + source = syntheticSource; + } + } + } + + return { + bitrate: source?.bitrate, + video: source?.video, + audio: source?.audio + }; +}; + const VODCard = ({ vod, onClick }) => { const isEpisode = vod.type === 'episode'; @@ -256,7 +409,6 @@ const SeriesModal = ({ series, opened, onClose }) => { // Check if episodes were fetched if (!details.episodes_fetched) { // Episodes not yet fetched, may need to wait for background fetch - console.log('Episodes not yet fetched for series, may load incrementally'); } }) .catch((error) => { @@ -354,7 +506,12 @@ const SeriesModal = ({ series, opened, onClose }) => { // Add selected provider as query parameter if available if (selectedProvider) { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { + streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; + } else { + streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; + } } if (env_mode === 'dev') { @@ -574,7 +731,7 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* Provider Information */} - Provider Information + Stream Selection {loadingProviders && ( )} @@ -600,19 +757,24 @@ const SeriesModal = ({ series, opened, onClose }) => { {providers[0].m3u_account.account_type === 'XC' ? 'Xtream Codes' : 'Standard M3U'} )} + {providers[0].stream_id && ( + + Stream {providers[0].stream_id} + + )} ) : providers.length > 1 ? ( ({ value: provider.id.toString(), - label: `${provider.m3u_account.name} (${provider.m3u_account.account_type === 'XC' ? 'Xtream Codes' : 'Standard M3U'})` + label: formatStreamLabel(provider) }))} value={selectedProvider?.id?.toString() || ''} onChange={(value) => { const provider = providers.find(p => p.id.toString() === value); setSelectedProvider(provider); }} - placeholder="Select provider..." + placeholder="Select stream..." style={{ minWidth: 250 }} disabled={loadingProviders} /> @@ -1216,7 +1388,7 @@ const VODModal = ({ vod, opened, onClose }) => { {/* Fallback provider info if no providers loaded yet */} {providers.length === 0 && !loadingProviders && vod?.m3u_account && ( - IPTV Provider + Stream Selection {vod.m3u_account.name} @@ -1242,66 +1414,79 @@ const VODModal = ({ vod, opened, onClose }) => { Play Movie {selectedProvider && ( - (via {selectedProvider.m3u_account.name}) + ({selectedProvider.stream_id ? `Stream ${selectedProvider.stream_id}` : selectedProvider.m3u_account.name}) )} {/* Technical Details */} - {(displayVOD.bitrate || displayVOD.video || displayVOD.audio) && ( - - Technical Details: - {displayVOD.bitrate && displayVOD.bitrate > 0 && ( - - Bitrate: {displayVOD.bitrate} kbps + {(() => { + const techDetails = getTechnicalDetails(selectedProvider, displayVOD); + const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio; + + return hasDetails && ( + + + Technical Details: + {selectedProvider && ( + + (from {selectedProvider.m3u_account.name} + {selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`}) + + )} - )} - {displayVOD.video && Object.keys(displayVOD.video).length > 0 && ( - - Video:{' '} - {displayVOD.video.codec_long_name || displayVOD.video.codec_name} - {displayVOD.video.profile ? ` (${displayVOD.video.profile})` : ''} - {displayVOD.video.width && displayVOD.video.height - ? `, ${displayVOD.video.width}x${displayVOD.video.height}` - : ''} - {displayVOD.video.display_aspect_ratio - ? `, Aspect Ratio: ${displayVOD.video.display_aspect_ratio}` - : ''} - {displayVOD.video.bit_rate - ? `, Bitrate: ${Math.round(Number(displayVOD.video.bit_rate) / 1000)} kbps` - : ''} - {displayVOD.video.r_frame_rate - ? `, Frame Rate: ${displayVOD.video.r_frame_rate.replace('/', '/')} fps` - : ''} - {displayVOD.video.tags?.encoder - ? `, Encoder: ${displayVOD.video.tags.encoder}` - : ''} - - )} - {displayVOD.audio && Object.keys(displayVOD.audio).length > 0 && ( - - Audio:{' '} - {displayVOD.audio.codec_long_name || displayVOD.audio.codec_name} - {displayVOD.audio.profile ? ` (${displayVOD.audio.profile})` : ''} - {displayVOD.audio.channel_layout - ? `, Channels: ${displayVOD.audio.channel_layout}` - : displayVOD.audio.channels - ? `, Channels: ${displayVOD.audio.channels}` + {techDetails.bitrate && techDetails.bitrate > 0 && ( + + Bitrate: {techDetails.bitrate} kbps + + )} + {techDetails.video && Object.keys(techDetails.video).length > 0 && ( + + Video:{' '} + {techDetails.video.codec_long_name || techDetails.video.codec_name} + {techDetails.video.profile ? ` (${techDetails.video.profile})` : ''} + {techDetails.video.width && techDetails.video.height + ? `, ${techDetails.video.width}x${techDetails.video.height}` : ''} - {displayVOD.audio.sample_rate - ? `, Sample Rate: ${displayVOD.audio.sample_rate} Hz` - : ''} - {displayVOD.audio.bit_rate - ? `, Bitrate: ${Math.round(Number(displayVOD.audio.bit_rate) / 1000)} kbps` - : ''} - {displayVOD.audio.tags?.handler_name - ? `, Handler: ${displayVOD.audio.tags.handler_name}` - : ''} - - )} - - )} + {techDetails.video.display_aspect_ratio + ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}` + : ''} + {techDetails.video.bit_rate + ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps` + : ''} + {techDetails.video.r_frame_rate + ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps` + : ''} + {techDetails.video.tags?.encoder + ? `, Encoder: ${techDetails.video.tags.encoder}` + : ''} + + )} + {techDetails.audio && Object.keys(techDetails.audio).length > 0 && ( + + Audio:{' '} + {techDetails.audio.codec_long_name || techDetails.audio.codec_name} + {techDetails.audio.profile ? ` (${techDetails.audio.profile})` : ''} + {techDetails.audio.channel_layout + ? `, Channels: ${techDetails.audio.channel_layout}` + : techDetails.audio.channels + ? `, Channels: ${techDetails.audio.channels}` + : ''} + {techDetails.audio.sample_rate + ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz` + : ''} + {techDetails.audio.bit_rate + ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps` + : ''} + {techDetails.audio.tags?.handler_name + ? `, Handler: ${techDetails.audio.tags.handler_name}` + : ''} + + )} + + ); + })()} {/* YouTube trailer if available */}