diff --git a/apps/output/views.py b/apps/output/views.py index 2e51fa01..51cd84d9 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1042,12 +1042,12 @@ def xc_get_vod_categories(user): def xc_get_vod_streams(request, user, category_id=None): """Get VOD streams (movies) for XtreamCodes API""" - from apps.vod.models import VOD + from apps.vod.models import Movie streams = [] # Build filters based on user access - filters = {"type": "movie", "m3u_account__is_active": True} + filters = {"m3u_account__is_active": True} if user.user_level == 0: # For regular users, filter by accessible M3U accounts @@ -1065,28 +1065,28 @@ def xc_get_vod_streams(request, user, category_id=None): if category_id: filters["category_id"] = category_id - vods = VOD.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') + movies = Movie.objects.filter(**filters).select_related('category', 'logo', 'm3u_account') - for vod in vods: + for movie in movies: streams.append({ - "num": vod.id, - "name": vod.name, + "num": movie.id, + "name": movie.name, "stream_type": "movie", - "stream_id": vod.id, + "stream_id": movie.id, "stream_icon": ( - None if not vod.logo + None if not movie.logo else request.build_absolute_uri( - reverse("api:channels:logo-cache", args=[vod.logo.id]) + reverse("api:channels:logo-cache", args=[movie.logo.id]) ) ), - "rating": vod.rating or "0", - "rating_5based": float(vod.rating or 0) / 2 if vod.rating else 0, + "rating": movie.rating or "0", + "rating_5based": float(movie.rating or 0) / 2 if movie.rating else 0, "added": int(time.time()), # TODO: use actual created date "is_adult": 0, - "category_id": str(vod.category.id) if vod.category else "0", - "container_extension": vod.container_extension or "mp4", + "category_id": str(movie.category.id) if movie.category else "0", + "container_extension": movie.container_extension or "mp4", "custom_sid": None, - "direct_source": vod.url, + "direct_source": movie.url, }) return streams @@ -1186,7 +1186,7 @@ def xc_get_series(request, user, category_id=None): def xc_get_series_info(request, user, series_id): """Get detailed series information including episodes""" - from apps.vod.models import Series, VOD + from apps.vod.models import Series, Episode if not series_id: raise Http404() @@ -1212,9 +1212,8 @@ def xc_get_series_info(request, user, series_id): raise Http404() # Get episodes grouped by season - episodes = VOD.objects.filter( - series=serie, - type="episode" + episodes = Episode.objects.filter( + series=serie ).order_by('season_number', 'episode_number') # Group episodes by season @@ -1285,13 +1284,13 @@ def xc_get_series_info(request, user, series_id): def xc_get_vod_info(request, user, vod_id): """Get detailed VOD (movie) information""" - from apps.vod.models import VOD + from apps.vod.models import Movie if not vod_id: raise Http404() - # Get VOD with user access filtering - filters = {"id": vod_id, "type": "movie", "m3u_account__is_active": True} + # Get Movie with user access filtering + filters = {"id": vod_id, "m3u_account__is_active": True} if user.user_level == 0: if user.channel_profiles.count() > 0: @@ -1306,55 +1305,56 @@ def xc_get_vod_info(request, user, vod_id): raise Http404() try: - vod = VOD.objects.get(**filters) - except VOD.DoesNotExist: + movie = Movie.objects.get(**filters) + except Movie.DoesNotExist: raise Http404() info = { "info": { - "tmdb_id": vod.tmdb_id or "", - "name": vod.name, - "o_name": vod.name, + "tmdb_id": movie.tmdb_id or "", + "name": movie.name, + "o_name": movie.name, "cover_big": ( - None if not vod.logo + None if not movie.logo else request.build_absolute_uri( - reverse("api:channels:logo-cache", args=[vod.logo.id]) + reverse("api:channels:logo-cache", args=[movie.logo.id]) ) ), "movie_image": ( - None if not vod.logo + None if not movie.logo else request.build_absolute_uri( - reverse("api:channels:logo-cache", args=[vod.logo.id]) + reverse("api:channels:logo-cache", args=[movie.logo.id]) ) ), - "releasedate": f"{vod.year}-01-01" if vod.year else "", - "episode_run_time": (vod.duration or 0) * 60, + "releasedate": f"{movie.year}-01-01" if movie.year else "", + "episode_run_time": (movie.duration or 0) * 60, "youtube_trailer": "", "director": "", "actors": "", "cast": "", - "description": vod.description or "", - "plot": vod.description or "", + "description": movie.description or "", + "plot": movie.description or "", "age": "", "country": "", - "genre": vod.genre or "", + "genre": movie.genre or "", "backdrop_path": [], - "duration_secs": (vod.duration or 0) * 60, - "duration": f"{vod.duration or 0} min", + "duration_secs": (movie.duration or 0) * 60, + "duration": f"{movie.duration or 0} min", "video": {}, "audio": {}, "bitrate": 0, - "rating": float(vod.rating or 0), + "rating": float(movie.rating or 0), }, "movie_data": { - "stream_id": vod.id, - "name": vod.name, + "stream_id": movie.id, + "name": movie.name, "added": int(time.time()), - "category_id": str(vod.category.id) if vod.category else "0", - "container_extension": vod.container_extension or "mp4", + "category_id": str(movie.category.id) if movie.category else "0", + "container_extension": movie.container_extension or "mp4", "custom_sid": "", - "direct_source": vod.url, + "direct_source": movie.url, } } return info + return info diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py index 41a27600..c867f19b 100644 --- a/apps/proxy/vod_proxy/urls.py +++ b/apps/proxy/vod_proxy/urls.py @@ -4,6 +4,11 @@ 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'), + # Movie streaming + path('movie/', views.stream_movie, name='stream_movie'), + path('movie//position', views.update_movie_position, name='update_movie_position'), + + # Episode streaming + path('episode/', views.stream_episode, name='stream_episode'), + path('episode//position', views.update_episode_position, name='update_episode_position'), ] diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 75d50ca0..2e2d76f6 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -5,10 +5,10 @@ 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 django.contrib.contenttypes.models import ContentType from rest_framework.decorators import api_view -from apps.vod.models import VOD, VODConnection - +from apps.vod.models import Movie, Episode, VODConnection from apps.m3u.models import M3UAccountProfile from dispatcharr.utils import network_access_allowed, get_client_ip from core.models import UserAgent, CoreSettings @@ -18,25 +18,37 @@ logger = logging.getLogger(__name__) @csrf_exempt @api_view(["GET"]) -def stream_vod(request, vod_uuid): - """Stream VOD content with connection tracking and range support""" +def stream_movie(request, movie_uuid): + """Stream movie content with connection tracking and range support""" + return _stream_content(request, Movie, movie_uuid, "movie") + + +@csrf_exempt +@api_view(["GET"]) +def stream_episode(request, episode_uuid): + """Stream episode content with connection tracking and range support""" + return _stream_content(request, Episode, episode_uuid, "episode") + + +def _stream_content(request, model_class, content_uuid, content_type_name): + """Generic function to stream VOD content""" if not network_access_allowed(request, "STREAMS"): return JsonResponse({"error": "Forbidden"}, status=403) - # Get VOD object - vod = get_object_or_404(VOD, uuid=vod_uuid) + # Get content object + content = get_object_or_404(model_class, uuid=content_uuid) # Generate client ID and get client info client_id = f"vod_client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" client_ip = get_client_ip(request) client_user_agent = request.META.get('HTTP_USER_AGENT', '') - logger.info(f"[{client_id}] VOD stream request for: {vod.name}") + logger.info(f"[{client_id}] VOD stream request for: {content.name}") try: # Get available M3U profile for connection management - m3u_account = vod.m3u_account + m3u_account = content.m3u_account available_profile = None for profile in m3u_account.profiles.filter(is_active=True): @@ -51,9 +63,11 @@ def stream_vod(request, vod_uuid): status=503 ) - # Create connection tracking record + # Create connection tracking record using generic foreign key + content_type = ContentType.objects.get_for_model(content) connection = VODConnection.objects.create( - vod=vod, + content_type=content_type, + object_id=content.id, m3u_profile=available_profile, client_id=client_id, client_ip=client_ip, @@ -83,7 +97,7 @@ def stream_vod(request, vod_uuid): # Stream the VOD content try: response = requests.get( - vod.url, + content.url, headers=headers, stream=True, timeout=(10, 60) @@ -98,7 +112,7 @@ def stream_vod(request, vod_uuid): ) # Determine content type - content_type = response.headers.get('Content-Type', 'video/mp4') + content_type_header = response.headers.get('Content-Type', 'video/mp4') content_length = response.headers.get('Content-Length') content_range = response.headers.get('Content-Range') @@ -132,7 +146,7 @@ def stream_vod(request, vod_uuid): # Build response with appropriate headers streaming_response = StreamingHttpResponse( stream_generator(), - content_type=content_type, + content_type=content_type_header, status=response.status_code ) @@ -147,7 +161,7 @@ def stream_vod(request, vod_uuid): streaming_response['Access-Control-Allow-Origin'] = '*' streaming_response['Cache-Control'] = 'no-cache' - logger.info(f"[{client_id}] Started streaming VOD: {vod.name}") + logger.info(f"[{client_id}] Started streaming {content_type_name}: {content.name}") return streaming_response except requests.RequestException as e: @@ -168,8 +182,20 @@ def stream_vod(request, vod_uuid): @csrf_exempt @api_view(["POST"]) -def update_position(request, vod_uuid): - """Update playback position for a VOD""" +def update_movie_position(request, movie_uuid): + """Update playback position for a movie""" + return _update_position(request, Movie, movie_uuid) + + +@csrf_exempt +@api_view(["POST"]) +def update_episode_position(request, episode_uuid): + """Update playback position for an episode""" + return _update_position(request, Episode, episode_uuid) + + +def _update_position(request, model_class, content_uuid): + """Generic function to update playback position""" if not network_access_allowed(request, "STREAMS"): return JsonResponse({"error": "Forbidden"}, status=403) @@ -181,8 +207,13 @@ def update_position(request, vod_uuid): 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) + content = get_object_or_404(model_class, uuid=content_uuid) + content_type = ContentType.objects.get_for_model(content) + connection = VODConnection.objects.get( + content_type=content_type, + object_id=content.id, + client_id=client_id + ) connection.update_activity(position=position) return JsonResponse({"status": "success"}) diff --git a/apps/vod/admin.py b/apps/vod/admin.py index 4ef113fe..310a342f 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import VOD, Series, VODCategory, VODConnection +from .models import Series, VODCategory, VODConnection, Movie, Episode @admin.register(VODCategory) @@ -17,23 +17,42 @@ class SeriesAdmin(admin.ModelAdmin): 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'] +@admin.register(Movie) +class MovieAdmin(admin.ModelAdmin): + list_display = ['name', 'year', 'genre', 'duration', 'm3u_account', 'created_at'] + list_filter = ['m3u_account', 'category', 'year', 'created_at'] search_fields = ['name', 'description', 'stream_id'] readonly_fields = ['uuid', 'created_at', 'updated_at'] def get_queryset(self, request): - return super().get_queryset(request).select_related('series', 'm3u_account', 'category') + return super().get_queryset(request).select_related('category', 'logo', 'm3u_account') + + +@admin.register(Episode) +class EpisodeAdmin(admin.ModelAdmin): + list_display = ['name', 'series', 'season_number', 'episode_number', 'duration', 'm3u_account', 'created_at'] + list_filter = ['m3u_account', 'series', 'season_number', 'created_at'] + search_fields = ['name', 'description', 'stream_id', 'series__name'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('series', 'm3u_account') @admin.register(VODConnection) class VODConnectionAdmin(admin.ModelAdmin): - list_display = ['vod', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] + list_display = ['get_content_name', 'client_ip', 'client_id', 'connected_at', 'last_activity', 'position_seconds'] list_filter = ['connected_at', 'last_activity'] - search_fields = ['client_ip', 'client_id', 'vod__name'] + search_fields = ['client_ip', 'client_id'] readonly_fields = ['connected_at'] + def get_content_name(self, obj): + if obj.content_object: + return obj.content_object.name + elif obj.vod: + return obj.vod.name + return "Unknown" + get_content_name.short_description = "Content" + def get_queryset(self, request): - return super().get_queryset(request).select_related('vod', 'm3u_profile') + return super().get_queryset(request).select_related('content_object', 'm3u_profile') diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index ada3f55a..c7cd81b8 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -9,39 +9,38 @@ from apps.accounts.permissions import ( Authenticated, permission_classes_by_action, ) -from .models import VOD, Series, VODCategory, VODConnection +from .models import Series, VODCategory, VODConnection, Movie, Episode from .serializers import ( - VODSerializer, + MovieSerializer, + EpisodeSerializer, SeriesSerializer, VODCategorySerializer, VODConnectionSerializer ) -class VODFilter(django_filters.FilterSet): +class MovieFilter(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'] + model = Movie + fields = ['name', 'category', 'm3u_account', 'year'] -class VODViewSet(viewsets.ReadOnlyModelViewSet): - """ViewSet for VOD content (Movies and Episodes)""" - queryset = VOD.objects.all() - serializer_class = VODSerializer +class MovieViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Movie content""" + queryset = Movie.objects.all() + serializer_class = MovieSerializer filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] - filterset_class = VODFilter + filterset_class = MovieFilter search_fields = ['name', 'description', 'genre'] - ordering_fields = ['name', 'year', 'created_at', 'season_number', 'episode_number'] + ordering_fields = ['name', 'year', 'created_at'] ordering = ['name'] def get_permissions(self): @@ -51,37 +50,44 @@ class VODViewSet(viewsets.ReadOnlyModelViewSet): return [Authenticated()] def get_queryset(self): - return VOD.objects.select_related( - 'series', 'category', 'logo', 'm3u_account' + return Movie.objects.select_related( + '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) +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() - serializer = self.get_serializer(movies, many=True) - return Response(serializer.data) + class Meta: + model = Episode + fields = ['name', 'series', 'm3u_account', 'season_number', 'episode_number'] - @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) +class EpisodeViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Episode content""" + queryset = Episode.objects.all() + serializer_class = EpisodeSerializer - serializer = self.get_serializer(episodes, many=True) - return Response(serializer.data) + 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', 'm3u_account' + ).filter(m3u_account__is_active=True) class SeriesViewSet(viewsets.ReadOnlyModelViewSet): @@ -113,10 +119,10 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): page = self.paginate_queryset(episodes) if page is not None: - serializer = VODSerializer(page, many=True) + serializer = EpisodeSerializer(page, many=True) return self.get_paginated_response(serializer.data) - serializer = VODSerializer(episodes, many=True) + serializer = EpisodeSerializer(episodes, many=True) return Response(serializer.data) @@ -162,4 +168,4 @@ class VODConnectionViewSet(viewsets.ReadOnlyModelViewSet): return [Authenticated()] def get_queryset(self): - return VODConnection.objects.select_related('vod', 'm3u_profile') + return VODConnection.objects.select_related('m3u_profile') diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py index 6d1b7a98..77ffd927 100644 --- a/apps/vod/migrations/0001_initial.py +++ b/apps/vod/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.4 on 2025-08-02 16:59 +# Generated by Django 5.2.4 on 2025-08-02 18:26 import django.db.models.deletion import uuid @@ -10,11 +10,29 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), ('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'), ('m3u', '0012_alter_m3uaccount_refresh_interval'), ] operations = [ + 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)), + ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('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'], + 'unique_together': {('name', 'm3u_account', 'category_type')}, + }, + ), migrations.CreateModel( name='Series', fields=[ @@ -33,32 +51,37 @@ class Migration(migrations.Migration): ('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')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), ], options={ 'verbose_name': 'Series', 'verbose_name_plural': 'Series', 'ordering': ['name'], + 'unique_together': {('series_id', 'm3u_account')}, }, ), migrations.CreateModel( - name='VODCategory', + name='VODConnection', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vod_categories', to='m3u.m3uaccount')), + ('object_id', models.PositiveIntegerField()), + ('client_id', models.CharField(max_length=255)), + ('client_ip', models.GenericIPAddressField()), + ('user_agent', models.TextField(blank=True, null=True)), + ('connected_at', models.DateTimeField(auto_now_add=True)), + ('last_activity', models.DateTimeField(auto_now=True)), + ('bytes_sent', models.BigIntegerField(default=0)), + ('position_seconds', models.IntegerField(default=0, help_text='Current playback position')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('m3u_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vod_connections', to='m3u.m3uaccountprofile')), ], options={ - 'verbose_name': 'VOD Category', - 'verbose_name_plural': 'VOD Categories', - 'ordering': ['name'], - 'unique_together': {('name', 'm3u_account', 'category_type')}, + 'verbose_name': 'VOD Connection', + 'verbose_name_plural': 'VOD Connections', }, ), migrations.CreateModel( - name='VOD', + name='Episode', 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)), @@ -66,9 +89,7 @@ class Migration(migrations.Migration): ('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)), @@ -79,45 +100,44 @@ class Migration(migrations.Migration): ('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')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='m3u.m3uaccount')), + ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')), ], options={ - 'verbose_name': 'VOD', - 'verbose_name_plural': 'VODs', - 'ordering': ['name', 'season_number', 'episode_number'], + 'verbose_name': 'Episode', + 'verbose_name_plural': 'Episodes', + 'ordering': ['series__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', + name='Movie', 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')), + ('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)), + ('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='movies', to='m3u.m3uaccount')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), ], options={ - 'verbose_name': 'VOD Connection', - 'verbose_name_plural': 'VOD Connections', - 'unique_together': {('vod', 'client_id')}, + 'verbose_name': 'Movie', + 'verbose_name_plural': 'Movies', + 'ordering': ['name'], + 'unique_together': {('stream_id', 'm3u_account')}, }, ), ] diff --git a/apps/vod/models.py b/apps/vod/models.py index a12eecb4..b741206e 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -1,5 +1,7 @@ from django.db import models from django.utils import timezone +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from apps.m3u.models import M3UAccount from apps.channels.models import Logo import uuid @@ -7,16 +9,16 @@ import uuid class VODCategory(models.Model): """Categories for organizing VODs (e.g., Action, Comedy, Drama)""" - + CATEGORY_TYPE_CHOICES = [ ('movie', 'Movie'), ('series', 'Series'), ] - - name = models.CharField(max_length=255, unique=True) + + name = models.CharField(max_length=255) category_type = models.CharField( - max_length=10, - choices=CATEGORY_TYPE_CHOICES, + max_length=10, + choices=CATEGORY_TYPE_CHOICES, default='movie', help_text="Type of content this category contains" ) @@ -72,13 +74,8 @@ class Series(models.Model): 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'), - ] - +class Movie(models.Model): + """Movie content""" uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) name = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) @@ -86,12 +83,6 @@ class VOD(models.Model): rating = models.CharField(max_length=10, blank=True, null=True) genre = models.CharField(max_length=255, blank=True, null=True) duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") - 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) @@ -102,7 +93,7 @@ class VOD(models.Model): m3u_account = models.ForeignKey( M3UAccount, on_delete=models.CASCADE, - related_name='vods' + related_name='movies' ) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") container_extension = models.CharField(max_length=10, blank=True, null=True) @@ -118,25 +109,76 @@ class VOD(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - verbose_name = "VOD" - verbose_name_plural = "VODs" - ordering = ['name', 'season_number', 'episode_number'] + verbose_name = "Movie" + verbose_name_plural = "Movies" + ordering = ['name'] 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}" + """Generate the proxied stream URL for this movie""" + return f"/proxy/vod/movie/{self.uuid}" +class Episode(models.Model): + """Episode content for TV series""" + 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) + duration = models.IntegerField(blank=True, null=True, help_text="Duration in minutes") + + # Episode specific fields + series = models.ForeignKey(Series, on_delete=models.CASCADE, 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) + + # M3U relationship + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + related_name='episodes' + ) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Metadata IDs + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata") + + # Additional properties + custom_properties = models.JSONField(blank=True, null=True, help_text="JSON data for additional properties") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Episode" + verbose_name_plural = "Episodes" + ordering = ['series__name', 'season_number', 'episode_number'] + unique_together = ['stream_id', 'm3u_account'] + + def __str__(self): + season_ep = f"S{self.season_number:02d}E{self.episode_number:02d}" if self.season_number and self.episode_number else "" + return f"{self.series.name} {season_ep} - {self.name}" + + def get_stream_url(self): + """Generate the proxied stream URL for this episode""" + return f"/proxy/vod/episode/{self.uuid}" + class VODConnection(models.Model): """Track active VOD connections for connection limit management""" - vod = models.ForeignKey(VOD, on_delete=models.CASCADE, related_name='connections') + # Use generic foreign key to support both Movie and Episode + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + m3u_profile = models.ForeignKey( 'm3u.M3UAccountProfile', on_delete=models.CASCADE, @@ -153,10 +195,10 @@ class VODConnection(models.Model): 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})" + content_name = getattr(self.content_object, 'name', 'Unknown') if self.content_object else 'Unknown' + return f"{content_name} - {self.client_ip} ({self.client_id})" def update_activity(self, bytes_sent=0, position=0): """Update connection activity""" diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index f1a19293..5c709a8c 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import VOD, Series, VODCategory, VODConnection +from .models import Series, VODCategory, VODConnection, Movie, Episode from apps.channels.serializers import LogoSerializer from apps.m3u.serializers import M3UAccountSerializer @@ -26,15 +26,28 @@ class SeriesSerializer(serializers.ModelSerializer): return obj.episodes.count() -class VODSerializer(serializers.ModelSerializer): +class MovieSerializer(serializers.ModelSerializer): logo = LogoSerializer(read_only=True) category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + stream_url = serializers.SerializerMethodField() + + class Meta: + model = Movie + fields = '__all__' + + def get_stream_url(self, obj): + return obj.get_stream_url() + + +class EpisodeSerializer(serializers.ModelSerializer): + logo = LogoSerializer(read_only=True) series = SeriesSerializer(read_only=True) m3u_account = M3UAccountSerializer(read_only=True) stream_url = serializers.SerializerMethodField() class Meta: - model = VOD + model = Episode fields = '__all__' def get_stream_url(self, obj): @@ -42,8 +55,13 @@ class VODSerializer(serializers.ModelSerializer): class VODConnectionSerializer(serializers.ModelSerializer): - vod = VODSerializer(read_only=True) + content_name = serializers.SerializerMethodField() class Meta: model = VODConnection fields = '__all__' + + def get_content_name(self, obj): + if obj.content_object: + return getattr(obj.content_object, 'name', 'Unknown') + return 'Unknown' diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index aa9a3954..a01e60bb 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -5,7 +5,7 @@ import re from celery import shared_task from django.utils import timezone from datetime import timedelta -from .models import VOD, Series, VODCategory, VODConnection +from .models import Series, VODCategory, VODConnection, Movie, Episode from apps.m3u.models import M3UAccount from apps.channels.models import Logo @@ -161,11 +161,10 @@ def refresh_movies(account): # Extract year from title if not provided in API year = extract_year_from_data(movie_data, 'name') - vod_data = { + movie_data_dict = { 'name': movie_data['name'], - 'type': 'movie', 'url': stream_url, - 'category_id': category, + 'category': category, 'year': year, 'rating': movie_data.get('rating'), 'genre': movie_data.get('genre'), @@ -176,10 +175,11 @@ def refresh_movies(account): 'custom_properties': json.dumps(movie_data) if movie_data else None } - vod, created = VOD.objects.update_or_create( + # Use new Movie model + movie, created = Movie.objects.update_or_create( stream_id=movie_data['stream_id'], m3u_account=account, - defaults=vod_data + defaults=movie_data_dict ) # Handle logo @@ -188,8 +188,8 @@ def refresh_movies(account): url=movie_data['stream_icon'], defaults={'name': movie_data['name']} ) - vod.logo = logo - vod.save() + movie.logo = logo + movie.save() except Exception as e: logger.error(f"Error processing movie {movie_data.get('name', 'Unknown')}: {e}") @@ -326,7 +326,6 @@ def refresh_series_episodes(account, series, series_id): 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'), @@ -341,7 +340,8 @@ def refresh_series_episodes(account, series, series_id): 'custom_properties': json.dumps(episode_data) if episode_data else None } - VOD.objects.update_or_create( + # Use new Episode model + episode, created = Episode.objects.update_or_create( stream_id=episode_data['id'], m3u_account=account, defaults=episode_dict @@ -354,7 +354,6 @@ def refresh_series_episodes(account, series, series_id): 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""" diff --git a/apps/vod/urls.py b/apps/vod/urls.py index 4a00172a..f90e3fb6 100644 --- a/apps/vod/urls.py +++ b/apps/vod/urls.py @@ -1,11 +1,12 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .api_views import VODViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet +from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, VODConnectionViewSet app_name = 'vod' router = DefaultRouter() -router.register(r'vods', VODViewSet) +router.register(r'movies', MovieViewSet) +router.register(r'episodes', EpisodeViewSet) router.register(r'series', SeriesViewSet) router.register(r'categories', VODCategoryViewSet) router.register(r'connections', VODConnectionViewSet) diff --git a/frontend/src/api.js b/frontend/src/api.js index ae76fe75..1e046cbe 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1703,7 +1703,7 @@ export default class API { static async getVODs(params = {}) { try { const searchParams = new URLSearchParams(params); - const response = await request(`${host}/api/vod/vods/?${searchParams.toString()}`); + const response = await request(`${host}/api/vod/movies/?${searchParams.toString()}`); return response; } catch (e) { errorNotification('Failed to retrieve VODs', e);