From 84aa6311966e917e595cce5f0f7408d632c45672 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 2 Aug 2025 10:42:36 -0500 Subject: [PATCH] Initial backend commit for vod --- README.md | 2 + apps/output/views.py | 374 +++++++++++++++++++++++++++- apps/proxy/vod_proxy/__init__.py | 0 apps/proxy/vod_proxy/urls.py | 9 + apps/proxy/vod_proxy/views.py | 194 +++++++++++++++ apps/vod/__init__.py | 0 apps/vod/admin.py | 39 +++ apps/vod/api_views.py | 154 ++++++++++++ apps/vod/apps.py | 12 + apps/vod/migrations/0001_initial.py | 121 +++++++++ apps/vod/migrations/__init__.py | 0 apps/vod/models.py | 155 ++++++++++++ apps/vod/serializers.py | 47 ++++ apps/vod/tasks.py | 268 ++++++++++++++++++++ apps/vod/urls.py | 15 ++ dispatcharr/settings.py | 1 + dispatcharr/urls.py | 3 + 17 files changed, 1393 insertions(+), 1 deletion(-) create mode 100644 apps/proxy/vod_proxy/__init__.py create mode 100644 apps/proxy/vod_proxy/urls.py create mode 100644 apps/proxy/vod_proxy/views.py create mode 100644 apps/vod/__init__.py create mode 100644 apps/vod/admin.py create mode 100644 apps/vod/api_views.py create mode 100644 apps/vod/apps.py create mode 100644 apps/vod/migrations/0001_initial.py create mode 100644 apps/vod/migrations/__init__.py create mode 100644 apps/vod/models.py create mode 100644 apps/vod/serializers.py create mode 100644 apps/vod/tasks.py create mode 100644 apps/vod/urls.py diff --git a/README.md b/README.md index 5216663f..9b359e25 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and ๐Ÿ“Š **Real-Time Stats Dashboard** โ€” Live insights into stream health and client activity\ ๐Ÿง  **EPG Auto-Match** โ€” Match program data to channels automatically\ โš™๏ธ **Streamlink + FFmpeg Support** โ€” Flexible backend options for streaming and recording\ +๐ŸŽฌ **VOD Management** โ€” Full Video on Demand support with movies and TV series\ ๐Ÿงผ **UI & UX Enhancements** โ€” Smoother, faster, more responsive interface\ ๐Ÿ› **Output Compatibility** โ€” HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more @@ -31,6 +32,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and โœ… **Full IPTV Control** โ€” Import, organize, proxy, and monitor IPTV streams on your own terms\ โœ… **Smart Playlist Handling** โ€” M3U import, filtering, grouping, and failover support\ +โœ… **VOD Content Management** โ€” Organize movies and TV series with metadata and streaming\ โœ… **Reliable EPG Integration** โ€” Match and manage TV guide data with ease\ โœ… **Clean & Responsive Interface** โ€” Modern design that gets out of your way\ โœ… **Fully Self-Hosted** โ€” Total control, zero reliance on third-party services diff --git a/apps/output/views.py b/apps/output/views.py index 3fcd512b..2e51fa01 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -789,7 +789,20 @@ def xc_player_api(request, full=False): "get_series_info", "get_vod_info", ]: - return JsonResponse([], safe=False) + if action == "get_vod_categories": + return JsonResponse(xc_get_vod_categories(user), safe=False) + elif action == "get_vod_streams": + return JsonResponse(xc_get_vod_streams(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_categories": + return JsonResponse(xc_get_series_categories(user), safe=False) + elif action == "get_series": + return JsonResponse(xc_get_series(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_info": + return JsonResponse(xc_get_series_info(request, user, request.GET.get("series_id")), safe=False) + elif action == "get_vod_info": + return JsonResponse(xc_get_vod_info(request, user, request.GET.get("vod_id")), safe=False) + else: + return JsonResponse([], safe=False) raise Http404() @@ -986,3 +999,362 @@ def xc_get_epg(request, user, short=False): output['epg_listings'].append(program_output) return output + + +def xc_get_vod_categories(user): + """Get VOD categories for XtreamCodes API""" + from apps.vod.models import VODCategory + + response = [] + + # Filter categories based on user's M3U accounts + if user.user_level == 0: + # For regular users, get categories from their accessible M3U accounts + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + # Get M3U accounts accessible through user's profiles + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + else: + m3u_accounts = [] + + categories = VODCategory.objects.filter( + m3u_account__in=m3u_accounts + ).distinct() + else: + # Admins can see all categories + categories = VODCategory.objects.filter( + m3u_account__is_active=True + ).distinct() + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_vod_streams(request, user, category_id=None): + """Get VOD streams (movies) for XtreamCodes API""" + from apps.vod.models import VOD + + streams = [] + + # Build filters based on user access + filters = {"type": "movie", "m3u_account__is_active": True} + + if user.user_level == 0: + # For regular users, filter by accessible M3U accounts + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + return [] # No accessible accounts + + if category_id: + filters["category_id"] = category_id + + vods = VOD.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + + for vod in vods: + streams.append({ + "num": vod.id, + "name": vod.name, + "stream_type": "movie", + "stream_id": vod.id, + "stream_icon": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "rating": vod.rating or "0", + "rating_5based": float(vod.rating or 0) / 2 if vod.rating else 0, + "added": int(time.time()), # TODO: use actual created date + "is_adult": 0, + "category_id": str(vod.category.id) if vod.category else "0", + "container_extension": vod.container_extension or "mp4", + "custom_sid": None, + "direct_source": vod.url, + }) + + return streams + + +def xc_get_series_categories(user): + """Get series categories for XtreamCodes API""" + from apps.vod.models import VODCategory + + response = [] + + # Similar filtering as VOD categories but for series + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + else: + m3u_accounts = [] + + categories = VODCategory.objects.filter( + m3u_account__in=m3u_accounts, + series__isnull=False # Only categories that have series + ).distinct() + else: + categories = VODCategory.objects.filter( + m3u_account__is_active=True, + series__isnull=False + ).distinct() + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_series(request, user, category_id=None): + """Get series list for XtreamCodes API""" + from apps.vod.models import Series + + series_list = [] + + # Build filters based on user access + filters = {"m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + return [] + + if category_id: + filters["category_id"] = category_id + + series = Series.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + + for serie in series: + series_list.append({ + "num": serie.id, + "name": serie.name, + "series_id": serie.id, + "cover": ( + None if not serie.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[serie.logo.id]) + ) + ), + "plot": serie.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, + "backdrop_path": [], + "youtube_trailer": "", + "episode_run_time": "", + "category_id": str(serie.category.id) if serie.category else "0", + }) + + return series_list + + +def xc_get_series_info(request, user, series_id): + """Get detailed series information including episodes""" + from apps.vod.models import Series, VOD + + if not series_id: + raise Http404() + + # Get series with user access filtering + filters = {"id": series_id, "m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + raise Http404() + + try: + serie = Series.objects.get(**filters) + except Series.DoesNotExist: + raise Http404() + + # Get episodes grouped by season + episodes = VOD.objects.filter( + series=serie, + type="episode" + ).order_by('season_number', 'episode_number') + + # Group episodes by season + seasons = {} + for episode in episodes: + season_num = episode.season_number or 1 + if season_num not in seasons: + seasons[season_num] = [] + + seasons[season_num].append({ + "id": episode.stream_id, + "episode_num": episode.episode_number or 0, + "title": episode.name, + "container_extension": episode.container_extension or "mp4", + "info": { + "air_date": f"{episode.year}-01-01" if episode.year else "", + "crew": "", + "directed_by": "", + "episode_num": episode.episode_number or 0, + "id": episode.stream_id, + "imdb_id": episode.imdb_id or "", + "name": episode.name, + "overview": episode.description or "", + "production_code": "", + "season_number": episode.season_number or 1, + "still_path": "", + "vote_average": float(episode.rating or 0), + "vote_count": 0, + "writer": "", + "release_date": f"{episode.year}-01-01" if episode.year else "", + "duration_secs": (episode.duration or 0) * 60, + "duration": f"{episode.duration or 0} min", + "video": {}, + "audio": {}, + "bitrate": 0, + } + }) + + # Build response + info = { + "seasons": list(seasons.keys()), + "info": { + "name": serie.name, + "cover": ( + None if not serie.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[serie.logo.id]) + ) + ), + "plot": serie.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, + "backdrop_path": [], + "youtube_trailer": "", + "episode_run_time": "", + "category_id": str(serie.category.id) if serie.category else "0", + }, + "episodes": dict(seasons) + } + + return info + + +def xc_get_vod_info(request, user, vod_id): + """Get detailed VOD (movie) information""" + from apps.vod.models import VOD + + if not vod_id: + raise Http404() + + # Get VOD with user access filtering + filters = {"id": vod_id, "type": "movie", "m3u_account__is_active": True} + + if user.user_level == 0: + if user.channel_profiles.count() > 0: + channel_profiles = user.channel_profiles.all() + from apps.m3u.models import M3UAccount + m3u_accounts = M3UAccount.objects.filter( + is_active=True, + profiles__in=channel_profiles + ).distinct() + filters["m3u_account__in"] = m3u_accounts + else: + raise Http404() + + try: + vod = VOD.objects.get(**filters) + except VOD.DoesNotExist: + raise Http404() + + info = { + "info": { + "tmdb_id": vod.tmdb_id or "", + "name": vod.name, + "o_name": vod.name, + "cover_big": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "movie_image": ( + None if not vod.logo + else request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[vod.logo.id]) + ) + ), + "releasedate": f"{vod.year}-01-01" if vod.year else "", + "episode_run_time": (vod.duration or 0) * 60, + "youtube_trailer": "", + "director": "", + "actors": "", + "cast": "", + "description": vod.description or "", + "plot": vod.description or "", + "age": "", + "country": "", + "genre": vod.genre or "", + "backdrop_path": [], + "duration_secs": (vod.duration or 0) * 60, + "duration": f"{vod.duration or 0} min", + "video": {}, + "audio": {}, + "bitrate": 0, + "rating": float(vod.rating or 0), + }, + "movie_data": { + "stream_id": vod.id, + "name": vod.name, + "added": int(time.time()), + "category_id": str(vod.category.id) if vod.category else "0", + "container_extension": vod.container_extension or "mp4", + "custom_sid": "", + "direct_source": vod.url, + } + } + + return info diff --git a/apps/proxy/vod_proxy/__init__.py b/apps/proxy/vod_proxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py new file mode 100644 index 00000000..41a27600 --- /dev/null +++ b/apps/proxy/vod_proxy/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'vod_proxy' + +urlpatterns = [ + path('stream/', views.stream_vod, name='stream_vod'), + path('stream//position', views.update_position, name='update_position'), +] diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py new file mode 100644 index 00000000..75d50ca0 --- /dev/null +++ b/apps/proxy/vod_proxy/views.py @@ -0,0 +1,194 @@ +import time +import random +import logging +import requests +from django.http import StreamingHttpResponse, JsonResponse +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 VOD, VODConnection + +from apps.m3u.models import M3UAccountProfile +from dispatcharr.utils import network_access_allowed, get_client_ip +from core.models import UserAgent, CoreSettings + +logger = logging.getLogger(__name__) + + +@csrf_exempt +@api_view(["GET"]) +def stream_vod(request, vod_uuid): + """Stream VOD content with connection tracking and range support""" + + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + # Get VOD object + vod = get_object_or_404(VOD, uuid=vod_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: {vod.name}") + + try: + # Get available M3U profile for connection management + m3u_account = vod.m3u_account + available_profile = None + + for profile in m3u_account.profiles.filter(is_active=True): + current_connections = VODConnection.objects.filter(m3u_profile=profile).count() + 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 + connection = VODConnection.objects.create( + vod=vod, + m3u_profile=available_profile, + client_id=client_id, + client_ip=client_ip, + user_agent=client_user_agent + ) + + # 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 + + # Handle range requests for seeking + range_header = request.META.get('HTTP_RANGE') + headers = { + 'User-Agent': upstream_user_agent, + 'Connection': 'keep-alive' + } + + if range_header: + headers['Range'] = range_header + logger.debug(f"[{client_id}] Range request: {range_header}") + + # Stream the VOD content + try: + response = requests.get( + vod.url, + headers=headers, + stream=True, + timeout=(10, 60) + ) + + if response.status_code not in [200, 206]: + logger.error(f"[{client_id}] Upstream error: {response.status_code}") + connection.delete() + return JsonResponse( + {"error": f"Upstream server error: {response.status_code}"}, + status=response.status_code + ) + + # Determine content type + content_type = response.headers.get('Content-Type', 'video/mp4') + content_length = response.headers.get('Content-Length') + content_range = response.headers.get('Content-Range') + + # Create streaming response + def stream_generator(): + bytes_sent = 0 + try: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + bytes_sent += len(chunk) + yield chunk + + # Update connection activity periodically + if bytes_sent % (8192 * 10) == 0: # Every ~80KB + try: + connection.update_activity(bytes_sent=len(chunk)) + except VODConnection.DoesNotExist: + # Connection was cleaned up, stop streaming + break + + except Exception as e: + logger.error(f"[{client_id}] Streaming error: {e}") + finally: + # Clean up connection when streaming ends + try: + connection.delete() + logger.info(f"[{client_id}] Connection cleaned up") + except VODConnection.DoesNotExist: + pass + + # Build response with appropriate headers + streaming_response = StreamingHttpResponse( + stream_generator(), + content_type=content_type, + status=response.status_code + ) + + # Copy important headers + if content_length: + streaming_response['Content-Length'] = content_length + if content_range: + streaming_response['Content-Range'] = content_range + + # Add CORS and caching headers + streaming_response['Accept-Ranges'] = 'bytes' + streaming_response['Access-Control-Allow-Origin'] = '*' + streaming_response['Cache-Control'] = 'no-cache' + + logger.info(f"[{client_id}] Started streaming VOD: {vod.name}") + return streaming_response + + except requests.RequestException as e: + logger.error(f"[{client_id}] Request error: {e}") + connection.delete() + return JsonResponse( + {"error": "Failed to connect to upstream server"}, + status=502 + ) + + except Exception as e: + logger.error(f"[{client_id}] Unexpected error: {e}") + return JsonResponse( + {"error": "Internal server error"}, + status=500 + ) + + +@csrf_exempt +@api_view(["POST"]) +def update_position(request, vod_uuid): + """Update playback position for a VOD""" + + 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: + vod = get_object_or_404(VOD, uuid=vod_uuid) + connection = VODConnection.objects.get(vod=vod, client_id=client_id) + connection.update_activity(position=position) + + return JsonResponse({"status": "success"}) + + except VODConnection.DoesNotExist: + return JsonResponse({"error": "Connection not found"}, status=404) + except Exception as e: + logger.error(f"Position update error: {e}") + return JsonResponse({"error": "Internal server error"}, status=500) diff --git a/apps/vod/__init__.py b/apps/vod/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/vod/admin.py b/apps/vod/admin.py new file mode 100644 index 00000000..6aa8cd3d --- /dev/null +++ b/apps/vod/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin +from .models import VOD, Series, VODCategory, VODConnection + + +@admin.register(VODCategory) +class VODCategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'm3u_account', 'created_at'] + list_filter = ['m3u_account', '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'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + +@admin.register(VOD) +class VODAdmin(admin.ModelAdmin): + list_display = ['name', 'type', 'series', 'season_number', 'episode_number', 'year', 'm3u_account'] + list_filter = ['type', 'm3u_account', 'category', 'year', 'created_at'] + search_fields = ['name', 'description', 'stream_id'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('series', 'm3u_account', 'category') + + +@admin.register(VODConnection) +class VODConnectionAdmin(admin.ModelAdmin): + list_display = ['vod', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] + list_filter = ['connected_at', 'last_activity'] + search_fields = ['client_ip', 'client_id', 'vod__name'] + readonly_fields = ['connected_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('vod', 'm3u_profile') diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py new file mode 100644 index 00000000..142dfad3 --- /dev/null +++ b/apps/vod/api_views.py @@ -0,0 +1,154 @@ +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 django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 +import django_filters +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, +) +from .models import VOD, Series, VODCategory, VODConnection +from .serializers import ( + VODSerializer, + SeriesSerializer, + VODCategorySerializer, + VODConnectionSerializer +) + + +class VODFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + type = django_filters.ChoiceFilter(choices=VOD.TYPE_CHOICES) + category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains") + series = django_filters.NumberFilter(field_name="series__id") + m3u_account = django_filters.NumberFilter(field_name="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 = VOD + fields = ['name', 'type', 'category', 'series', 'm3u_account', 'year'] + + +class VODViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for VOD content (Movies and Episodes)""" + queryset = VOD.objects.all() + serializer_class = VODSerializer + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = VODFilter + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at', 'season_number', 'episode_number'] + 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): + return VOD.objects.select_related( + 'series', 'category', 'logo', 'm3u_account' + ).filter(m3u_account__is_active=True) + + @action(detail=False, methods=['get']) + def movies(self, request): + """Get only movie content""" + movies = self.get_queryset().filter(type='movie') + movies = self.filter_queryset(movies) + + page = self.paginate_queryset(movies) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(movies, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def episodes(self, request): + """Get only episode content""" + episodes = self.get_queryset().filter(type='episode') + episodes = self.filter_queryset(episodes) + + page = self.paginate_queryset(episodes) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(episodes, many=True) + return Response(serializer.data) + + +class SeriesViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Series management""" + queryset = Series.objects.all() + serializer_class = SeriesSerializer + + 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 get_queryset(self): + return Series.objects.select_related( + 'category', 'logo', 'm3u_account' + ).prefetch_related('episodes').filter(m3u_account__is_active=True) + + @action(detail=True, methods=['get']) + def episodes(self, request, pk=None): + """Get episodes for a specific series""" + series = self.get_object() + episodes = series.episodes.all().order_by('season_number', 'episode_number') + + page = self.paginate_queryset(episodes) + if page is not None: + serializer = VODSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = VODSerializer(episodes, many=True) + return Response(serializer.data) + + +class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for VOD Categories""" + queryset = VODCategory.objects.all() + serializer_class = VODCategorySerializer + + filter_backends = [SearchFilter, OrderingFilter] + 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 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('vod', 'm3u_profile') diff --git a/apps/vod/apps.py b/apps/vod/apps.py new file mode 100644 index 00000000..0e2af56d --- /dev/null +++ b/apps/vod/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class VODConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.vod' + verbose_name = 'Video on Demand' + + def ready(self): + """Initialize VOD app when Django is ready""" + # Import models to ensure they're registered + from . import models diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py new file mode 100644 index 00000000..af882079 --- /dev/null +++ b/apps/vod/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.4 on 2025-08-02 15:33 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'), + ('m3u', '0012_alter_m3uaccount_refresh_interval'), + ] + + operations = [ + migrations.CreateModel( + name='Series', + 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)), + ('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)), + ('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='series', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'Series', + 'verbose_name_plural': 'Series', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='VODCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=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')), + ], + options={ + 'verbose_name': 'VOD Category', + 'verbose_name_plural': 'VOD Categories', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='VOD', + 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)), + ('type', models.CharField(choices=[('movie', 'Movie'), ('episode', 'Episode')], default='movie', max_length=10)), + ('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)), + ('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='vods', to='m3u.m3uaccount')), + ('series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'VOD', + 'verbose_name_plural': 'VODs', + 'ordering': ['name', 'season_number', 'episode_number'], + 'unique_together': {('stream_id', 'm3u_account')}, + }, + ), + migrations.AddField( + model_name='series', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory'), + ), + migrations.AlterUniqueTogether( + name='series', + 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')), + ('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')), + ('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')), + ('vod', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connections', to='vod.vod')), + ], + options={ + 'verbose_name': 'VOD Connection', + 'verbose_name_plural': 'VOD Connections', + 'unique_together': {('vod', 'client_id')}, + }, + ), + ] diff --git a/apps/vod/migrations/__init__.py b/apps/vod/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/vod/models.py b/apps/vod/models.py new file mode 100644 index 00000000..7302bfcd --- /dev/null +++ b/apps/vod/models.py @@ -0,0 +1,155 @@ +from django.db import models +from django.utils import timezone +from apps.m3u.models import M3UAccount +from apps.channels.models import Logo +import uuid + + +class VODCategory(models.Model): + """Categories for organizing VODs (e.g., Action, Comedy, Drama)""" + name = models.CharField(max_length=255, unique=True) + 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" + ordering = ['name'] + + def __str__(self): + return self.name + + +class Series(models.Model): + """Series information for TV shows""" + 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(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") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Series" + verbose_name_plural = "Series" + ordering = ['name'] + unique_together = ['series_id', 'm3u_account'] + + def __str__(self): + return f"{self.name} ({self.year or 'Unknown'})" + + +class VOD(models.Model): + """Video on Demand content (Movies and Episodes)""" + TYPE_CHOICES = [ + ('movie', 'Movie'), + ('episode', 'Episode'), + ] + + 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(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") + type = models.CharField(max_length=10, choices=TYPE_CHOICES, default='movie') + + # Episode specific fields + series = models.ForeignKey(Series, on_delete=models.CASCADE, null=True, blank=True, related_name='episodes') + season_number = models.IntegerField(blank=True, null=True) + episode_number = models.IntegerField(blank=True, null=True) + + # 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='vods' + ) + 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") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "VOD" + verbose_name_plural = "VODs" + ordering = ['name', 'season_number', 'episode_number'] + unique_together = ['stream_id', 'm3u_account'] + + def __str__(self): + if self.type == 'episode' and self.series: + 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}" + return f"{self.name} ({self.year or 'Unknown'})" + + def get_stream_url(self): + """Generate the proxied stream URL for this VOD""" + return f"/proxy/vod/stream/{self.uuid}" + + +class VODConnection(models.Model): + """Track active VOD connections for connection limit management""" + vod = models.ForeignKey(VOD, on_delete=models.CASCADE, related_name='connections') + 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 Meta: + verbose_name = "VOD Connection" + verbose_name_plural = "VOD Connections" + unique_together = ['vod', 'client_id'] + + def __str__(self): + return f"{self.vod.name} - {self.client_ip} ({self.client_id})" + + 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']) diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py new file mode 100644 index 00000000..1e070e9c --- /dev/null +++ b/apps/vod/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers +from .models import VOD, Series, VODCategory, VODConnection +from apps.channels.serializers import LogoSerializer +from apps.m3u.serializers import M3UAccountSerializer + + +class VODCategorySerializer(serializers.ModelSerializer): + class Meta: + model = VODCategory + fields = '__all__' + + +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: + model = Series + fields = '__all__' + + def get_episode_count(self, obj): + return obj.episodes.count() + + +class VODSerializer(serializers.ModelSerializer): + logo = LogoSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + series = SeriesSerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + stream_url = serializers.SerializerMethodField() + + class Meta: + model = VOD + fields = '__all__' + + def get_stream_url(self, obj): + return obj.get_stream_url() + + +class VODConnectionSerializer(serializers.ModelSerializer): + vod = VODSerializer(read_only=True) + + class Meta: + model = VODConnection + fields = '__all__' diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py new file mode 100644 index 00000000..89c08b11 --- /dev/null +++ b/apps/vod/tasks.py @@ -0,0 +1,268 @@ +import logging +import requests +import json +from celery import shared_task +from django.utils import timezone +from datetime import timedelta +from .models import VOD, Series, VODCategory, VODConnection +from apps.m3u.models import M3UAccount +from apps.channels.models import Logo + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True) +def refresh_vod_content(self, account_id): + """Refresh VOD content from XtreamCodes API""" + try: + account = M3UAccount.objects.get(id=account_id) + if account.account_type != M3UAccount.Types.XC: + logger.warning(f"Account {account_id} is not XtreamCodes type") + return + + # Get movies and series + refresh_movies(account) + refresh_series(account) + + logger.info(f"Successfully refreshed VOD content for account {account_id}") + + 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}") + + +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/update categories + for cat_data in categories_data: + VODCategory.objects.get_or_create( + name=cat_data['category_name'], + m3u_account=account, + defaults={'name': cat_data['category_name']} + ) + + # 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 + if movie_data.get('category_id'): + try: + category = VODCategory.objects.get( + name__icontains=movie_data.get('category_name', ''), + m3u_account=account + ) + except VODCategory.DoesNotExist: + pass + + # Create/update movie + stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}" + + vod_data = { + 'name': movie_data['name'], + 'type': 'movie', + 'url': stream_url, + 'category': category, + 'year': movie_data.get('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': json.dumps(movie_data) if movie_data else None + } + + vod, created = VOD.objects.update_or_create( + stream_id=movie_data['stream_id'], + m3u_account=account, + defaults=vod_data + ) + + # Handle logo + if movie_data.get('stream_icon'): + logo, _ = Logo.objects.get_or_create( + url=movie_data['stream_icon'], + defaults={'name': movie_data['name']} + ) + vod.logo = logo + vod.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/update series categories + for cat_data in categories_data: + VODCategory.objects.get_or_create( + name=cat_data['category_name'], + m3u_account=account, + defaults={'name': cat_data['category_name']} + ) + + # 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 + if series_item.get('category_id'): + try: + category = VODCategory.objects.get( + name__icontains=series_item.get('category_name', ''), + m3u_account=account + ) + except VODCategory.DoesNotExist: + pass + + # Create/update series + series_data_dict = { + 'name': series_item['name'], + 'description': series_item.get('plot'), + 'year': series_item.get('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': json.dumps(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() + + # Get series episodes + refresh_series_episodes(account, series, series_item['series_id']) + + 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')}" + + episode_dict = { + 'name': episode_data.get('title', f"Episode {episode_data.get('episode_num', '')}"), + 'type': 'episode', + '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_data.get('plot'), + 'year': episode_data.get('air_date', '').split('-')[0] if episode_data.get('air_date') else None, + 'rating': episode_data.get('rating'), + 'duration': episode_data.get('duration_secs', 0) // 60 if episode_data.get('duration_secs') else None, + 'container_extension': episode_data.get('container_extension'), + 'tmdb_id': episode_data.get('tmdb_id'), + 'imdb_id': episode_data.get('imdb_id'), + 'custom_properties': json.dumps(episode_data) if episode_data else None + } + + VOD.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 + + 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) + + count = inactive_connections.count() + if count > 0: + inactive_connections.delete() + logger.info(f"Cleaned up {count} inactive VOD connections") + + return count diff --git a/apps/vod/urls.py b/apps/vod/urls.py new file mode 100644 index 00000000..4a00172a --- /dev/null +++ b/apps/vod/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import VODViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet + +app_name = 'vod' + +router = DefaultRouter() +router.register(r'vods', VODViewSet) +router.register(r'series', SeriesViewSet) +router.register(r'categories', VODCategoryViewSet) +router.register(r'connections', VODConnectionViewSet) + +urlpatterns = [ + path('api/', include(router.urls)), +] diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index acac4c1a..040e9156 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -28,6 +28,7 @@ INSTALLED_APPS = [ "apps.output", "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", + "apps.vod.apps.VODConfig", "core", "daphne", "drf_yasg", diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 3e891314..143b6e4c 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -65,6 +65,9 @@ urlpatterns = [ path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), # Optionally, serve the raw Swagger JSON path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), + # VOD + path("api/vod/", include("apps.vod.urls")), + path("proxy/vod/", include("apps.proxy.vod_proxy.urls")), # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point path("", TemplateView.as_view(template_name="index.html")),