Dispatcharr/apps/vod/api_views.py
Dispatcharr f21cd216c3 p
2025-10-08 10:08:34 -05:00

956 lines
41 KiB
Python

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_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))
# Build logo object in the format expected by frontend
logo_data = None
if item_dict['logo_id']:
cache_url = build_logo_cache_url(request, item_dict['logo_id'])
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': []
}
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'],
'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': item_dict['custom_properties'] or {},
'logo': logo_data,
'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)