diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index f2f1d779..12b1ae2c 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -17,6 +17,15 @@ logger = logging.getLogger("vod_proxy") class VODConnectionManager: """Manages VOD connections using Redis for tracking""" + _instance = None + + @classmethod + def get_instance(cls): + """Get the singleton instance of VODConnectionManager""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + def __init__(self): self.redis_client = RedisClient.get_client() self.connection_ttl = 3600 # 1 hour TTL for connections @@ -295,6 +304,109 @@ class VODConnectionManager: except Exception as e: logger.error(f"Error during connection cleanup: {e}") + def stream_content(self, content_obj, stream_url, m3u_profile, client_ip, user_agent, request): + """ + Stream VOD content with connection tracking + + Args: + content_obj: Movie or Episode object + stream_url: Final stream URL to proxy + m3u_profile: M3UAccountProfile instance + client_ip: Client IP address + user_agent: Client user agent + request: Django request object + + Returns: + StreamingHttpResponse or HttpResponse with error + """ + import time + import random + import requests + from django.http import StreamingHttpResponse, HttpResponse + + try: + # Generate unique client ID + client_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + + # Determine content type and get content info + if hasattr(content_obj, 'episodes'): # Series + content_type = 'series' + elif hasattr(content_obj, 'series'): # Episode + content_type = 'episode' + else: # Movie + content_type = 'movie' + + content_uuid = str(content_obj.uuid) + content_name = getattr(content_obj, 'name', getattr(content_obj, 'title', 'Unknown')) + + # Create connection tracking + connection_created = self.create_connection( + content_type=content_type, + content_uuid=content_uuid, + content_name=content_name, + client_id=client_id, + client_ip=client_ip, + user_agent=user_agent, + m3u_profile=m3u_profile + ) + + if not connection_created: + logger.error(f"Failed to create connection tracking for {content_type} {content_uuid}") + return HttpResponse("Connection limit exceeded", status=503) + + # Create streaming generator + def stream_generator(): + try: + logger.info(f"[{client_id}] Starting VOD stream for {content_type} {content_name}") + + # Make request to actual stream URL + headers = {'User-Agent': user_agent} if user_agent else {} + + with requests.get(stream_url, headers=headers, stream=True, timeout=(10, 30)) as response: + response.raise_for_status() + + bytes_sent = 0 + chunk_count = 0 + + for chunk in response.iter_content(chunk_size=8192): + if chunk: + yield chunk + bytes_sent += len(chunk) + chunk_count += 1 + + # Update connection activity every 100 chunks + if chunk_count % 100 == 0: + self.update_connection_activity( + content_type=content_type, + content_uuid=content_uuid, + client_id=client_id, + bytes_sent=len(chunk) + ) + + logger.info(f"[{client_id}] VOD stream completed: {bytes_sent} bytes sent") + + except requests.RequestException as e: + logger.error(f"[{client_id}] Error streaming from source: {e}") + yield b"Error: Unable to stream content" + except Exception as e: + logger.error(f"[{client_id}] Error in stream generator: {e}") + finally: + # Clean up connection tracking + self.remove_connection(content_type, content_uuid, client_id) + + # Create streaming response + response = StreamingHttpResponse( + streaming_content=stream_generator(), + content_type='video/mp4' + ) + response['Cache-Control'] = 'no-cache' + response['Accept-Ranges'] = 'none' + + return response + + except Exception as e: + logger.error(f"Error in stream_content: {e}", exc_info=True) + return HttpResponse(f"Streaming error: {str(e)}", status=500) # Global instance _connection_manager = None diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py index c67a4e4f..1622b58e 100644 --- a/apps/proxy/vod_proxy/urls.py +++ b/apps/proxy/vod_proxy/urls.py @@ -5,7 +5,7 @@ app_name = 'vod_proxy' urlpatterns = [ # Generic VOD streaming (supports movies, episodes, series) - path('//', views.VODStreamView.as_view(), name='vod_stream'), + path('/', views.VODStreamView.as_view(), name='vod_stream'), path('///', views.VODStreamView.as_view(), name='vod_stream_with_profile'), # VOD playlist generation diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 4af3af5b..043c4c0c 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -41,19 +41,27 @@ class VODStreamView(View): client_ip, user_agent = get_client_info(request) logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...") - # Get the content object - content_obj = self._get_content_object(content_type, content_id) - if not content_obj: - logger.error(f"[VOD-ERROR] Content not found: {content_type} {content_id}") + # Get the content object and its relation + content_obj, relation = self._get_content_and_relation(content_type, content_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}") - logger.info(f"[VOD-CONTENT] Found content: {content_obj.title if hasattr(content_obj, 'title') else getattr(content_obj, 'name', 'Unknown')}") - logger.info(f"[VOD-CONTENT] Content URL: {getattr(content_obj, 'url', 'No URL found')}") + logger.info(f"[VOD-CONTENT] Found content: {getattr(content_obj, 'name', 'Unknown')}") - # Get M3U account and profile - m3u_account = content_obj.m3u_account + # Get M3U account from relation + m3u_account = relation.m3u_account logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}") + # Get stream URL from relation + stream_url = self._get_stream_url_from_relation(relation) + logger.info(f"[VOD-CONTENT] Content URL: {stream_url or 'No URL found'}") + + if not stream_url: + logger.error(f"[VOD-ERROR] No stream URL available for {content_type} {content_id}") + return HttpResponse("No stream URL available", status=503) + + # Get M3U profile m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent) if not m3u_profile: @@ -73,12 +81,12 @@ class VODStreamView(View): logger.error(f"Error tracking connection in Redis: {e}") # Transform URL based on profile - stream_url = self._transform_url(content_obj, m3u_profile) - logger.info(f"[VOD-URL] Final stream URL: {stream_url}") + final_stream_url = self._transform_url(stream_url, m3u_profile) + logger.info(f"[VOD-URL] Final stream URL: {final_stream_url}") # Validate stream URL - if not stream_url or not stream_url.startswith(('http://', 'https://')): - logger.error(f"[VOD-ERROR] Invalid stream URL: {stream_url}") + if not final_stream_url or not final_stream_url.startswith(('http://', 'https://')): + logger.error(f"[VOD-ERROR] Invalid stream URL: {final_stream_url}") return HttpResponse("Invalid stream URL", status=500) # Get connection manager @@ -88,7 +96,7 @@ class VODStreamView(View): logger.info("[VOD-STREAM] Calling connection manager to stream content") response = connection_manager.stream_content( content_obj=content_obj, - stream_url=stream_url, + stream_url=final_stream_url, m3u_profile=m3u_profile, client_ip=client_ip, user_agent=user_agent, @@ -102,18 +110,27 @@ class VODStreamView(View): logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True) return HttpResponse(f"Streaming error: {str(e)}", status=500) - def _get_content_object(self, content_type, content_id): - """Get the content object based on type and UUID""" + def _get_content_and_relation(self, content_type, content_id): + """Get the content object and its M3U relation""" try: logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}") + if content_type == 'movie': - obj = get_object_or_404(Movie, uuid=content_id) - logger.info(f"[CONTENT-FOUND] Movie: {obj.name} (ID: {obj.id})") - return obj + content_obj = get_object_or_404(Movie, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})") + + # Get the first active relation + relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first() + return content_obj, relation + elif content_type == 'episode': - obj = get_object_or_404(Episode, uuid=content_id) - logger.info(f"[CONTENT-FOUND] Episode: {obj.name} (ID: {obj.id}, Series: {obj.series.name})") - return obj + 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})") + + # Get the first active relation + relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first() + return content_obj, relation + elif content_type == 'series': # For series, get the first episode series = get_object_or_404(Series, uuid=content_id) @@ -121,37 +138,36 @@ class VODStreamView(View): episode = series.episodes.first() if not episode: logger.error(f"[CONTENT-ERROR] No episodes found for series {series.name}") - raise Http404("No episodes found for series") + return None, None + logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") - return episode + relation = episode.m3u_relations.filter(m3u_account__is_active=True).first() + return episode, relation else: logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}") - raise Http404(f"Invalid content type: {content_type}") + return None, None + except Exception as e: logger.error(f"Error getting content object: {e}") + return None, None + + def _get_stream_url_from_relation(self, relation): + """Get stream URL from the M3U relation""" + try: + if hasattr(relation, 'url') and relation.url: + return relation.url + elif hasattr(relation, 'get_stream_url'): + return relation.get_stream_url() + else: + logger.error("Relation has no URL or get_stream_url method") + return None + except Exception as e: + logger.error(f"Error getting stream URL from relation: {e}") return None - def _get_m3u_profile(self, content_obj, profile_id, user_agent): + def _get_m3u_profile(self, m3u_account, profile_id, user_agent): """Get appropriate M3U profile for streaming""" try: - # Get M3U account from content object's relations - m3u_account = None - - if hasattr(content_obj, 'm3u_relations'): - # This is a Movie or Episode with relations - relation = content_obj.m3u_relations.filter(m3u_account__is_active=True).first() - if relation: - m3u_account = relation.m3u_account - elif hasattr(content_obj, 'series'): - # This is an Episode, get relation through series - relation = content_obj.series.m3u_relations.filter(m3u_account__is_active=True).first() - if relation: - m3u_account = relation.m3u_account - - if not m3u_account: - logger.error("No M3U account found for content object") - return None - # If specific profile requested, try to use it if profile_id: try: @@ -195,33 +211,12 @@ class VODStreamView(View): except Exception: return True - def _transform_url(self, content_obj, m3u_profile): + def _transform_url(self, original_url, m3u_profile): """Transform URL based on M3U profile settings""" try: import re - # Get URL from the content object's relations - original_url = None - - if hasattr(content_obj, 'm3u_relations'): - # This is a Movie or Episode with relations - relation = content_obj.m3u_relations.filter( - m3u_account=m3u_profile.m3u_account - ).first() - if relation: - original_url = relation.url if hasattr(relation, 'url') else relation.get_stream_url() - elif hasattr(content_obj, 'series'): - # This is an Episode, get URL from episode relation - from apps.vod.models import M3UEpisodeRelation - relation = M3UEpisodeRelation.objects.filter( - episode=content_obj, - m3u_account=m3u_profile.m3u_account - ).first() - if relation: - original_url = relation.get_stream_url() - if not original_url: - logger.error("No URL found for content object") return None search_pattern = m3u_profile.search_pattern @@ -237,7 +232,7 @@ class VODStreamView(View): except Exception as e: logger.error(f"Error transforming URL: {e}") - return None + return original_url @method_decorator(csrf_exempt, name='dispatch') class VODPlaylistView(View): diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 5f299e8f..e4269e08 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -128,6 +128,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): # Build response with available data response_data = { 'id': movie.id, + 'uuid': movie.uuid, 'stream_id': relation.stream_id, 'name': info.get('name', movie.name), 'o_name': info.get('o_name', ''), @@ -149,7 +150,7 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): 'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []), 'cover': info.get('cover_big', ''), 'cover_big': info.get('cover_big', ''), - 'movie_image': movie.logo.url or info.get('movie_image', ''), + 'movie_image': movie.logo.url if movie.logo else info.get('movie_image', ''), 'bitrate': info.get('bitrate', 0), 'video': info.get('video', {}), 'audio': info.get('audio', {}),