import json from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.decorators import action from rest_framework.filters import SearchFilter, OrderingFilter from rest_framework.pagination import PageNumberPagination from django_filters.rest_framework import DjangoFilterBackend from django.shortcuts import get_object_or_404 from django.urls import reverse import django_filters from django.db.models import Q import logging from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, ) from .models import ( Series, VODCategory, Movie, Episode, M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation ) from .serializers import ( MovieSerializer, EpisodeSerializer, SeriesSerializer, VODCategorySerializer, M3UMovieRelationSerializer, M3USeriesRelationSerializer, M3UEpisodeRelationSerializer ) from .tasks import refresh_series_episodes, refresh_movie_advanced_data from django.utils import timezone from datetime import timedelta logger = logging.getLogger(__name__) def build_logo_cache_url(request, logo_id): if not logo_id: return None cache_path = reverse("api:channels:logo-cache", args=[logo_id]) if request: return request.build_absolute_uri(cache_path) return cache_path class VODPagination(PageNumberPagination): page_size = 20 # Default page size to match frontend default page_size_query_param = "page_size" # Allow clients to specify page size max_page_size = 100 # Prevent excessive page sizes for VOD content class MovieFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") category = django_filters.CharFilter(method='filter_category') 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', 'm3u_account', 'category', 'year'] def filter_category(self, queryset, name, value): """Custom category filter that handles 'name|type' format""" if not value: return queryset # Handle the format 'category_name|category_type' if '|' in value: category_name, category_type = value.split('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type ) else: # Fallback: treat as category name only return queryset.filter(m3u_relations__category__name=value) class MovieViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet for Movie content""" queryset = Movie.objects.all() serializer_class = MovieSerializer pagination_class = VODPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = MovieFilter search_fields = ['name', 'description', 'genre'] ordering_fields = ['name', 'year', 'created_at'] ordering = ['name'] def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] def get_queryset(self): # Only return movies that have active M3U relations return ( Movie.objects.filter( Q(m3u_relations__m3u_account__is_active=True) | Q(library_items__library__use_as_vod_source=True) ) .distinct() .select_related('logo') .prefetch_related('m3u_relations__m3u_account', 'library_items__library') ) @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') relation_payload = M3UMovieRelationSerializer(relations, many=True).data library_payload = [] for media_item in movie.library_items.select_related("library"): library = media_item.library if not library or not library.use_as_vod_source: continue library_payload.append( { "id": f"library-{media_item.id}", "provider_type": "library", "type": "library", "library_item_id": media_item.id, "library": { "id": library.id, "name": library.name, }, "stream_id": None, "m3u_account": { "id": f"library-{library.id}", "name": library.name, "account_type": "library", }, "quality_info": (movie.custom_properties or {}).get("quality"), "custom_properties": { "source": "library", "library_id": library.id, "media_item_id": media_item.id, }, } ) return Response([*relation_payload, *library_payload]) @action(detail=True, methods=['get'], url_path='provider-info') def provider_info(self, request, pk=None): """Get detailed movie information from the original provider, throttled to 24h.""" movie = self.get_object() library_item = movie.library_items.select_related("library").filter( library__use_as_vod_source=True ).first() if library_item: library = library_item.library custom_props = movie.custom_properties or {} logo_cache_url = build_logo_cache_url(request, movie.logo_id) response_data = { 'id': movie.id, 'uuid': movie.uuid, 'stream_id': None, 'name': movie.name, 'description': movie.description, 'plot': movie.description, 'year': movie.year, 'genre': movie.genre, 'rating': movie.rating, 'tmdb_id': movie.tmdb_id, 'imdb_id': movie.imdb_id, 'duration_secs': movie.duration_secs, 'movie_image': logo_cache_url or (movie.logo.url if movie.logo else custom_props.get('poster_url')), 'backdrop_path': ( custom_props.get('backdrop_path') if isinstance(custom_props.get('backdrop_path'), list) else ([custom_props.get('backdrop_url')] if custom_props.get('backdrop_url') else []) ), 'video': (custom_props.get('quality') or {}).get('video'), 'audio': (custom_props.get('quality') or {}).get('audio'), 'bitrate': (custom_props.get('quality') or {}).get('bitrate'), 'container_extension': (custom_props.get('quality') or {}).get('container'), 'direct_source': None, 'library_item_id': library_item.id, 'library_sources': [ { 'library_id': library.id, 'library_name': library.name, 'media_item_id': library_item.id, } ], 'custom_properties': custom_props, 'm3u_account': { 'id': f'library-{library.id}', 'name': library.name, 'account_type': 'library', }, 'logo': { 'id': movie.logo_id, 'name': movie.logo.name if movie.logo else None, 'url': movie.logo.url if movie.logo else custom_props.get('poster_url'), 'cache_url': logo_cache_url, } if movie.logo else None, } return Response(response_data) # Get the highest priority active relation relation = M3UMovieRelation.objects.filter( movie=movie, m3u_account__is_active=True ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() if not relation: return Response( {'error': 'No active M3U account associated with this movie'}, status=status.HTTP_400_BAD_REQUEST ) force_refresh = request.query_params.get('force_refresh', 'false').lower() == 'true' now = timezone.now() needs_refresh = ( force_refresh or not relation.last_advanced_refresh or (now - relation.last_advanced_refresh).total_seconds() > 86400 ) if needs_refresh: # Trigger advanced data refresh logger.debug(f"Refreshing advanced data for movie {movie.id} (relation ID: {relation.id})") refresh_movie_advanced_data(relation.id, force_refresh=force_refresh) # Refresh objects from database after task completion movie.refresh_from_db() relation.refresh_from_db() logo_cache_url = build_logo_cache_url(request, movie.logo_id) # Use refreshed data from database custom_props = relation.custom_properties or {} info = custom_props.get('detailed_info', {}) movie_data = custom_props.get('movie_data', {}) # Build response with available data response_data = { 'id': movie.id, 'uuid': movie.uuid, 'stream_id': relation.stream_id, 'name': info.get('name', movie.name), 'o_name': info.get('o_name', ''), 'description': info.get('description', info.get('plot', movie.description)), 'plot': info.get('plot', info.get('description', movie.description)), 'year': movie.year or info.get('year'), 'release_date': (movie.custom_properties or {}).get('release_date') or info.get('release_date') or info.get('releasedate', ''), 'genre': movie.genre or info.get('genre', ''), 'director': (movie.custom_properties or {}).get('director') or info.get('director', ''), 'actors': (movie.custom_properties or {}).get('actors') or info.get('actors', ''), 'country': (movie.custom_properties or {}).get('country') or info.get('country', ''), 'rating': movie.rating or info.get('rating', movie.rating or 0), 'tmdb_id': movie.tmdb_id or info.get('tmdb_id', ''), 'imdb_id': movie.imdb_id or info.get('imdb_id', ''), 'youtube_trailer': (movie.custom_properties or {}).get('youtube_trailer') or info.get('youtube_trailer') or info.get('trailer', ''), 'duration_secs': movie.duration_secs or info.get('duration_secs'), 'age': info.get('age', ''), 'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []), 'cover': info.get('cover_big', ''), 'cover_big': info.get('cover_big', ''), 'movie_image': logo_cache_url or (movie.logo.url if movie.logo else info.get('movie_image', '')), 'bitrate': info.get('bitrate', 0), 'video': info.get('video', {}), 'audio': info.get('audio', {}), 'container_extension': movie_data.get('container_extension', 'mp4'), 'direct_source': movie_data.get('direct_source', ''), 'category_id': movie_data.get('category_id', ''), 'added': movie_data.get('added', ''), 'm3u_account': { 'id': relation.m3u_account.id, 'name': relation.m3u_account.name, 'account_type': relation.m3u_account.account_type }, 'logo': { 'id': movie.logo_id, 'name': movie.logo.name if movie.logo else None, 'url': movie.logo.url if movie.logo else info.get('movie_image', ''), 'cache_url': logo_cache_url, } if movie.logo_id else None } return Response(response_data) class EpisodeFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") series = django_filters.NumberFilter(field_name="series__id") m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") season_number = django_filters.NumberFilter() episode_number = django_filters.NumberFilter() class Meta: model = Episode fields = ['name', 'series', 'm3u_account', 'season_number', 'episode_number'] class SeriesFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") category = django_filters.CharFilter(method='filter_category') 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 = Series fields = ['name', 'm3u_account', 'category', 'year'] def filter_category(self, queryset, name, value): """Custom category filter that handles 'name|type' format""" if not value: return queryset # Handle the format 'category_name|category_type' if '|' in value: category_name, category_type = value.split('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type ) else: # Fallback: treat as category name only return queryset.filter(m3u_relations__category__name=value) class EpisodeViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet for Episode content""" queryset = Episode.objects.all() serializer_class = EpisodeSerializer pagination_class = VODPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = EpisodeFilter search_fields = ['name', 'description'] ordering_fields = ['name', 'season_number', 'episode_number', 'created_at'] ordering = ['series__name', 'season_number', 'episode_number'] 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 ( Episode.objects.select_related("series") .filter( Q(m3u_relations__m3u_account__is_active=True) | Q(library_items__library__use_as_vod_source=True) ) .distinct() .prefetch_related("m3u_relations__m3u_account", "library_items__library") ) class SeriesViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet for Series management""" queryset = Series.objects.all() serializer_class = SeriesSerializer pagination_class = VODPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = SeriesFilter search_fields = ['name', 'description', 'genre'] ordering_fields = ['name', 'year', 'created_at'] ordering = ['name'] def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] def get_queryset(self): # Only return series that have active M3U relations return ( Series.objects.filter( Q(m3u_relations__m3u_account__is_active=True) | Q(library_items__library__use_as_vod_source=True) ) .distinct() .select_related('logo') .prefetch_related('episodes', 'm3u_relations__m3u_account', 'library_items__library') ) @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') relation_payload = M3USeriesRelationSerializer(relations, many=True).data library_payload = [] for media_item in series.library_items.select_related("library"): library = media_item.library if not library or not library.use_as_vod_source: continue library_payload.append( { "id": f"library-{media_item.id}", "provider_type": "library", "type": "library", "library_item_id": media_item.id, "library": { "id": library.id, "name": library.name, }, "m3u_account": { "id": f"library-{library.id}", "name": library.name, "account_type": "library", }, "quality_info": None, "custom_properties": { "source": "library", "library_id": library.id, "media_item_id": media_item.id, }, } ) return Response([*relation_payload, *library_payload]) @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') relation_payload = M3UEpisodeRelationSerializer(relations, many=True).data library_payload = [] for media_item in episode.library_items.select_related("library"): library = media_item.library if not library or not library.use_as_vod_source: continue library_payload.append( { "id": f"library-{media_item.id}", "provider_type": "library", "type": "library", "library_item_id": media_item.id, "library": { "id": library.id, "name": library.name, }, "m3u_account": { "id": f"library-{library.id}", "name": library.name, "account_type": "library", }, "stream_id": None, "quality_info": None, "custom_properties": { "source": "library", "library_id": library.id, "media_item_id": media_item.id, }, } ) episode_data['providers'] = [*relation_payload, *library_payload] 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): """Get detailed series information, refreshing from provider if needed""" logger.debug(f"SeriesViewSet.series_info called for series ID: {pk}") series = self.get_object() logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") library_item = series.library_items.select_related("library").filter( library__use_as_vod_source=True ).first() if library_item: library = library_item.library series_logo_cache_url = build_logo_cache_url(request, series.logo_id) episodes_by_season: dict[str, list[dict]] = {} for episode in series.episodes.all().prefetch_related("library_items__library").order_by('season_number', 'episode_number'): if not episode.library_items.filter(library__use_as_vod_source=True).exists(): continue season_key = str(episode.season_number or 0) if season_key not in episodes_by_season: episodes_by_season[season_key] = [] episodes_by_season[season_key].append( { 'id': episode.id, 'title': episode.name, 'plot': episode.description, 'season_number': episode.season_number, 'episode_number': episode.episode_number, 'duration_secs': episode.duration_secs, 'rating': episode.rating, 'tmdb_id': episode.tmdb_id, 'imdb_id': episode.imdb_id, 'uuid': str(episode.uuid), 'library_media_item_ids': [ item.id for item in episode.library_items.filter(library__use_as_vod_source=True) ], } ) response_data = { 'id': series.id, 'series_id': series.id, 'name': series.name, 'description': series.description, 'year': series.year, 'genre': series.genre, 'rating': series.rating, 'tmdb_id': series.tmdb_id, 'imdb_id': series.imdb_id, 'category_id': None, 'category_name': None, 'cover': { 'id': series.logo.id, 'url': series_logo_cache_url or series.logo.url, 'name': series.logo.name, 'cache_url': series_logo_cache_url, } if series.logo else None, 'custom_properties': series.custom_properties or {}, 'm3u_account': { 'id': f'library-{library.id}', 'name': library.name, 'account_type': 'library', }, 'episodes_fetched': True, 'detailed_fetched': True, 'episodes': episodes_by_season, 'library_item_id': library_item.id, 'library_sources': [ { 'library_id': library.id, 'library_name': library.name, 'media_item_id': library_item.id, } ], } return Response(response_data) # Get the highest priority active relation relation = M3USeriesRelation.objects.filter( series=series, m3u_account__is_active=True ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() if not relation: return Response( {'error': 'No active M3U account associated with this series'}, status=status.HTTP_400_BAD_REQUEST ) try: # Check if we should refresh data (optional force refresh parameter) force_refresh = request.query_params.get('force_refresh', 'false').lower() == 'true' refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours now = timezone.now() last_refreshed = relation.last_episode_refresh # 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} needs detailed/episode refresh, forcing refresh") elif last_refreshed is None or (now - last_refreshed) > timedelta(hours=refresh_interval_hours): force_refresh = True logger.debug(f"Series {series.id} refresh interval exceeded or never refreshed, forcing refresh") if force_refresh: logger.debug(f"Refreshing series {series.id} data from provider") # Use existing refresh logic with external_series_id from .tasks import refresh_series_episodes account = relation.m3u_account if account and account.is_active: 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': relation.external_series_id, 'name': series.name, 'description': series.description, 'year': series.year, 'genre': series.genre, 'rating': series.rating, 'tmdb_id': series.tmdb_id, 'imdb_id': series.imdb_id, '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, 'm3u_account': { '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 if they've been fetched include_episodes = request.query_params.get('include_episodes', 'true').lower() == 'true' 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'): season_key = str(episode.season_number or 0) 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, 'name': episode.name, 'title': episode.name, 'episode_number': episode.episode_number, 'season_number': episode.season_number, 'description': episode.description, 'air_date': episode.air_date, 'plot': episode.description, 'duration_secs': episode.duration_secs, 'rating': episode.rating, 'tmdb_id': episode.tmdb_id, 'imdb_id': episode.imdb_id, 'movie_image': episode.custom_properties.get('movie_image', '') if episode.custom_properties else '', 'container_extension': episode_relation.container_extension if episode_relation else 'mp4', 'type': 'episode', 'series': { 'id': series.id, 'name': series.name } } episodes_by_season[season_key].append(episode_data) 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) except Exception as e: logger.error(f"Error fetching series info for series {pk}: {str(e)}") return Response( {'error': f'Failed to fetch series information: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) class VODCategoryFilter(django_filters.FilterSet): name = django_filters.CharFilter(lookup_expr="icontains") category_type = django_filters.ChoiceFilter(choices=VODCategory.CATEGORY_TYPE_CHOICES) m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") class Meta: model = VODCategory fields = ['name', 'category_type', 'm3u_account'] class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet for VOD Categories""" queryset = VODCategory.objects.all() serializer_class = VODCategorySerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = VODCategoryFilter search_fields = ['name'] ordering = ['name'] def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): """ViewSet that combines Movies and Series for unified 'All' view""" queryset = Movie.objects.none() # Empty queryset, we override list method serializer_class = MovieSerializer # Default serializer, overridden in list pagination_class = VODPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] search_fields = ['name', 'description', 'genre'] ordering_fields = ['name', 'year', 'created_at'] ordering = ['name'] def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] except KeyError: return [Authenticated()] def list(self, request, *args, **kwargs): """Override list to handle unified content properly - database-level approach""" import logging from django.db import connection logger = logging.getLogger(__name__) logger.error("=== UnifiedContentViewSet.list() called ===") try: # Get pagination parameters page_size = int(request.query_params.get('page_size', 24)) page_number = int(request.query_params.get('page', 1)) logger.error(f"Page {page_number}, page_size {page_size}") # Calculate offset for unified pagination offset = (page_number - 1) * page_size # For high page numbers, use raw SQL for efficiency # This avoids loading and sorting massive amounts of data in Python search = request.query_params.get('search', '') category = request.query_params.get('category', '') # Build base WHERE clauses movie_m3u_condition = ( "movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr " "JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id " "WHERE ma.is_active = true)" ) movie_library_condition = ( "EXISTS (SELECT 1 FROM media_library_mediaitem mi " "JOIN media_library_library lib ON mi.library_id = lib.id " "WHERE mi.vod_movie_id = movies.id AND lib.use_as_vod_source = true)" ) series_m3u_condition = ( "series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr " "JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id " "WHERE ma.is_active = true)" ) series_library_condition = ( "EXISTS (SELECT 1 FROM media_library_mediaitem mi " "JOIN media_library_library lib ON mi.library_id = lib.id " "WHERE mi.vod_series_id = series.id AND lib.use_as_vod_source = true)" ) movie_where = f"(({movie_m3u_condition}) OR ({movie_library_condition}))" series_where = f"(({series_m3u_condition}) OR ({series_library_condition}))" params = [] if search: movie_where += " AND LOWER(movies.name) LIKE %s" series_where += " AND LOWER(series.name) LIKE %s" search_param = f"%{search.lower()}%" params.extend([search_param, search_param]) if category: if '|' in category: cat_name, cat_type = category.split('|', 1) if cat_type == 'movie': movie_where += ( " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr " "JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" ) series_where = "1=0" # Exclude series params.append(cat_name) elif cat_type == 'series': series_where += ( " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr " "JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" ) movie_where = "1=0" # Exclude movies params.append(cat_name) else: movie_where += ( " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr " "JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" ) series_where += ( " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr " "JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" ) params.extend([category, category]) # Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination # This is much more efficient than Python sorting sql = f""" WITH unified_content AS ( SELECT movies.id, movies.uuid, movies.name, movies.description, movies.year, movies.rating, movies.genre, movies.duration_secs as duration, movies.created_at, movies.updated_at, movies.custom_properties, movies.logo_id, logo.name as logo_name, logo.url as logo_url, 'movie' as content_type FROM vod_movie movies LEFT JOIN dispatcharr_channels_logo logo ON movies.logo_id = logo.id WHERE {movie_where} UNION ALL SELECT series.id, series.uuid, series.name, series.description, series.year, series.rating, series.genre, NULL as duration, series.created_at, series.updated_at, series.custom_properties, series.logo_id, logo.name as logo_name, logo.url as logo_url, 'series' as content_type FROM vod_series series LEFT JOIN dispatcharr_channels_logo logo ON series.logo_id = logo.id WHERE {series_where} ) SELECT * FROM unified_content ORDER BY LOWER(name), id LIMIT %s OFFSET %s """ params.extend([page_size, offset]) logger.error(f"Executing SQL with LIMIT {page_size} OFFSET {offset}") with connection.cursor() as cursor: cursor.execute(sql, params) columns = [col[0] for col in cursor.description] results = [] for row in cursor.fetchall(): item_dict = dict(zip(columns, row)) custom_props = item_dict.get('custom_properties') or {} if isinstance(custom_props, str): try: custom_props = json.loads(custom_props) except json.JSONDecodeError: custom_props = {} # Build logo object in the format expected by frontend logo_data = None poster_candidate = None if item_dict['logo_id']: cache_url = build_logo_cache_url(request, item_dict['logo_id']) poster_candidate = cache_url or item_dict.get('logo_url') logo_data = { 'id': item_dict['logo_id'], 'name': item_dict['logo_name'], 'url': item_dict['logo_url'], 'cache_url': cache_url, 'channel_count': 0, 'is_used': True, 'channel_names': [] } if not poster_candidate: poster_candidate = custom_props.get('poster_url') or custom_props.get('cover') backdrop_values = [] if isinstance(custom_props.get('backdrop_path'), list): backdrop_values = custom_props['backdrop_path'] elif custom_props.get('backdrop_url'): backdrop_values = [custom_props['backdrop_url']] rating_value = item_dict['rating'] try: rating_parsed = float(rating_value) if rating_value is not None else 0.0 except (TypeError, ValueError): rating_parsed = rating_value # Convert to the format expected by frontend formatted_item = { 'id': item_dict['id'], 'uuid': str(item_dict['uuid']), 'name': item_dict['name'], 'description': item_dict['description'] or '', 'year': item_dict['year'], 'rating': rating_parsed, 'genre': item_dict['genre'] or '', 'duration': item_dict['duration'], 'duration_secs': item_dict['duration'], 'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None, 'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None, 'custom_properties': custom_props, 'logo': logo_data, 'movie_image': poster_candidate if item_dict['content_type'] == 'movie' else None, 'series_image': poster_candidate if item_dict['content_type'] == 'series' else None, 'backdrop_path': backdrop_values, 'content_type': item_dict['content_type'] } results.append(formatted_item) logger.error(f"Retrieved {len(results)} results via SQL") # Get total count estimate (for pagination info) # Use a separate efficient count query count_sql = f""" SELECT COUNT(*) FROM ( SELECT 1 FROM vod_movie movies WHERE {movie_where} UNION ALL SELECT 1 FROM vod_series series WHERE {series_where} ) as total_count """ count_params = params[:-2] # Remove LIMIT and OFFSET params with connection.cursor() as cursor: cursor.execute(count_sql, count_params) total_count = cursor.fetchone()[0] response_data = { 'count': total_count, 'next': offset + page_size < total_count, 'previous': page_number > 1, 'results': results } return Response(response_data) except Exception as e: logger.error(f"Error in UnifiedContentViewSet.list(): {e}") import traceback logger.error(traceback.format_exc()) return Response({'error': str(e)}, status=500)