diff --git a/apps/output/views.py b/apps/output/views.py index 51cd84d9..ba1bccef 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1003,7 +1003,7 @@ def xc_get_epg(request, user, short=False): def xc_get_vod_categories(user): """Get VOD categories for XtreamCodes API""" - from apps.vod.models import VODCategory + from apps.vod.models import VODCategory, M3UMovieRelation response = [] @@ -1021,13 +1021,16 @@ def xc_get_vod_categories(user): else: m3u_accounts = [] + # Get categories that have movie relations with these accounts categories = VODCategory.objects.filter( - m3u_account__in=m3u_accounts + category_type='movie', + m3umovierelation__m3u_account__in=m3u_accounts ).distinct() else: - # Admins can see all categories + # Admins can see all categories that have active movie relations categories = VODCategory.objects.filter( - m3u_account__is_active=True + category_type='movie', + m3umovierelation__m3u_account__is_active=True ).distinct() for category in categories: @@ -1042,7 +1045,7 @@ def xc_get_vod_categories(user): def xc_get_vod_streams(request, user, category_id=None): """Get VOD streams (movies) for XtreamCodes API""" - from apps.vod.models import Movie + from apps.vod.models import M3UMovieRelation streams = [] @@ -1065,14 +1068,18 @@ def xc_get_vod_streams(request, user, category_id=None): if category_id: filters["category_id"] = category_id - movies = Movie.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + # Get movie relations instead of movies directly + movie_relations = M3UMovieRelation.objects.filter(**filters).select_related( + 'movie', 'movie__logo', 'category', 'm3u_account' + ) - for movie in movies: + for relation in movie_relations: + movie = relation.movie streams.append({ - "num": movie.id, + "num": relation.id, # Use relation ID as num "name": movie.name, "stream_type": "movie", - "stream_id": movie.id, + "stream_id": relation.id, # Use relation ID "stream_icon": ( None if not movie.logo else request.build_absolute_uri( @@ -1081,12 +1088,12 @@ def xc_get_vod_streams(request, user, category_id=None): ), "rating": movie.rating or "0", "rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0, - "added": int(time.time()), # TODO: use actual created date + "added": int(relation.created_at.timestamp()), "is_adult": 0, - "category_id": str(movie.category.id) if movie.category else "0", - "container_extension": movie.container_extension or "mp4", + "category_id": str(relation.category.id) if relation.category else "0", + "container_extension": relation.container_extension or "mp4", "custom_sid": None, - "direct_source": movie.url, + "direct_source": relation.url, }) return streams @@ -1094,7 +1101,7 @@ def xc_get_vod_streams(request, user, category_id=None): def xc_get_series_categories(user): """Get series categories for XtreamCodes API""" - from apps.vod.models import VODCategory + from apps.vod.models import VODCategory, M3USeriesRelation response = [] @@ -1110,14 +1117,15 @@ def xc_get_series_categories(user): else: m3u_accounts = [] + # Get categories that have series relations with these accounts categories = VODCategory.objects.filter( - m3u_account__in=m3u_accounts, - series__isnull=False # Only categories that have series + category_type='series', + m3useriesrelation__m3u_account__in=m3u_accounts ).distinct() else: categories = VODCategory.objects.filter( - m3u_account__is_active=True, - series__isnull=False + category_type='series', + m3useriesrelation__m3u_account__is_active=True ).distinct() for category in categories: @@ -1132,7 +1140,7 @@ def xc_get_series_categories(user): def xc_get_series(request, user, category_id=None): """Get series list for XtreamCodes API""" - from apps.vod.models import Series + from apps.vod.models import M3USeriesRelation series_list = [] @@ -1154,31 +1162,35 @@ def xc_get_series(request, user, category_id=None): if category_id: filters["category_id"] = category_id - series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + # Get series relations instead of series directly + series_relations = M3USeriesRelation.objects.filter(**filters).select_related( + 'series', 'series__logo', 'category', 'm3u_account' + ) - for serie in series: + for relation in series_relations: + series = relation.series series_list.append({ - "num": serie.id, - "name": serie.name, - "series_id": serie.id, + "num": relation.id, # Use relation ID + "name": series.name, + "series_id": relation.id, # Use relation ID "cover": ( - None if not serie.logo + None if not series.logo else request.build_absolute_uri( - reverse("api:channels:logo-cache", args=[serie.logo.id]) + reverse("api:channels:logo-cache", args=[series.logo.id]) ) ), - "plot": serie.description or "", + "plot": series.description or "", "cast": "", "director": "", - "genre": serie.genre or "", - "release_date": str(serie.year) if serie.year else "", - "last_modified": int(time.time()), - "rating": serie.rating or "0", - "rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0, + "genre": series.genre or "", + "release_date": str(series.year) if series.year else "", + "last_modified": int(relation.updated_at.timestamp()), + "rating": series.rating or "0", + "rating_5based": float(series.rating or 0) / 2 if series.rating else 0, "backdrop_path": [], "youtube_trailer": "", "episode_run_time": "", - "category_id": str(serie.category.id) if serie.category else "0", + "category_id": str(relation.category.id) if relation.category else "0", }) return series_list @@ -1186,12 +1198,12 @@ def xc_get_series(request, user, category_id=None): def xc_get_series_info(request, user, series_id): """Get detailed series information including episodes""" - from apps.vod.models import Series, Episode + from apps.vod.models import M3USeriesRelation, M3UEpisodeRelation if not series_id: raise Http404() - # Get series with user access filtering + # Get series relation with user access filtering filters = {"id": series_id, "m3u_account__is_active": True} if user.user_level == 0: @@ -1207,33 +1219,36 @@ def xc_get_series_info(request, user, series_id): raise Http404() try: - serie = Series.objects.get(**filters) - except Series.DoesNotExist: + series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters) + series = series_relation.series + except M3USeriesRelation.DoesNotExist: raise Http404() - # Get episodes grouped by season - episodes = Episode.objects.filter( - series=serie - ).order_by('season_number', 'episode_number') + # Get episodes for this series from the same M3U account + episode_relations = M3UEpisodeRelation.objects.filter( + episode__series=series, + m3u_account=series_relation.m3u_account + ).select_related('episode').order_by('episode__season_number', 'episode__episode_number') # Group episodes by season seasons = {} - for episode in episodes: + for relation in episode_relations: + episode = relation.episode season_num = episode.season_number or 1 if season_num not in seasons: seasons[season_num] = [] seasons[season_num].append({ - "id": episode.stream_id, + "id": relation.stream_id, "episode_num": episode.episode_number or 0, "title": episode.name, - "container_extension": episode.container_extension or "mp4", + "container_extension": relation.container_extension or "mp4", "info": { - "air_date": f"{episode.year}-01-01" if episode.year else "", + "air_date": f"{episode.release_date}" if episode.release_date else "", "crew": "", "directed_by": "", "episode_num": episode.episode_number or 0, - "id": episode.stream_id, + "id": relation.stream_id, "imdb_id": episode.imdb_id or "", "name": episode.name, "overview": episode.description or "", @@ -1243,7 +1258,7 @@ def xc_get_series_info(request, user, series_id): "vote_average": float(episode.rating or 0), "vote_count": 0, "writer": "", - "release_date": f"{episode.year}-01-01" if episode.year else "", + "release_date": f"{episode.release_date}" if episode.release_date else "", "duration_secs": (episode.duration or 0) * 60, "duration": f"{episode.duration or 0} min", "video": {}, @@ -1256,25 +1271,25 @@ def xc_get_series_info(request, user, series_id): info = { "seasons": list(seasons.keys()), "info": { - "name": serie.name, + "name": series.name, "cover": ( - None if not serie.logo + None if not series.logo else request.build_absolute_uri( - reverse("api:channels:logo-cache", args=[serie.logo.id]) + reverse("api:channels:logo-cache", args=[series.logo.id]) ) ), - "plot": serie.description or "", + "plot": series.description or "", "cast": "", "director": "", - "genre": serie.genre or "", - "release_date": str(serie.year) if serie.year else "", - "last_modified": int(time.time()), - "rating": serie.rating or "0", - "rating_5based": float(serie.rating or 0) / 2 if serie.rating else 0, + "genre": series.genre or "", + "release_date": str(series.year) if series.year else "", + "last_modified": int(series_relation.updated_at.timestamp()), + "rating": series.rating or "0", + "rating_5based": float(series.rating or 0) / 2 if series.rating else 0, "backdrop_path": [], "youtube_trailer": "", "episode_run_time": "", - "category_id": str(serie.category.id) if serie.category else "0", + "category_id": str(series_relation.category.id) if series_relation.category else "0", }, "episodes": dict(seasons) } @@ -1284,12 +1299,12 @@ def xc_get_series_info(request, user, series_id): def xc_get_vod_info(request, user, vod_id): """Get detailed VOD (movie) information""" - from apps.vod.models import Movie + from apps.vod.models import M3UMovieRelation if not vod_id: raise Http404() - # Get Movie with user access filtering + # Get movie relation with user access filtering filters = {"id": vod_id, "m3u_account__is_active": True} if user.user_level == 0: @@ -1305,8 +1320,9 @@ def xc_get_vod_info(request, user, vod_id): raise Http404() try: - movie = Movie.objects.get(**filters) - except Movie.DoesNotExist: + movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').get(**filters) + movie = movie_relation.movie + except M3UMovieRelation.DoesNotExist: raise Http404() info = { @@ -1346,15 +1362,14 @@ def xc_get_vod_info(request, user, vod_id): "rating": float(movie.rating or 0), }, "movie_data": { - "stream_id": movie.id, + "stream_id": movie_relation.id, # Use relation ID "name": movie.name, - "added": int(time.time()), - "category_id": str(movie.category.id) if movie.category else "0", - "container_extension": movie.container_extension or "mp4", + "added": int(movie_relation.created_at.timestamp()), + "category_id": str(movie_relation.category.id) if movie_relation.category else "0", + "container_extension": movie_relation.container_extension or "mp4", "custom_sid": "", - "direct_source": movie.url, + "direct_source": movie_relation.url, } } return info - return info diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py index c867f19b..c67a4e4f 100644 --- a/apps/proxy/vod_proxy/urls.py +++ b/apps/proxy/vod_proxy/urls.py @@ -4,11 +4,14 @@ from . import views app_name = 'vod_proxy' urlpatterns = [ - # Movie streaming - path('movie/', views.stream_movie, name='stream_movie'), - path('movie//position', views.update_movie_position, name='update_movie_position'), + # Generic VOD streaming (supports movies, episodes, series) + path('//', views.VODStreamView.as_view(), name='vod_stream'), + path('///', views.VODStreamView.as_view(), name='vod_stream_with_profile'), - # Episode streaming - path('episode/', views.stream_episode, name='stream_episode'), - path('episode//position', views.update_episode_position, name='update_episode_position'), + # VOD playlist generation + path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'), + path('playlist//', views.VODPlaylistView.as_view(), name='vod_playlist_with_profile'), + + # Position tracking + path('position//', views.VODPositionView.as_view(), name='vod_position'), ] diff --git a/apps/proxy/vod_proxy/utils.py b/apps/proxy/vod_proxy/utils.py new file mode 100644 index 00000000..7ccf08b4 --- /dev/null +++ b/apps/proxy/vod_proxy/utils.py @@ -0,0 +1,58 @@ +""" +Utility functions for VOD proxy operations. +""" + +import logging +from django.http import HttpResponse + +logger = logging.getLogger(__name__) + + +def get_client_info(request): + """ + Extract client IP and User-Agent from request. + + Args: + request: Django HttpRequest object + + Returns: + tuple: (client_ip, user_agent) + """ + # Get client IP, checking for proxy headers + client_ip = request.META.get('HTTP_X_FORWARDED_FOR') + if client_ip: + # Take the first IP if there are multiple (comma-separated) + client_ip = client_ip.split(',')[0].strip() + else: + client_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR', 'unknown') + + # Get User-Agent + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + + return client_ip, user_agent + + +def create_vod_response(content, content_type='video/mp4', filename=None): + """ + Create a streaming HTTP response for VOD content. + + Args: + content: Content to stream (file-like object or bytes) + content_type: MIME type of the content + filename: Optional filename for Content-Disposition header + + Returns: + HttpResponse: Configured HTTP response for streaming + """ + response = HttpResponse(content, content_type=content_type) + + if filename: + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + # Add headers for streaming + response['Accept-Ranges'] = 'bytes' + response['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response['Pragma'] = 'no-cache' + response['Expires'] = '0' + + return response diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 21a16f24..9b2b1add 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -1,235 +1,334 @@ +""" +VOD (Video on Demand) proxy views for handling movie and series streaming. +Supports M3U profiles for authentication and URL transformation. +""" + import time import random import logging import requests -from django.http import StreamingHttpResponse, JsonResponse +from django.http import StreamingHttpResponse, JsonResponse, Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.views.decorators.csrf import csrf_exempt -from rest_framework.decorators import api_view - -from apps.vod.models import Movie, Episode -from dispatcharr.utils import network_access_allowed, get_client_ip -from core.models import UserAgent, CoreSettings -from .connection_manager import get_connection_manager +from django.utils.decorators import method_decorator +from django.views import View +from apps.vod.models import Movie, Series, Episode +from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.proxy.vod_proxy.connection_manager import VODConnectionManager +from .utils import get_client_info, create_vod_response logger = logging.getLogger(__name__) -@csrf_exempt -@api_view(["GET"]) -def stream_movie(request, movie_uuid): - """Stream movie content with connection tracking and range support""" - return _stream_content(request, Movie, movie_uuid, "movie") +@method_decorator(csrf_exempt, name='dispatch') +class VODStreamView(View): + """Handle VOD streaming requests with M3U profile support""" + def get(self, request, content_type, content_id, profile_id=None): + """ + Stream VOD content (movies or series episodes) -@csrf_exempt -@api_view(["GET"]) -def stream_episode(request, episode_uuid): - """Stream episode content with connection tracking and range support""" - return _stream_content(request, Episode, episode_uuid, "episode") + Args: + content_type: 'movie', 'series', or 'episode' + content_id: ID of the content + profile_id: Optional M3U profile ID for authentication + """ + logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, profile: {profile_id}") + logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}") + logger.info(f"[VOD-REQUEST] Request method: {request.method}") - -def _stream_content(request, model_class, content_uuid, content_type_name): - """Generic function to stream VOD content""" - - if not network_access_allowed(request, "STREAMS"): - return JsonResponse({"error": "Forbidden"}, status=403) - - # Get content object - content = get_object_or_404(model_class, uuid=content_uuid) - - # Generate client ID and get client info - client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" - client_ip = get_client_ip(request) - client_user_agent = request.META.get('HTTP_USER_AGENT', '') - - logger.info(f"[{client_id}] VOD stream request for: {content.name}") - - try: - # Get connection manager - connection_manager = get_connection_manager() - - # Get available M3U profile for connection management - m3u_account = content.m3u_account - available_profile = None - - for profile in m3u_account.profiles.filter(is_active=True): - # Use standardized connection counting method - current_connections = connection_manager.get_profile_connections(profile.id) - if profile.max_streams == 0 or current_connections < profile.max_streams: - available_profile = profile - break - - if not available_profile: - return JsonResponse( - {"error": "No available connections for this VOD"}, - status=503 - ) - - # Create connection tracking record in Redis - connection_created = connection_manager.create_connection( - content_type=content_type_name, - content_uuid=str(content_uuid), - content_name=content.name, - client_id=client_id, - client_ip=client_ip, - user_agent=client_user_agent, - m3u_profile=available_profile - ) - - if not connection_created: - return JsonResponse( - {"error": "Failed to create connection tracking"}, - status=503 - ) - - # Get user agent for upstream request try: - user_agent_obj = m3u_account.get_user_agent() - upstream_user_agent = user_agent_obj.user_agent - except: - default_ua_id = CoreSettings.get_default_user_agent_id() - user_agent_obj = UserAgent.objects.get(id=default_ua_id) - upstream_user_agent = user_agent_obj.user_agent + client_ip, user_agent = get_client_info(request) + logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {user_agent[:100]}...") - # Handle range requests for seeking - range_header = request.META.get('HTTP_RANGE') - headers = { - 'User-Agent': upstream_user_agent, - 'Connection': 'keep-alive' - } + # 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}") + raise Http404(f"Content not found: {content_type} {content_id}") - if range_header: - headers['Range'] = range_header - logger.debug(f"[{client_id}] Range request: {range_header}") + 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')}") - # Stream the VOD content - try: - response = requests.get( - content.url, - headers=headers, - stream=True, - timeout=(10, 60) + # Get M3U account and profile + m3u_account = content_obj.m3u_account + logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}") + + m3u_profile = self._get_m3u_profile(m3u_account, profile_id, user_agent) + + if not m3u_profile: + logger.error(f"[VOD-ERROR] No suitable M3U profile found for {content_type} {content_id}") + return HttpResponse("No available stream", status=503) + + logger.info(f"[VOD-PROFILE] Using M3U profile: {m3u_profile.id} (max_streams: {m3u_profile.max_streams}, current: {m3u_profile.current_viewers})") + + # Track connection start in Redis + try: + from core.utils import RedisClient + redis_client = RedisClient.get_client() + profile_connections_key = f"profile_connections:{m3u_profile.id}" + current_count = redis_client.incr(profile_connections_key) + logger.debug(f"Incremented VOD profile {m3u_profile.id} connections to {current_count}") + except Exception as e: + 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}") + + # Validate stream URL + if not stream_url or not stream_url.startswith(('http://', 'https://')): + logger.error(f"[VOD-ERROR] Invalid stream URL: {stream_url}") + return HttpResponse("Invalid stream URL", status=500) + + # Get connection manager + connection_manager = VODConnectionManager.get_instance() + + # Stream the content + logger.info("[VOD-STREAM] Calling connection manager to stream content") + response = connection_manager.stream_content( + content_obj=content_obj, + stream_url=stream_url, + m3u_profile=m3u_profile, + client_ip=client_ip, + user_agent=user_agent, + request=request ) - if response.status_code not in [200, 206]: - logger.error(f"[{client_id}] Upstream error: {response.status_code}") - connection_manager.remove_connection(content_type_name, str(content_uuid), client_id) - return JsonResponse( - {"error": f"Upstream server error: {response.status_code}"}, - status=response.status_code - ) + logger.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}") + return response - # Determine content type - content_type_header = response.headers.get('Content-Type', 'video/mp4') - content_length = response.headers.get('Content-Length') - content_range = response.headers.get('Content-Range') + except Exception as e: + logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True) + return HttpResponse(f"Streaming error: {str(e)}", status=500) - # Create streaming response - def stream_generator(): - bytes_sent = 0 + def _get_content_object(self, content_type, content_id): + """Get the content object based on type and UUID""" + 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 + 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 + elif content_type == 'series': + # For series, get the first episode + series = get_object_or_404(Series, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Series: {series.name} (ID: {series.id})") + 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") + logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") + return episode + else: + logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}") + raise Http404(f"Invalid content type: {content_type}") + except Exception as e: + logger.error(f"Error getting content object: {e}") + return None + + def _get_m3u_profile(self, content_obj, 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: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - bytes_sent += len(chunk) - yield chunk + profile = M3UAccountProfile.objects.get( + id=profile_id, + m3u_account=m3u_account, + is_active=True + ) + if profile.current_viewers < profile.max_streams or profile.max_streams == 0: + return profile + except M3UAccountProfile.DoesNotExist: + pass - # Update connection activity periodically - if bytes_sent % (8192 * 10) == 0: # Every ~80KB - connection_manager.update_connection_activity( - content_type_name, - str(content_uuid), - client_id, - bytes_sent=len(chunk) - ) + # Find available profile based on user agent matching + profiles = M3UAccountProfile.objects.filter( + m3u_account=m3u_account, + is_active=True + ).order_by('current_viewers') - except Exception as e: - logger.error(f"[{client_id}] Streaming error: {e}") - finally: - # Clean up connection when streaming ends - connection_manager.remove_connection(content_type_name, str(content_uuid), client_id) - logger.info(f"[{client_id}] Connection cleaned up") + for profile in profiles: + # Check if profile matches user agent pattern + if self._matches_user_agent_pattern(profile, user_agent): + if profile.current_viewers < profile.max_streams or profile.max_streams == 0: + return profile - # Build response with appropriate headers - streaming_response = StreamingHttpResponse( - stream_generator(), - content_type=content_type_header, - status=response.status_code - ) + # Fallback to default profile + return profiles.filter(is_default=True).first() - # Copy important headers - if content_length: - streaming_response['Content-Length'] = content_length - if content_range: - streaming_response['Content-Range'] = content_range + except Exception as e: + logger.error(f"Error getting M3U profile: {e}") + return None - # Add CORS and caching headers - streaming_response['Accept-Ranges'] = 'bytes' - streaming_response['Access-Control-Allow-Origin'] = '*' - streaming_response['Cache-Control'] = 'no-cache' + def _matches_user_agent_pattern(self, profile, user_agent): + """Check if user agent matches profile pattern""" + try: + import re + pattern = profile.search_pattern + if pattern and user_agent: + return bool(re.search(pattern, user_agent, re.IGNORECASE)) + return True # If no pattern, match all + except Exception: + return True - logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}") - return streaming_response + def _transform_url(self, content_obj, m3u_profile): + """Transform URL based on M3U profile settings""" + try: + import re - except requests.RequestException as e: - logger.error(f"[{client_id}] Request error: {e}") - connection_manager.remove_connection(content_type_name, str(content_uuid), client_id) - return JsonResponse( - {"error": "Failed to connect to upstream server"}, - status=502 - ) + # Get URL from the content object's relations + original_url = None - except Exception as e: - logger.error(f"[{client_id}] Unexpected error: {e}") - return JsonResponse( - {"error": "Internal server error"}, - status=500 - ) + 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 + elif hasattr(content_obj, 'series'): + # This is an Episode, get URL from episode relation + from .models import M3UEpisodeRelation + relation = M3UEpisodeRelation.objects.filter( + episode=content_obj, + m3u_account=m3u_profile.m3u_account + ).first() + if relation: + original_url = relation.url + + if not original_url: + logger.error("No URL found for content object") + return None + + search_pattern = m3u_profile.search_pattern + replace_pattern = m3u_profile.replace_pattern + safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern) + + if search_pattern and replace_pattern: + transformed_url = re.sub(search_pattern, safe_replace_pattern, original_url) + logger.debug(f"URL transformed from {original_url} to {transformed_url}") + return transformed_url + + return original_url + + except Exception as e: + logger.error(f"Error transforming URL: {e}") + return None + +@method_decorator(csrf_exempt, name='dispatch') +class VODPlaylistView(View): + """Generate M3U playlists for VOD content""" + + def get(self, request, profile_id=None): + """Generate VOD playlist""" + try: + # Get profile if specified + m3u_profile = None + if profile_id: + try: + m3u_profile = M3UAccountProfile.objects.get( + id=profile_id, + is_active=True + ) + except M3UAccountProfile.DoesNotExist: + return HttpResponse("Profile not found", status=404) + + # Generate playlist content + playlist_content = self._generate_playlist(m3u_profile) + + response = HttpResponse(playlist_content, content_type='application/vnd.apple.mpegurl') + response['Content-Disposition'] = 'attachment; filename="vod_playlist.m3u8"' + return response + + except Exception as e: + logger.error(f"Error generating VOD playlist: {e}") + return HttpResponse("Playlist generation error", status=500) + + def _generate_playlist(self, m3u_profile=None): + """Generate M3U playlist content for VOD""" + lines = ["#EXTM3U"] + + # Add movies + movies = Movie.objects.filter(is_active=True) + if m3u_profile: + movies = movies.filter(m3u_account=m3u_profile.m3u_account) + + for movie in movies: + profile_param = f"?profile={m3u_profile.id}" if m3u_profile else "" + lines.append(f'#EXTINF:-1 tvg-id="{movie.tmdb_id}" group-title="Movies",{movie.title}') + lines.append(f'/proxy/vod/movie/{movie.uuid}/{profile_param}') + + # Add series + series_list = Series.objects.filter(is_active=True) + if m3u_profile: + series_list = series_list.filter(m3u_account=m3u_profile.m3u_account) + + for series in series_list: + for episode in series.episodes.all(): + profile_param = f"?profile={m3u_profile.id}" if m3u_profile else "" + episode_title = f"{series.title} - S{episode.season_number:02d}E{episode.episode_number:02d}" + lines.append(f'#EXTINF:-1 tvg-id="{series.tmdb_id}" group-title="Series",{episode_title}') + lines.append(f'/proxy/vod/episode/{episode.uuid}/{profile_param}') + + return '\n'.join(lines) -@csrf_exempt -@api_view(["POST"]) -def update_movie_position(request, movie_uuid): - """Update playback position for a movie""" - return _update_position(request, Movie, movie_uuid, "movie") +@method_decorator(csrf_exempt, name='dispatch') +class VODPositionView(View): + """Handle VOD position updates""" + def post(self, request, content_id): + """Update playback position for VOD content""" + try: + import json + data = json.loads(request.body) + client_id = data.get('client_id') + position = data.get('position', 0) -@csrf_exempt -@api_view(["POST"]) -def update_episode_position(request, episode_uuid): - """Update playback position for an episode""" - return _update_position(request, Episode, episode_uuid, "episode") + # Find the content object + content_obj = None + try: + content_obj = Movie.objects.get(uuid=content_id) + except Movie.DoesNotExist: + try: + content_obj = Episode.objects.get(uuid=content_id) + except Episode.DoesNotExist: + return JsonResponse({'error': 'Content not found'}, status=404) + # Here you could store the position in a model or cache + # For now, just return success + logger.info(f"Position update for {content_obj.__class__.__name__} {content_id}: {position}s") -def _update_position(request, model_class, content_uuid, content_type_name): - """Generic function to update playback position""" + return JsonResponse({ + 'success': True, + 'content_id': str(content_id), + 'position': position + }) - if not network_access_allowed(request, "STREAMS"): - return JsonResponse({"error": "Forbidden"}, status=403) - - client_id = request.data.get('client_id') - position = request.data.get('position', 0) - - if not client_id: - return JsonResponse({"error": "Client ID required"}, status=400) - - try: - content = get_object_or_404(model_class, uuid=content_uuid) - connection_manager = get_connection_manager() - - # Update position in Redis - success = connection_manager.update_connection_activity( - content_type_name, - str(content_uuid), - client_id, - position_seconds=position - ) - - if not success: - return JsonResponse({"error": "Connection not found"}, status=404) - - return JsonResponse({"status": "success"}) - - except Exception as e: - logger.error(f"Position update error: {e}") - return JsonResponse({"error": "Internal server error"}, status=500) + except Exception as e: + logger.error(f"Error updating VOD position: {e}") + return JsonResponse({'error': str(e)}, status=500) diff --git a/apps/vod/admin.py b/apps/vod/admin.py index 310a342f..0fc8f28d 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -1,58 +1,67 @@ from django.contrib import admin -from .models import Series, VODCategory, VODConnection, Movie, Episode +from .models import ( + Series, VODCategory, Movie, Episode, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation +) @admin.register(VODCategory) class VODCategoryAdmin(admin.ModelAdmin): - list_display = ['name', 'category_type', 'm3u_account', 'created_at'] - list_filter = ['category_type', 'm3u_account', 'created_at'] + list_display = ['name', 'category_type', 'created_at'] + list_filter = ['category_type', 'created_at'] search_fields = ['name'] @admin.register(Series) class SeriesAdmin(admin.ModelAdmin): - list_display = ['name', 'year', 'genre', 'm3u_account', 'created_at'] - list_filter = ['m3u_account', 'category', 'year', 'created_at'] - search_fields = ['name', 'description', 'series_id'] + list_display = ['name', 'year', 'genre', 'created_at'] + list_filter = ['year', 'created_at'] + search_fields = ['name', 'description', 'tmdb_id', 'imdb_id'] readonly_fields = ['uuid', 'created_at', 'updated_at'] @admin.register(Movie) class MovieAdmin(admin.ModelAdmin): - list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at'] - list_filter = ['m3u_account', 'category', 'year', 'created_at'] - search_fields = ['name', 'description', 'stream_id'] + list_display = ['name', 'year', 'genre', 'duration', 'created_at'] + list_filter = ['year', 'created_at'] + search_fields = ['name', 'description', 'tmdb_id', 'imdb_id'] readonly_fields = ['uuid', 'created_at', 'updated_at'] def get_queryset(self, request): - return super().get_queryset(request).select_related('category', 'logo', 'm3u_account') + return super().get_queryset(request).select_related('logo') @admin.register(Episode) class EpisodeAdmin(admin.ModelAdmin): - list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at'] - list_filter = ['m3u_account', 'series', 'season_number', 'created_at'] - search_fields = ['name', 'description', 'stream_id', 'series__name'] + list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'created_at'] + list_filter = ['series', 'season_number', 'created_at'] + search_fields = ['name', 'description', 'series__name'] readonly_fields = ['uuid', 'created_at', 'updated_at'] def get_queryset(self, request): - return super().get_queryset(request).select_related('series', 'm3u_account') + return super().get_queryset(request).select_related('series') -@admin.register(VODConnection) -class VODConnectionAdmin(admin.ModelAdmin): - list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] - list_filter = ['connected_at', 'last_activity'] - search_fields = ['client_ip', 'client_id'] - readonly_fields = ['connected_at'] +@admin.register(M3UMovieRelation) +class M3UMovieRelationAdmin(admin.ModelAdmin): + list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at'] + list_filter = ['m3u_account', 'category', 'created_at'] + search_fields = ['movie__name', 'm3u_account__name', 'stream_id'] + readonly_fields = ['created_at', 'updated_at'] - def get_content_name(self, obj): - if obj.content_object: - return obj.content_object.name - elif obj.vod: - return obj.vod.name - return "Unknown" - get_content_name.short_description = "Content" - def get_queryset(self, request): - return super().get_queryset(request).select_related('content_object', 'm3u_profile') +@admin.register(M3USeriesRelation) +class M3USeriesRelationAdmin(admin.ModelAdmin): + list_display = ['series', 'm3u_account', 'category', 'external_series_id', 'created_at'] + list_filter = ['m3u_account', 'category', 'created_at'] + search_fields = ['series__name', 'm3u_account__name', 'external_series_id'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(M3UEpisodeRelation) +class M3UEpisodeRelationAdmin(admin.ModelAdmin): + list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_filter = ['m3u_account', 'created_at'] + search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] + readonly_fields = ['created_at', 'updated_at'] + diff --git a/apps/vod/api_urls.py b/apps/vod/api_urls.py index 43c590a3..b49e79e3 100644 --- a/apps/vod/api_urls.py +++ b/apps/vod/api_urls.py @@ -5,7 +5,6 @@ from .api_views import ( EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, - VODConnectionViewSet, ) app_name = 'vod' @@ -15,6 +14,5 @@ router.register(r'movies', MovieViewSet, basename='movie') router.register(r'episodes', EpisodeViewSet, basename='episode') router.register(r'series', SeriesViewSet, basename='series') router.register(r'categories', VODCategoryViewSet, basename='vodcategory') -router.register(r'connections', VODConnectionViewSet, basename='vodconnection') urlpatterns = router.urls diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 18aadb05..05cdc2fe 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -10,15 +10,19 @@ from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, ) -from .models import Series, VODCategory, VODConnection, Movie, Episode +from .models import ( + Series, VODCategory, Movie, Episode, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation +) from .serializers import ( MovieSerializer, EpisodeSerializer, SeriesSerializer, VODCategorySerializer, - VODConnectionSerializer + M3UMovieRelationSerializer, + M3USeriesRelationSerializer, + M3UEpisodeRelationSerializer ) -from core.xtream_codes import Client as XtreamCodesClient from .tasks import refresh_series_episodes from django.utils import timezone from datetime import timedelta @@ -28,15 +32,14 @@ logger = logging.getLogger(__name__) class MovieFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") - category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains") - m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") + m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") year = django_filters.NumberFilter() year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte") year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte") class Meta: model = Movie - fields = ['name', 'category', 'm3u_account', 'year'] + fields = ['name', 'm3u_account', 'year'] class MovieViewSet(viewsets.ReadOnlyModelViewSet): @@ -57,84 +60,133 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): return [Authenticated()] def get_queryset(self): - return Movie.objects.select_related( - 'category', 'logo', 'm3u_account' - ).filter(m3u_account__is_active=True) + # Only return movies that have active M3U relations + return Movie.objects.filter( + m3u_relations__m3u_account__is_active=True + ).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account') - def _extract_year(self, date_string): - """Extract year from date string""" - if not date_string: - return None - try: - return int(date_string.split('-')[0]) - except (ValueError, IndexError): - return None + @action(detail=True, methods=['get'], url_path='providers') + def get_providers(self, request, pk=None): + """Get all providers (M3U accounts) that have this movie""" + movie = self.get_object() + relations = M3UMovieRelation.objects.filter( + movie=movie, + m3u_account__is_active=True + ).select_related('m3u_account', 'category') - def _convert_duration_to_minutes(self, duration_secs): - """Convert duration from seconds to minutes""" - if not duration_secs: - return 0 - try: - return int(duration_secs) // 60 - except (ValueError, TypeError): - return 0 + serializer = M3UMovieRelationSerializer(relations, many=True) + return Response(serializer.data) @action(detail=True, methods=['get'], url_path='provider-info') def provider_info(self, request, pk=None): """Get detailed movie information from the original provider""" - logger.debug(f"MovieViewSet.provider_info called for movie ID: {pk}") movie = self.get_object() - logger.debug(f"Retrieved movie: {movie.name} (ID: {movie.id})") - if not movie.m3u_account: + # Get the first active relation + relation = M3UMovieRelation.objects.filter( + movie=movie, + m3u_account__is_active=True + ).select_related('m3u_account').first() + + if not relation: return Response( - {'error': 'No M3U account associated with this movie'}, + {'error': 'No active M3U account associated with this movie'}, status=status.HTTP_400_BAD_REQUEST ) + # Check if detailed data has been fetched + custom_props = relation.custom_properties or {} + detailed_fetched = custom_props.get('detailed_fetched', False) + + # If detailed data hasn't been fetched, fetch it now + if not detailed_fetched: + try: + from core.xtream_codes import Client as XtreamCodesClient + + with XtreamCodesClient( + server_url=relation.m3u_account.server_url, + username=relation.m3u_account.username, + password=relation.m3u_account.password, + user_agent=relation.m3u_account.get_user_agent().user_agent + ) as client: + # Get detailed VOD info from provider + vod_info = client.get_vod_info(relation.stream_id) + + if vod_info and 'info' in vod_info: + # Update movie with detailed info + info = vod_info.get('info', {}) + movie_data = vod_info.get('movie_data', {}) + + movie.description = info.get('plot', movie.description) + movie.rating = info.get('rating', movie.rating) + movie.genre = info.get('genre', movie.genre) + movie.duration = self._convert_duration_to_minutes(info.get('duration_secs')) + if info.get('releasedate'): + movie.year = self._extract_year(info.get('releasedate')) + movie.save() + + # Update relation with detailed data + custom_props['detailed_info'] = info + custom_props['movie_data'] = movie_data + custom_props['detailed_fetched'] = True + relation.custom_properties = custom_props + relation.save() + + except Exception as e: + logger.error(f"Error fetching detailed VOD info for movie {pk}: {str(e)}") + # Continue with available data + try: - # Create XtreamCodes client + from core.xtream_codes import Client as XtreamCodesClient + + # Create XtreamCodes client for final response (minimal call) with XtreamCodesClient( - server_url=movie.m3u_account.server_url, - username=movie.m3u_account.username, - password=movie.m3u_account.password, - user_agent=movie.m3u_account.user_agent + server_url=relation.m3u_account.server_url, + username=relation.m3u_account.username, + password=relation.m3u_account.password, + user_agent=relation.m3u_account.get_user_agent().user_agent ) as client: - # Get detailed VOD info from provider - logger.debug(f"Fetching VOD info for movie {movie.id} with stream ID {movie.stream_id} from provider") - vod_info = client.get_vod_info(movie.stream_id) - if not vod_info or 'info' not in vod_info: - return Response( - {'error': 'No information available from provider'}, - status=status.HTTP_404_NOT_FOUND - ) + # Use cached detailed data if available + custom_props = relation.custom_properties or {} + info = custom_props.get('detailed_info', {}) + movie_data = custom_props.get('movie_data', {}) - # Extract and format the info - info = vod_info.get('info', {}) - movie_data = vod_info.get('movie_data', {}) + # If no cached data, use basic data + if not info: + basic_data = custom_props.get('basic_data', {}) + info = { + 'name': movie.name, + 'plot': movie.description, + 'rating': movie.rating, + 'genre': movie.genre, + } + movie_data = { + 'container_extension': basic_data.get('container_extension', 'mp4'), + 'added': basic_data.get('added', ''), + } - # Build response with all available fields + # Build response with available data response_data = { 'id': movie.id, - 'stream_id': movie.stream_id, + 'stream_id': relation.stream_id, 'name': info.get('name', movie.name), 'o_name': info.get('o_name', ''), - 'description': info.get('description', info.get('plot', '')), - 'plot': info.get('plot', info.get('description', '')), - 'year': self._extract_year(info.get('releasedate', '')), + 'description': info.get('description', info.get('plot', movie.description)), + 'plot': info.get('plot', info.get('description', movie.description)), + 'year': movie.year or self._extract_year(info.get('releasedate', '')), 'release_date': info.get('release_date', ''), 'releasedate': info.get('releasedate', ''), - 'genre': info.get('genre', ''), + 'genre': info.get('genre', movie.genre), 'director': info.get('director', ''), 'actors': info.get('actors', info.get('cast', '')), 'cast': info.get('cast', info.get('actors', '')), 'country': info.get('country', ''), - 'rating': info.get('rating', 0), - 'tmdb_id': info.get('tmdb_id', ''), + 'rating': info.get('rating', movie.rating or 0), + 'tmdb_id': info.get('tmdb_id', movie.tmdb_id or ''), 'youtube_trailer': info.get('youtube_trailer', ''), - 'duration': self._convert_duration_to_minutes(info.get('duration_secs', 0)), - 'duration_secs': info.get('duration_secs', 0), + 'duration': movie.duration or self._convert_duration_to_minutes(info.get('duration_secs', 0)), + 'duration_secs': info.get('duration_secs', (movie.duration or 0) * 60), 'episode_run_time': info.get('episode_run_time', 0), 'age': info.get('age', ''), 'backdrop_path': info.get('backdrop_path', []), @@ -149,12 +201,18 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet): 'direct_source': movie_data.get('direct_source', ''), 'category_id': movie_data.get('category_id', ''), 'added': movie_data.get('added', ''), + # Include M3U account info + 'm3u_account': { + 'id': relation.m3u_account.id, + 'name': relation.m3u_account.name, + 'account_type': relation.m3u_account.account_type + } } return Response(response_data) except Exception as e: - logger.error(f"Error fetching VOD info from provider for movie {pk}: {str(e)}") + logger.error(f"Error in provider info for movie {pk}: {str(e)}") return Response( {'error': f'Failed to fetch information from provider: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR @@ -213,9 +271,46 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): return [Authenticated()] def get_queryset(self): - return Series.objects.select_related( - 'category', 'logo', 'm3u_account' - ).prefetch_related('episodes').filter(m3u_account__is_active=True) + # Only return series that have active M3U relations + return Series.objects.filter( + m3u_relations__m3u_account__is_active=True + ).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account') + + @action(detail=True, methods=['get'], url_path='providers') + def get_providers(self, request, pk=None): + """Get all providers (M3U accounts) that have this series""" + series = self.get_object() + relations = M3USeriesRelation.objects.filter( + series=series, + m3u_account__is_active=True + ).select_related('m3u_account', 'category') + + serializer = M3USeriesRelationSerializer(relations, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['get'], url_path='episodes') + def get_episodes(self, request, pk=None): + """Get episodes for this series with provider information""" + series = self.get_object() + episodes = Episode.objects.filter(series=series).prefetch_related( + 'm3u_relations__m3u_account' + ).order_by('season_number', 'episode_number') + + episodes_data = [] + for episode in episodes: + episode_serializer = EpisodeSerializer(episode) + episode_data = episode_serializer.data + + # Add provider information + relations = M3UEpisodeRelation.objects.filter( + episode=episode, + m3u_account__is_active=True + ).select_related('m3u_account') + + episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data + episodes_data.append(episode_data) + + return Response(episodes_data) @action(detail=True, methods=['get'], url_path='provider-info') def series_info(self, request, pk=None): @@ -224,9 +319,15 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): series = self.get_object() logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") - if not series.m3u_account: + # Get the first active relation + relation = M3USeriesRelation.objects.filter( + series=series, + m3u_account__is_active=True + ).select_related('m3u_account').first() + + if not relation: return Response( - {'error': 'No M3U account associated with this series'}, + {'error': 'No active M3U account associated with this series'}, status=status.HTTP_400_BAD_REQUEST ) @@ -236,28 +337,36 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours now = timezone.now() - last_refreshed = series.last_episode_refresh + last_refreshed = relation.last_episode_refresh - # Force refresh if episodes have never been populated (last_episode_refresh is null) - if last_refreshed is None: + # Check if detailed data has been fetched + custom_props = relation.custom_properties or {} + episodes_fetched = custom_props.get('episodes_fetched', False) + detailed_fetched = custom_props.get('detailed_fetched', False) + + # Force refresh if episodes have never been fetched or if forced + if not episodes_fetched or not detailed_fetched or force_refresh: force_refresh = True - logger.debug(f"Series {series.id} has never been refreshed, forcing refresh") - else: - logger.debug(f"Series {series.id} last refreshed at {last_refreshed}, now is {now}") + logger.debug(f"Series {series.id} needs detailed/episode refresh, forcing refresh") + elif last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours): + force_refresh = True + logger.debug(f"Series {series.id} refresh interval exceeded, forcing refresh") - if force_refresh or (last_refreshed and (now - last_refreshed) > timedelta(hours=refresh_interval_hours)): + if force_refresh: logger.debug(f"Refreshing series {series.id} data from provider") - # Use existing refresh logic + # Use existing refresh logic with external_series_id from .tasks import refresh_series_episodes - account = series.m3u_account + account = relation.m3u_account if account and account.is_active: - refresh_series_episodes(account, series, series.series_id) + refresh_series_episodes(account, series, relation.external_series_id) series.refresh_from_db() # Reload from database after refresh + relation.refresh_from_db() # Reload relation too # Return the database data (which should now be fresh) + custom_props = relation.custom_properties or {} response_data = { 'id': series.id, - 'series_id': series.series_id, + 'series_id': relation.external_series_id, 'name': series.name, 'description': series.description, 'year': series.year, @@ -265,25 +374,27 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): 'rating': series.rating, 'tmdb_id': series.tmdb_id, 'imdb_id': series.imdb_id, - 'category_id': series.category.id if series.category else None, - 'category_name': series.category.name if series.category else None, + 'category_id': relation.category.id if relation.category else None, + 'category_name': relation.category.name if relation.category else None, 'cover': { 'id': series.logo.id, 'url': series.logo.url, 'name': series.logo.name, } if series.logo else None, 'last_refreshed': series.updated_at, - 'custom_properties': series.custom_properties or {}, + 'custom_properties': custom_props, 'm3u_account': { - 'id': series.m3u_account.id, - 'name': series.m3u_account.name, - 'account_type': series.m3u_account.account_type - } if series.m3u_account else None, + 'id': relation.m3u_account.id, + 'name': relation.m3u_account.name, + 'account_type': relation.m3u_account.account_type + }, + 'episodes_fetched': custom_props.get('episodes_fetched', False), + 'detailed_fetched': custom_props.get('detailed_fetched', False) } - # Always include episodes for series info + # Always include episodes for series info if they've been fetched include_episodes = request.query_params.get('include_episodes', 'true').lower() == 'true' - if include_episodes: + if include_episodes and custom_props.get('episodes_fetched', False): logger.debug(f"Including episodes for series {series.id}") episodes_by_season = {} for episode in series.episodes.all().order_by('season_number', 'episode_number'): @@ -291,6 +402,12 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): if season_key not in episodes_by_season: episodes_by_season[season_key] = [] + # Get episode relation for additional data + episode_relation = M3UEpisodeRelation.objects.filter( + episode=episode, + m3u_account=relation.m3u_account + ).first() + episode_data = { 'id': episode.id, 'uuid': episode.uuid, @@ -303,8 +420,8 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): 'plot': episode.description, 'duration': episode.duration, 'rating': episode.rating, - 'movie_image': episode.custom_properties.get('info', {}).get('movie_image') if episode.custom_properties else None, - 'container_extension': episode.container_extension, + 'movie_image': episode_relation.custom_properties.get('info', {}).get('movie_image') if episode_relation and episode_relation.custom_properties else None, + 'container_extension': episode_relation.container_extension if episode_relation else 'mp4', 'type': 'episode', 'series': { 'id': series.id, @@ -315,6 +432,9 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): response_data['episodes'] = episodes_by_season logger.debug(f"Added {len(episodes_by_season)} seasons of episodes to response") + elif include_episodes: + # Episodes not yet fetched, include empty episodes list + response_data['episodes'] = {} logger.debug(f"Returning series info response for series {series.id}") return Response(response_data) @@ -352,21 +472,3 @@ class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] - - -class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for monitoring VOD connections""" - queryset = VODConnection.objects.all() - serializer_class = VODConnectionSerializer - - filter_backends = [DjangoFilterBackend, OrderingFilter] - ordering = ['-connected_at'] - - def get_permissions(self): - try: - return [perm() for perm in permission_classes_by_action[self.action]] - except KeyError: - return [Authenticated()] - - def get_queryset(self): - return VODConnection.objects.select_related('m3u_profile') diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py index 1bf13b53..de6a0cbc 100644 --- a/apps/vod/migrations/0001_initial.py +++ b/apps/vod/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.4 on 2025-08-05 20:40 +# Generated by Django 5.2.4 on 2025-08-07 17:23 import django.db.models.deletion import uuid @@ -10,27 +10,33 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'), + ('dispatcharr_channels', '0024_channelgroupm3uaccount_enable_vod_sync'), ('m3u', '0012_alter_m3uaccount_refresh_interval'), ] operations = [ migrations.CreateModel( - name='VODCategory', + name='Movie', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('name', models.CharField(max_length=255)), - ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)), + ('description', models.TextField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('genre', models.CharField(blank=True, max_length=255, null=True)), + ('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)), + ('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), ], options={ - 'verbose_name': 'VOD Category', - 'verbose_name_plural': 'VOD Categories', + 'verbose_name': 'Movie', + 'verbose_name_plural': 'Movies', 'ordering': ['name'], - 'unique_together': {('name', 'm3u_account', 'category_type')}, + 'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')}, }, ), migrations.CreateModel( @@ -43,42 +49,17 @@ class Migration(migrations.Migration): ('year', models.IntegerField(blank=True, null=True)), ('rating', models.CharField(blank=True, max_length=10, null=True)), ('genre', models.CharField(blank=True, max_length=255, null=True)), - ('series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)), - ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)), - ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)), - ('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)), + ('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)), ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), - ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series', to='m3u.m3uaccount')), - ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), ], options={ 'verbose_name': 'Series', 'verbose_name_plural': 'Series', 'ordering': ['name'], - 'unique_together': {('series_id', 'm3u_account')}, - }, - ), - migrations.CreateModel( - name='VODConnection', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.PositiveIntegerField()), - ('client_id', models.CharField(max_length=255)), - ('client_ip', models.GenericIPAddressField()), - ('user_agent', models.TextField(blank=True, null=True)), - ('connected_at', models.DateTimeField(auto_now_add=True)), - ('last_activity', models.DateTimeField(auto_now=True)), - ('bytes_sent', models.BigIntegerField(default=0)), - ('position_seconds', models.IntegerField(default=0, help_text='Current playback position')), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), - ('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')), - ], - options={ - 'verbose_name': 'VOD Connection', - 'verbose_name_plural': 'VOD Connections', + 'unique_together': {('name', 'year', 'imdb_id'), ('name', 'year', 'tmdb_id')}, }, ), migrations.CreateModel( @@ -93,52 +74,91 @@ class Migration(migrations.Migration): ('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)), ('season_number', models.IntegerField(blank=True, null=True)), ('episode_number', models.IntegerField(blank=True, null=True)), - ('url', models.URLField(max_length=2048)), - ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), - ('container_extension', models.CharField(blank=True, max_length=10, null=True)), - ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)), - ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)), - ('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)), + ('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='m3u.m3uaccount')), ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')), ], options={ 'verbose_name': 'Episode', 'verbose_name_plural': 'Episodes', 'ordering': ['series__name', 'season_number', 'episode_number'], - 'unique_together': {('stream_id', 'm3u_account')}, + 'unique_together': {('series', 'season_number', 'episode_number')}, }, ), migrations.CreateModel( - name='Movie', + name='VODCategory', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True, null=True)), - ('year', models.IntegerField(blank=True, null=True)), - ('rating', models.CharField(blank=True, max_length=10, null=True)), - ('genre', models.CharField(blank=True, max_length=255, null=True)), - ('duration', models.IntegerField(blank=True, help_text='Duration in minutes', null=True)), + ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'VOD Category', + 'verbose_name_plural': 'VOD Categories', + 'ordering': ['name'], + 'unique_together': {('name', 'category_type')}, + }, + ), + migrations.CreateModel( + name='M3UEpisodeRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('url', models.URLField(max_length=2048)), ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), ('container_extension', models.CharField(blank=True, max_length=10, null=True)), - ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True)), - ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True)), - ('custom_properties', models.JSONField(blank=True, help_text='JSON data for additional properties', null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dispatcharr_channels.logo')), - ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movies', to='m3u.m3uaccount')), + ('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.episode')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'M3U Episode Relation', + 'verbose_name_plural': 'M3U Episode Relations', + 'unique_together': {('m3u_account', 'stream_id')}, + }, + ), + migrations.CreateModel( + name='M3USeriesRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_relations', to='m3u.m3uaccount')), + ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.series')), ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), ], options={ - 'verbose_name': 'Movie', - 'verbose_name_plural': 'Movies', - 'ordering': ['name'], - 'unique_together': {('stream_id', 'm3u_account')}, + 'verbose_name': 'M3U Series Relation', + 'verbose_name_plural': 'M3U Series Relations', + 'unique_together': {('m3u_account', 'external_series_id')}, + }, + ), + migrations.CreateModel( + name='M3UMovieRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(max_length=2048)), + ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), + ('container_extension', models.CharField(blank=True, max_length=10, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_relations', to='m3u.m3uaccount')), + ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.movie')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'M3U Movie Relation', + 'verbose_name_plural': 'M3U Movie Relations', + 'unique_together': {('m3u_account', 'stream_id')}, }, ), ] diff --git a/apps/vod/models.py b/apps/vod/models.py index c1884f5d..e41e29ed 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -22,21 +22,14 @@ class VODCategory(models.Model): default='movie', help_text="Type of content this category contains" ) - m3u_account = models.ForeignKey( - M3UAccount, - on_delete=models.CASCADE, - related_name='vod_categories', - null=True, - blank=True - ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "VOD Category" - verbose_name_plural = "VOD Categories" + verbose_name = 'VOD Category' + verbose_name_plural = 'VOD Categories' ordering = ['name'] - unique_together = ['name', 'm3u_account', 'category_type'] + unique_together = [('name', 'category_type')] def __str__(self): return f"{self.name} ({self.get_category_type_display()})" @@ -51,28 +44,27 @@ class Series(models.Model): rating = models.CharField(max_length=10, blank=True, null=True) genre = models.CharField(max_length=255, blank=True, null=True) logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True) - category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) - m3u_account = models.ForeignKey( - M3UAccount, - on_delete=models.CASCADE, - related_name='series' - ) - series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider") - tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") - imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") - custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + + # Metadata IDs for deduplication + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed") class Meta: - verbose_name = "Series" - verbose_name_plural = "Series" + verbose_name = 'Series' + verbose_name_plural = 'Series' ordering = ['name'] - unique_together = ['series_id', 'm3u_account'] + # Create unique constraint for deduplication + unique_together = [ + ('name', 'year', 'tmdb_id'), + ('name', 'year', 'imdb_id'), + ] def __str__(self): - return f"{self.name} ({self.year or 'Unknown'})" + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" class Movie(models.Model): @@ -84,43 +76,28 @@ class Movie(models.Model): rating = models.CharField(max_length=10, blank=True, null=True) genre = models.CharField(max_length=255, blank=True, null=True) duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") - - # Streaming information - url = models.URLField(max_length=2048) logo = models.ForeignKey(Logo, on_delete=models.SET_NULL, null=True, blank=True) - category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) - # M3U relationship - m3u_account = models.ForeignKey( - M3UAccount, - on_delete=models.CASCADE, - related_name='movies' - ) - stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") - container_extension = models.CharField(max_length=10, blank=True, null=True) - - # Metadata IDs - tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") - imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") - - # Additional properties - custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + # Metadata IDs for deduplication + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "Movie" - verbose_name_plural = "Movies" + verbose_name = 'Movie' + verbose_name_plural = 'Movies' ordering = ['name'] - unique_together = ['stream_id', 'm3u_account'] + # Create unique constraint for deduplication + unique_together = [ + ('name', 'year', 'tmdb_id'), + ('name', 'year', 'imdb_id'), + ] def __str__(self): - return f"{self.name} ({self.year or 'Unknown'})" - - def get_stream_url(self): - """Generate the proxied stream URL for this movie""" - return f"/proxy/vod/movie/{self.uuid}" + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" class Episode(models.Model): @@ -137,75 +114,108 @@ class Episode(models.Model): season_number = models.IntegerField(blank=True, null=True) episode_number = models.IntegerField(blank=True, null=True) - # Streaming information - url = models.URLField(max_length=2048) - - # M3U relationship - m3u_account = models.ForeignKey( - M3UAccount, - on_delete=models.CASCADE, - related_name='episodes' - ) - stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") - container_extension = models.CharField(max_length=10, blank=True, null=True) - # Metadata IDs - tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") - imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") - - # Additional properties - custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "Episode" - verbose_name_plural = "Episodes" + verbose_name = 'Episode' + verbose_name_plural = 'Episodes' ordering = ['series__name', 'season_number', 'episode_number'] - unique_together = ['stream_id', 'm3u_account'] + unique_together = [ + ('series', 'season_number', 'episode_number'), + ] def __str__(self): - season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else "" - return f"{self.series.name} {season_ep} - {self.name}" + season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}" + return f"{self.series.name} - {season_ep} - {self.name}" - def get_stream_url(self): - """Generate the proxied stream URL for this episode""" - return f"/proxy/vod/episode/{self.uuid}" -class VODConnection(models.Model): - """Track active VOD connections for connection limit management""" - # Use generic foreign key to support both Movie and Episode - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') +# New relation models to link M3U accounts with VOD content - m3u_profile = models.ForeignKey( - 'm3u.M3UAccountProfile', - on_delete=models.CASCADE, - related_name='vod_connections' - ) - client_id = models.CharField(max_length=255) - client_ip = models.GenericIPAddressField() - user_agent = models.TextField(blank=True, null=True) - connected_at = models.DateTimeField(auto_now_add=True) - last_activity = models.DateTimeField(auto_now=True) - bytes_sent = models.BigIntegerField(default=0) - position_seconds = models.IntegerField(default=0, help_text="Current playback position") +class M3USeriesRelation(models.Model): + """Links M3U accounts to Series with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations') + series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Provider-specific fields - renamed to avoid clash with series ForeignKey + external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider") + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed") class Meta: - verbose_name = "VOD Connection" - verbose_name_plural = "VOD Connections" + verbose_name = 'M3U Series Relation' + verbose_name_plural = 'M3U Series Relations' + unique_together = [('m3u_account', 'external_series_id')] def __str__(self): - content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown' - return f"{content_name} - {self.client_ip} ({self.client_id})" + return f"{self.m3u_account.name} - {self.series.name}" - def update_activity(self, bytes_sent=0, position=0): - """Update connection activity""" - self.last_activity = timezone.now() - if bytes_sent: - self.bytes_sent += bytes_sent - if position: - self.position_seconds = position - self.save(update_fields=['last_activity', 'bytes_sent', 'position_seconds']) + +class M3UMovieRelation(models.Model): + """Links M3U accounts to Movies with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations') + movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Streaming information (provider-specific) + url = models.URLField(max_length=2048) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'M3U Movie Relation' + verbose_name_plural = 'M3U Movie Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.movie.name}" + + def get_stream_url(self): + """Get the full stream URL for this movie from this provider""" + return self.url + + +class M3UEpisodeRelation(models.Model): + """Links M3U accounts to Episodes with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') + episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + + # Streaming information (provider-specific) + url = models.URLField(max_length=2048) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'M3U Episode Relation' + verbose_name_plural = 'M3U Episode Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.episode}" + + def get_stream_url(self): + """Get the full stream URL for this episode from this provider""" + return self.url diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index 5c709a8c..00d9f328 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -1,5 +1,8 @@ from rest_framework import serializers -from .models import Series, VODCategory, VODConnection, Movie, Episode +from .models import ( + Series, VODCategory, Movie, Episode, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation +) from apps.channels.serializers import LogoSerializer from apps.m3u.serializers import M3UAccountSerializer @@ -14,8 +17,6 @@ class VODCategorySerializer(serializers.ModelSerializer): class SeriesSerializer(serializers.ModelSerializer): logo = LogoSerializer(read_only=True) - category = VODCategorySerializer(read_only=True) - m3u_account = M3UAccountSerializer(read_only=True) episode_count = serializers.SerializerMethodField() class Meta: @@ -28,40 +29,59 @@ class SeriesSerializer(serializers.ModelSerializer): class MovieSerializer(serializers.ModelSerializer): logo = LogoSerializer(read_only=True) - category = VODCategorySerializer(read_only=True) - m3u_account = M3UAccountSerializer(read_only=True) - stream_url = serializers.SerializerMethodField() class Meta: model = Movie fields = '__all__' - def get_stream_url(self, obj): - return obj.get_stream_url() - class EpisodeSerializer(serializers.ModelSerializer): - logo = LogoSerializer(read_only=True) series = SeriesSerializer(read_only=True) - m3u_account = M3UAccountSerializer(read_only=True) - stream_url = serializers.SerializerMethodField() class Meta: model = Episode fields = '__all__' - def get_stream_url(self, obj): - return obj.get_stream_url() - -class VODConnectionSerializer(serializers.ModelSerializer): - content_name = serializers.SerializerMethodField() +class M3USeriesRelationSerializer(serializers.ModelSerializer): + series = SeriesSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) class Meta: - model = VODConnection + model = M3USeriesRelation fields = '__all__' - def get_content_name(self, obj): - if obj.content_object: - return getattr(obj.content_object, 'name', 'Unknown') - return 'Unknown' + +class M3UMovieRelationSerializer(serializers.ModelSerializer): + movie = MovieSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + + class Meta: + model = M3UMovieRelation + fields = '__all__' + + +class M3UEpisodeRelationSerializer(serializers.ModelSerializer): + episode = EpisodeSerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + + class Meta: + model = M3UEpisodeRelation + fields = '__all__' + + +class EnhancedSeriesSerializer(serializers.ModelSerializer): + """Enhanced serializer for series with provider information""" + logo = LogoSerializer(read_only=True) + providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True) + episode_count = serializers.SerializerMethodField() + + class Meta: + model = Series + fields = '__all__' + + def get_episode_count(self, obj): + return obj.episodes.count() + diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 79f1ffa0..609996a4 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1,379 +1,508 @@ -import logging -import requests -import json -import re from celery import shared_task from django.utils import timezone -from datetime import timedelta -from .models import Series, VODCategory, VODConnection, Movie, Episode +from django.db import transaction from apps.m3u.models import M3UAccount -from apps.channels.models import Logo from core.xtream_codes import Client as XtreamCodesClient +from .models import ( + VODCategory, Series, Movie, Episode, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation +) +from apps.channels.models import Logo +import logging +import json logger = logging.getLogger(__name__) -@shared_task(bind=True) -def refresh_vod_content(self, account_id): - """Refresh VOD content from XtreamCodes API""" +@shared_task +def refresh_vod_content(account_id): + """Refresh VOD content for an M3U account""" try: - account = M3UAccount.objects.get(id=account_id) + account = M3UAccount.objects.get(id=account_id, is_active=True) + if account.account_type != M3UAccount.Types.XC: - logger.warning(f"Account {account_id} is not XtreamCodes type") - return + logger.warning(f"VOD refresh called for non-XC account {account_id}") + return "VOD refresh only available for XtreamCodes accounts" - # Get movies and series - refresh_movies(account) - refresh_series(account) + logger.info(f"Starting VOD refresh for account {account.name}") - logger.info(f"Successfully refreshed VOD content for account {account_id}") + with XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) as client: + + # Refresh movies + refresh_movies(client, account) + + # Refresh series + refresh_series(client, account) + + logger.info(f"VOD refresh completed for account {account.name}") + return f"VOD refresh completed for account {account.name}" - except M3UAccount.DoesNotExist: - logger.error(f"M3U Account {account_id} not found") except Exception as e: - logger.error(f"Error refreshing VOD content for account {account_id}: {e}") + logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}") + return f"VOD refresh failed: {str(e)}" -def extract_year_from_title(title): - """Extract year from movie title if present""" - if not title: +def refresh_movies(client, account): + """Refresh movie content - only basic list, no detailed calls""" + logger.info(f"Refreshing movies for account {account.name}") + + # Get movie categories + categories = client.get_vod_categories() + + for category_data in categories: + category_name = category_data.get('category_name', 'Unknown') + category_id = category_data.get('category_id') + + # Get or create category + category, created = VODCategory.objects.get_or_create( + name=category_name, + category_type='movie', + defaults={'name': category_name} + ) + + # Get movies in this category - only basic list + movies = client.get_vod_streams(category_id) + + for movie_data in movies: + process_movie_basic(client, account, movie_data, category) + + +def refresh_series(client, account): + """Refresh series content - only basic list, no detailed calls""" + logger.info(f"Refreshing series for account {account.name}") + + # Get series categories + categories = client.get_series_categories() + + for category_data in categories: + category_name = category_data.get('category_name', 'Unknown') + category_id = category_data.get('category_id') + + # Get or create category + category, created = VODCategory.objects.get_or_create( + name=category_name, + category_type='series', + defaults={'name': category_name} + ) + + # Get series in this category - only basic list + series_list = client.get_series(category_id) + + for series_data in series_list: + process_series_basic(client, account, series_data, category) + + +def process_movie_basic(client, account, movie_data, category): + """Process a single movie - basic info only, no detailed API call""" + try: + stream_id = movie_data.get('stream_id') + name = movie_data.get('name', 'Unknown') + + # Extract all available metadata from the basic data + year = extract_year(movie_data.get('added', '')) # Use added date as fallback + if not year and movie_data.get('year'): + year = extract_year(str(movie_data.get('year'))) + + # Extract TMDB and IMDB IDs if available in basic data + tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb') + imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb') + + # Extract additional metadata that might be available in basic data + description = movie_data.get('description') or movie_data.get('plot') or '' + rating = movie_data.get('rating') or movie_data.get('vote_average') or '' + genre = movie_data.get('genre') or movie_data.get('category_name') or '' + duration_minutes = None + + # Try to extract duration from various possible fields + if movie_data.get('duration_secs'): + duration_minutes = convert_duration_to_minutes(movie_data.get('duration_secs')) + elif movie_data.get('duration'): + # Handle duration that might be in different formats + duration_str = str(movie_data.get('duration')) + if duration_str.isdigit(): + duration_minutes = int(duration_str) # Assume minutes if just a number + else: + # Try to parse time format like "01:30:00" + try: + time_parts = duration_str.split(':') + if len(time_parts) == 3: + hours, minutes, seconds = map(int, time_parts) + duration_minutes = (hours * 60) + minutes + elif len(time_parts) == 2: + minutes, seconds = map(int, time_parts) + duration_minutes = minutes + except (ValueError, AttributeError): + pass + + # Build info dict with all extracted data + info = { + 'plot': description, + 'rating': rating, + 'genre': genre, + 'duration_secs': movie_data.get('duration_secs'), + } + + # Use find_or_create_movie to handle duplicates properly + movie = find_or_create_movie( + name=name, + year=year, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + info=info + ) + + # Handle logo from basic data if available + if movie_data.get('stream_icon'): + logo, _ = Logo.objects.get_or_create( + url=movie_data['stream_icon'], + defaults={'name': name} + ) + if not movie.logo: + movie.logo = logo + movie.save(update_fields=['logo']) + + # Create or update relation + stream_url = client.get_vod_stream_url(stream_id) + + relation, created = M3UMovieRelation.objects.update_or_create( + m3u_account=account, + stream_id=str(stream_id), + defaults={ + 'movie': movie, + 'category': category, + 'url': stream_url, + 'container_extension': movie_data.get('container_extension', 'mp4'), + 'custom_properties': { + 'basic_data': movie_data, + 'detailed_fetched': False # Flag to indicate detailed data not fetched + } + } + ) + + if created: + logger.debug(f"Created new movie relation: {name}") + else: + logger.debug(f"Updated movie relation: {name}") + + except Exception as e: + logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {str(e)}") + + +def process_series_basic(client, account, series_data, category): + """Process a single series - basic info only, no detailed API call""" + try: + series_id = series_data.get('series_id') + name = series_data.get('name', 'Unknown') + + # Extract all available metadata from the basic data + year = extract_year(series_data.get('releaseDate', '')) # Use releaseDate from API + if not year and series_data.get('release_date'): + year = extract_year(series_data.get('release_date')) + + # Extract TMDB and IMDB IDs if available in basic data + tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id') + imdb_id = series_data.get('imdb') or series_data.get('imdb_id') + + # Extract additional metadata that matches the actual API response + description = series_data.get('plot') or series_data.get('description') or series_data.get('overview') or '' + rating = series_data.get('rating') or series_data.get('vote_average') or '' + genre = series_data.get('genre') or '' + + # Build info dict with all extracted data + info = { + 'plot': description, + 'rating': rating, + 'genre': genre, + } + + # Use find_or_create_series to handle duplicates properly + series = find_or_create_series( + name=name, + year=year, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + info=info + ) + + # Handle logo from basic data if available + if series_data.get('cover'): + logo, _ = Logo.objects.get_or_create( + url=series_data['cover'], + defaults={'name': name} + ) + if not series.logo: + series.logo = logo + series.save(update_fields=['logo']) + + # Create or update series relation + series_relation, created = M3USeriesRelation.objects.update_or_create( + m3u_account=account, + external_series_id=str(series_id), + defaults={ + 'series': series, + 'category': category, + 'custom_properties': { + 'basic_data': series_data, + 'detailed_fetched': False, # Flag to indicate detailed data not fetched + 'episodes_fetched': False # Flag to indicate episodes not fetched + }, + 'last_episode_refresh': None # Set to None since we haven't fetched episodes + } + ) + + if created: + logger.debug(f"Created new series relation: {name}") + else: + logger.debug(f"Updated series relation: {name}") + + except Exception as e: + logger.error(f"Error processing series {series_data.get('name', 'Unknown')}: {str(e)}") + + +# Remove the detailed processing functions since they're no longer used during refresh +# process_movie and process_series are now only called on-demand + +def refresh_series_episodes(account, series, external_series_id, episodes_data=None): + """Refresh episodes for a series - only called on-demand""" + try: + if not episodes_data: + # Fetch detailed series info including episodes + with XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) as client: + series_info = client.get_series_info(external_series_id) + if series_info: + # Update series with detailed info + info = series_info.get('info', {}) + if info: + series.description = info.get('plot', series.description) + series.rating = info.get('rating', series.rating) + series.genre = info.get('genre', series.genre) + if info.get('releasedate'): + series.year = extract_year(info.get('releasedate')) + series.save() + + episodes_data = series_info.get('episodes', {}) + else: + episodes_data = {} + + # Clear existing episodes for this account to handle deletions + Episode.objects.filter( + series=series, + m3u_relations__m3u_account=account + ).delete() + + for season_num, season_episodes in episodes_data.items(): + for episode_data in season_episodes: + process_episode(account, series, episode_data, int(season_num)) + + # Update the series relation to mark episodes as fetched + series_relation = M3USeriesRelation.objects.filter( + series=series, + m3u_account=account + ).first() + + if series_relation: + custom_props = series_relation.custom_properties or {} + custom_props['episodes_fetched'] = True + custom_props['detailed_fetched'] = True + series_relation.custom_properties = custom_props + series_relation.last_episode_refresh = timezone.now() + series_relation.save() + + except Exception as e: + logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") + + +def find_or_create_movie(name, year, tmdb_id, imdb_id, info): + """Find existing movie or create new one based on metadata""" + # Try to find by TMDB ID first + if tmdb_id: + movie = Movie.objects.filter(tmdb_id=tmdb_id).first() + if movie: + # Update with any new info we have + updated = False + if info.get('plot') and not movie.description: + movie.description = info.get('plot') + updated = True + if info.get('rating') and not movie.rating: + movie.rating = info.get('rating') + updated = True + if info.get('genre') and not movie.genre: + movie.genre = info.get('genre') + updated = True + if not movie.year and year: + movie.year = year + updated = True + duration = convert_duration_to_minutes(info.get('duration_secs')) + if duration and not movie.duration: + movie.duration = duration + updated = True + if updated: + movie.save() + return movie + + # Try to find by IMDB ID + if imdb_id: + movie = Movie.objects.filter(imdb_id=imdb_id).first() + if movie: + # Update with any new info we have + updated = False + if info.get('plot') and not movie.description: + movie.description = info.get('plot') + updated = True + if info.get('rating') and not movie.rating: + movie.rating = info.get('rating') + updated = True + if info.get('genre') and not movie.genre: + movie.genre = info.get('genre') + updated = True + if not movie.year and year: + movie.year = year + updated = True + duration = convert_duration_to_minutes(info.get('duration_secs')) + if duration and not movie.duration: + movie.duration = duration + updated = True + if updated: + movie.save() + return movie + + # Try to find by name and year - use first() to handle multiple matches + if year: + movie = Movie.objects.filter(name=name, year=year).first() + if movie: + return movie + + # Try to find by name only if no year provided + movie = Movie.objects.filter(name=name).first() + if movie: + return movie + + # Create new movie with all available data + return Movie.objects.create( + name=name, + year=year, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + description=info.get('plot', ''), + rating=info.get('rating', ''), + genre=info.get('genre', ''), + duration=convert_duration_to_minutes(info.get('duration_secs')) + ) + + +def find_or_create_series(name, year, tmdb_id, imdb_id, info): + """Find existing series or create new one based on metadata""" + # Try to find by TMDB ID first + if tmdb_id: + series = Series.objects.filter(tmdb_id=tmdb_id).first() + if series: + # Update with any new info we have + updated = False + if info.get('plot') and not series.description: + series.description = info.get('plot') + updated = True + if info.get('rating') and not series.rating: + series.rating = info.get('rating') + updated = True + if info.get('genre') and not series.genre: + series.genre = info.get('genre') + updated = True + if not series.year and year: + series.year = year + updated = True + if updated: + series.save() + return series + + # Try to find by IMDB ID + if imdb_id: + series = Series.objects.filter(imdb_id=imdb_id).first() + if series: + # Update with any new info we have + updated = False + if info.get('plot') and not series.description: + series.description = info.get('plot') + updated = True + if info.get('rating') and not series.rating: + series.rating = info.get('rating') + updated = True + if info.get('genre') and not series.genre: + series.genre = info.get('genre') + updated = True + if not series.year and year: + series.year = year + updated = True + if updated: + series.save() + return series + + # Try to find by name and year - use first() to handle multiple matches + if year: + series = Series.objects.filter(name=name, year=year).first() + if series: + return series + + # Try to find by name only if no year provided + series = Series.objects.filter(name=name).first() + if series: + return series + + # Create new series with all available data + return Series.objects.create( + name=name, + year=year, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + description=info.get('plot', ''), + rating=info.get('rating', ''), + genre=info.get('genre', '') + ) + + +def extract_year(date_string): + """Extract year from date string""" + if not date_string: + return None + try: + return int(date_string.split('-')[0]) + except (ValueError, IndexError): return None - # Pattern for (YYYY) format - pattern1 = r'\((\d{4})\)' - # Pattern for - YYYY format - pattern2 = r'\s-\s(\d{4})' - # Pattern for YYYY at the end - pattern3 = r'\s(\d{4})$' - for pattern in [pattern1, pattern2, pattern3]: - match = re.search(pattern, title) - if match: - year = int(match.group(1)) - # Validate year is reasonable (between 1900 and current year + 5) - if 1900 <= year <= 2030: - return year - - return None - - -def extract_year_from_data(data, title_key='name'): - """Extract year from various data sources with fallback options""" +def convert_duration_to_minutes(duration_secs): + """Convert duration from seconds to minutes""" + if not duration_secs: + return None try: - # First try the year field - year = data.get('year') - if year and str(year).strip() and str(year).strip() != '': - try: - year_int = int(year) - if 1900 <= year_int <= 2030: - return year_int - except (ValueError, TypeError): - pass + return int(duration_secs) // 60 + except (ValueError, TypeError): + return None - # Try releaseDate or release_date fields - for date_field in ['releaseDate', 'release_date']: - date_value = data.get(date_field) - if date_value and isinstance(date_value, str) and date_value.strip(): - # Extract year from date format like "2011-09-19" - try: - year_str = date_value.split('-')[0].strip() - if year_str: - year = int(year_str) - if 1900 <= year <= 2030: - return year - except (ValueError, IndexError): - continue - - # Finally try extracting from title - title = data.get(title_key, '') - if title and title.strip(): - return extract_year_from_title(title) - - except Exception: - # Don't fail processing if year extraction fails - pass - - return None - - -def refresh_movies(account): - """Refresh movie content""" - try: - # Get movie categories - categories_url = f"{account.server_url}/player_api.php" - params = { - 'username': account.username, - 'password': account.password, - 'action': 'get_vod_categories' - } - - response = requests.get(categories_url, params=params, timeout=30) - response.raise_for_status() - categories_data = response.json() - - # Create a mapping of category_id to category name for lookup - category_id_to_name = {} - for cat_data in categories_data: - category_id_to_name[cat_data.get('category_id')] = cat_data['category_name'] - - # Create/update categories - VODCategory.objects.get_or_create( - name=cat_data['category_name'], - m3u_account=account, - category_type='movie', - defaults={ - 'name': cat_data['category_name'], - 'category_type': 'movie' - } - ) - - # Get movies - movies_url = f"{account.server_url}/player_api.php" - params['action'] = 'get_vod_streams' - - response = requests.get(movies_url, params=params, timeout=30) - response.raise_for_status() - movies_data = response.json() - - for movie_data in movies_data: - try: - # Get category - category = None - category_id = movie_data.get('category_id') - - if category_id: - # First try to get category name from our mapping - category_name = category_id_to_name.get(category_id) - if not category_name: - # Fallback to category_name from movie data - category_name = movie_data.get('category_name', '') - - if category_name: - try: - category = VODCategory.objects.filter( - name=category_name, - m3u_account=account, - category_type='movie' - ).first() - except Exception as e: - logger.warning(f"Error finding category for movie {movie_data.get('name', 'Unknown')}: {e}") - category = None - - # Create/update movie - stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}" - - # Extract year from title if not provided in API - year = extract_year_from_data(movie_data, 'name') - - movie_data_dict = { - 'name': movie_data['name'], - 'url': stream_url, - 'category': category, - 'year': year, - 'rating': movie_data.get('rating'), - 'genre': movie_data.get('genre'), - 'duration': movie_data.get('duration_secs', 0) // 60 if movie_data.get('duration_secs') else None, - 'container_extension': movie_data.get('container_extension'), - 'tmdb_id': movie_data.get('tmdb_id'), - 'imdb_id': movie_data.get('imdb_id'), - 'custom_properties': movie_data if movie_data else None - } - - # Use new Movie model - movie, created = Movie.objects.update_or_create( - stream_id=movie_data['stream_id'], - m3u_account=account, - defaults=movie_data_dict - ) - - # Handle logo - if movie_data.get('stream_icon'): - logo, _ = Logo.objects.get_or_create( - url=movie_data['stream_icon'], - defaults={'name': movie_data['name']} - ) - movie.logo = logo - movie.save() - - except Exception as e: - logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}") - continue - - except Exception as e: - logger.error(f"Error refreshing movies for account {account.id}: {e}") - - -def refresh_series(account): - """Refresh series and episodes content""" - try: - # Get series categories - categories_url = f"{account.server_url}/player_api.php" - params = { - 'username': account.username, - 'password': account.password, - 'action': 'get_series_categories' - } - - response = requests.get(categories_url, params=params, timeout=30) - response.raise_for_status() - categories_data = response.json() - - # Create a mapping of category_id to category name for lookup - category_id_to_name = {} - for cat_data in categories_data: - category_id_to_name[cat_data.get('category_id')] = cat_data['category_name'] - - # Create/update series categories - VODCategory.objects.get_or_create( - name=cat_data['category_name'], - m3u_account=account, - category_type='series', - defaults={ - 'name': cat_data['category_name'], - 'category_type': 'series' - } - ) - - # Get series list - series_url = f"{account.server_url}/player_api.php" - params['action'] = 'get_series' - - response = requests.get(series_url, params=params, timeout=30) - response.raise_for_status() - series_data = response.json() - - for series_item in series_data: - try: - # Get category - category = None - category_id = series_item.get('category_id') - - if category_id: - # First try to get category name from our mapping - category_name = category_id_to_name.get(category_id) - if not category_name: - # Fallback to category_name from series data - category_name = series_item.get('category_name', '') - - if category_name: - try: - category = VODCategory.objects.filter( - name=category_name, - m3u_account=account, - category_type='series' - ).first() - except Exception as e: - logger.warning(f"Error finding category for series {series_item.get('name', 'Unknown')}: {e}") - category = None - - # Create/update series - # Extract year from series data - year = extract_year_from_data(series_item, 'name') - - series_data_dict = { - 'name': series_item['name'], - 'description': series_item.get('plot'), - 'year': year, - 'rating': series_item.get('rating'), - 'genre': series_item.get('genre'), - 'category': category, - 'tmdb_id': series_item.get('tmdb_id'), - 'imdb_id': series_item.get('imdb_id'), - 'custom_properties': series_item if series_item else None - } - - series, created = Series.objects.update_or_create( - series_id=series_item['series_id'], - m3u_account=account, - defaults=series_data_dict - ) - - # Handle series logo - if series_item.get('cover'): - logo, _ = Logo.objects.get_or_create( - url=series_item['cover'], - defaults={'name': series_item['name']} - ) - series.logo = logo - series.save() - - - except Exception as e: - logger.error(f"Error processing series {series_item.get('name', 'Unknown')}: {e}") - continue - - except Exception as e: - logger.error(f"Error refreshing series for account {account.id}: {e}") - - -def refresh_series_episodes(account, series, series_id): - """Refresh episodes for a specific series""" - try: - episodes_url = f"{account.server_url}/player_api.php" - params = { - 'username': account.username, - 'password': account.password, - 'action': 'get_series_info', - 'series_id': series_id - } - - response = requests.get(episodes_url, params=params, timeout=30) - response.raise_for_status() - series_info = response.json() - - # Process episodes by season - if 'episodes' in series_info: - for season_num, episodes in series_info['episodes'].items(): - for episode_data in episodes: - try: - # Build episode stream URL - stream_url = f"{account.server_url}/series/{account.username}/{account.password}/{episode_data['id']}.{episode_data.get('container_extension', 'mp4')}" - - # Get episode info (metadata is nested in 'info' object) - episode_info = episode_data.get('info', {}) - - episode_dict = { - 'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"), - 'series': series, - 'season_number': int(season_num) if season_num.isdigit() else None, - 'episode_number': episode_data.get('episode_num'), - 'url': stream_url, - 'description': episode_info.get('plot') or episode_info.get('overview'), - 'release_date': episode_info.get('release_date') or episode_info.get('releasedate'), - 'rating': episode_info.get('rating'), - 'duration': episode_info.get('duration_secs'), - 'container_extension': episode_data.get('container_extension'), - 'tmdb_id': episode_info.get('tmdb_id'), - 'imdb_id': episode_info.get('imdb_id'), - 'custom_properties': episode_data if episode_data else None - } - # Use new Episode model - episode, created = Episode.objects.update_or_create( - stream_id=episode_data['id'], - m3u_account=account, - defaults=episode_dict - ) - - except Exception as e: - logger.error(f"Error processing episode {episode_data.get('title', 'Unknown')}: {e}") - continue - - # Update last_episode_refresh timestamp - series.last_episode_refresh = timezone.now() - series.save(update_fields=['last_episode_refresh']) - - except Exception as e: - logger.error(f"Error refreshing episodes for series {series_id}: {e}") @shared_task -def cleanup_inactive_vod_connections(): - """Clean up inactive VOD connections""" - cutoff_time = timezone.now() - timedelta(minutes=30) - inactive_connections = VODConnection.objects.filter(last_activity__lt=cutoff_time) +def cleanup_orphaned_vod_content(): + """Clean up VOD content that has no M3U relations""" + # Clean up movies with no relations + orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True) + movie_count = orphaned_movies.count() + orphaned_movies.delete() - count = inactive_connections.count() - if count > 0: - inactive_connections.delete() - logger.info(f"Cleaned up {count} inactive VOD connections") + # Clean up series with no relations + orphaned_series = Series.objects.filter(m3u_relations__isnull=True) + series_count = orphaned_series.count() + orphaned_series.delete() - return count \ No newline at end of file + # Episodes will be cleaned up via CASCADE when series are deleted + + logger.info(f"Cleaned up {movie_count} orphaned movies and {series_count} orphaned series") + return f"Cleaned up {movie_count} movies and {series_count} series" \ No newline at end of file diff --git a/core/views.py b/core/views.py index 397783fb..d10df027 100644 --- a/core/views.py +++ b/core/views.py @@ -73,7 +73,6 @@ def stream_view(request, channel_uuid): default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) profiles = [obj for obj in m3u_profiles if not obj.is_default] - # -- Loop through profiles and pick the first active one -- for profile in [default_profile] + profiles: logger.debug(f'Checking profile {profile.name}...') @@ -174,7 +173,7 @@ def stream_view(request, channel_uuid): persistent_lock.release() logger.debug("Persistent lock released for channel ID=%s", channel.id) - return StreamingHttpResponse( - stream_generator(process, stream, persistent_lock), - content_type="video/MP2T" - ) + return StreamingHttpResponse( + stream_generator(process, stream, persistent_lock), + content_type="video/MP2T" + ) diff --git a/core/xtream_codes.py b/core/xtream_codes.py index b8b4d862..0d02d1f8 100644 --- a/core/xtream_codes.py +++ b/core/xtream_codes.py @@ -370,6 +370,14 @@ class Client: """Get the playback URL for a VOD""" return f"{self.server_url}/movie/{self.username}/{self.password}/{vod_id}.{container_extension}" + def get_movie_stream_url(self, vod_id, container_extension="mp4"): + """Get the playback URL for a movie (alias for get_vod_stream_url)""" + return self.get_vod_stream_url(vod_id, container_extension) + + def get_episode_stream_url(self, episode_id, container_extension="mp4"): + """Get the playback URL for an episode""" + return f"{self.server_url}/series/{self.username}/{self.password}/{episode_id}.{container_extension}" + def close(self): """Close the session and cleanup resources""" if hasattr(self, 'session') and self.session: diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index f7d1992f..990a133a 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -238,6 +238,11 @@ const SeriesModal = ({ series, opened, onClose }) => { fetchSeriesInfo(series.id) .then((details) => { setDetailedSeries(details); + // 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) => { console.warn('Failed to fetch series details, using basic info:', error); @@ -541,10 +546,10 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* Provider Information */} {displaySeries.m3u_account && ( - IPTV Provider + Provider Information - {displaySeries.m3u_account.name || displaySeries.m3u_account} + {displaySeries.m3u_account.name} {displaySeries.m3u_account.account_type && ( @@ -764,7 +769,6 @@ const SeriesModal = ({ series, opened, onClose }) => { title="Trailer" size="xl" centered - withCloseButton > {trailerUrl && (