diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py index d4d6885b..e1db3dd4 100644 --- a/apps/m3u/admin.py +++ b/apps/m3u/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.utils.html import format_html from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent +import json class M3UFilterInline(admin.TabularInline): model = M3UFilter @@ -10,8 +11,8 @@ class M3UFilterInline(admin.TabularInline): @admin.register(M3UAccount) class M3UAccountAdmin(admin.ModelAdmin): - list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at') - list_filter = ('is_active', 'server_group') + list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'vod_enabled_display', 'uploaded_file_link', 'created_at', 'updated_at') + list_filter = ('is_active', 'server_group', 'account_type') search_fields = ('name', 'server_url', 'server_group__name') inlines = [M3UFilterInline] actions = ['activate_accounts', 'deactivate_accounts'] @@ -25,6 +26,18 @@ class M3UAccountAdmin(admin.ModelAdmin): return "None" user_agent_display.short_description = "User Agent(s)" + def vod_enabled_display(self, obj): + """Display whether VOD is enabled for this account""" + if obj.custom_properties: + try: + custom_props = json.loads(obj.custom_properties) + return "Yes" if custom_props.get('enable_vod', False) else "No" + except (json.JSONDecodeError, TypeError): + pass + return "No" + vod_enabled_display.short_description = "VOD Enabled" + vod_enabled_display.boolean = True + def uploaded_file_link(self, obj): if obj.uploaded_file: return format_html("Download M3U", obj.uploaded_file.url) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index d3739f19..c0ab1115 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -30,8 +30,7 @@ from .serializers import ( ) from .tasks import refresh_single_m3u_account, refresh_m3u_accounts -from django.core.files.storage import default_storage -from django.core.files.base import ContentFile +import json class M3UAccountViewSet(viewsets.ModelViewSet): @@ -78,15 +77,33 @@ class M3UAccountViewSet(viewsets.ModelViewSet): # Now call super().create() to create the instance response = super().create(request, *args, **kwargs) - print(response.data.get("account_type")) - if response.data.get("account_type") == M3UAccount.Types.XC: - refresh_m3u_groups(response.data.get("id")) + account_type = response.data.get("account_type") + account_id = response.data.get("id") + + if account_type == M3UAccount.Types.XC: + refresh_m3u_groups(account_id) + + # Check if VOD is enabled + enable_vod = request.data.get("enable_vod", False) + if enable_vod: + from apps.vod.tasks import refresh_vod_content + + refresh_vod_content.delay(account_id) # After the instance is created, return the response return response def update(self, request, *args, **kwargs): instance = self.get_object() + old_vod_enabled = False + + # Check current VOD setting + if instance.custom_properties: + try: + custom_props = json.loads(instance.custom_properties) + old_vod_enabled = custom_props.get("enable_vod", False) + except (json.JSONDecodeError, TypeError): + pass # Handle file upload first, if any file_path = None @@ -122,6 +139,18 @@ class M3UAccountViewSet(viewsets.ModelViewSet): # Now call super().update() to update the instance response = super().update(request, *args, **kwargs) + # Check if VOD setting changed and trigger refresh if needed + new_vod_enabled = request.data.get("enable_vod", old_vod_enabled) + + if ( + instance.account_type == M3UAccount.Types.XC + and not old_vod_enabled + and new_vod_enabled + ): + from apps.vod.tasks import refresh_vod_content + + refresh_vod_content.delay(instance.id) + # After the instance is updated, return the response return response @@ -143,6 +172,46 @@ class M3UAccountViewSet(viewsets.ModelViewSet): # Continue with regular partial update return super().partial_update(request, *args, **kwargs) + @action(detail=True, methods=["post"], url_path="refresh-vod") + def refresh_vod(self, request, pk=None): + """Trigger VOD content refresh for XtreamCodes accounts""" + account = self.get_object() + + if account.account_type != M3UAccount.Types.XC: + return Response( + {"error": "VOD refresh is only available for XtreamCodes accounts"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if VOD is enabled + vod_enabled = False + if account.custom_properties: + try: + custom_props = json.loads(account.custom_properties) + vod_enabled = custom_props.get("enable_vod", False) + except (json.JSONDecodeError, TypeError): + pass + + if not vod_enabled: + return Response( + {"error": "VOD is not enabled for this account"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + from apps.vod.tasks import refresh_vod_content + + refresh_vod_content.delay(account.id) + return Response( + {"message": f"VOD refresh initiated for account {account.name}"}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + return Response( + {"error": f"Failed to initiate VOD refresh: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + @action(detail=True, methods=["patch"], url_path="group-settings") def update_group_settings(self, request, pk=None): """Update auto channel sync settings for M3U account groups""" diff --git a/apps/m3u/forms.py b/apps/m3u/forms.py index f6fc7f91..dc29188a 100644 --- a/apps/m3u/forms.py +++ b/apps/m3u/forms.py @@ -4,6 +4,13 @@ from .models import M3UAccount, M3UFilter import re class M3UAccountForm(forms.ModelForm): + enable_vod = forms.BooleanField( + required=False, + initial=False, + label="Enable VOD Content", + help_text="Parse and import VOD (movies/series) content for XtreamCodes accounts" + ) + class Meta: model = M3UAccount fields = [ @@ -13,8 +20,44 @@ class M3UAccountForm(forms.ModelForm): 'server_group', 'max_streams', 'is_active', + 'enable_vod', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set initial value for enable_vod from custom_properties + if self.instance and self.instance.custom_properties: + try: + import json + custom_props = json.loads(self.instance.custom_properties) + self.fields['enable_vod'].initial = custom_props.get('enable_vod', False) + except (json.JSONDecodeError, TypeError): + pass + + def save(self, commit=True): + instance = super().save(commit=False) + + # Handle enable_vod field + enable_vod = self.cleaned_data.get('enable_vod', False) + + # Parse existing custom_properties + custom_props = {} + if instance.custom_properties: + try: + import json + custom_props = json.loads(instance.custom_properties) + except (json.JSONDecodeError, TypeError): + custom_props = {} + + # Update VOD preference + custom_props['enable_vod'] = enable_vod + instance.custom_properties = json.dumps(custom_props) + + if commit: + instance.save() + return instance + def clean_uploaded_file(self): uploaded_file = self.cleaned_data.get('uploaded_file') if uploaded_file: diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index a86227aa..3ed6ad19 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -9,6 +9,7 @@ from apps.channels.serializers import ( ChannelGroupSerializer, ) import logging +import json logger = logging.getLogger(__name__) @@ -83,6 +84,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): allow_null=True, validators=[validate_flexible_url], ) + enable_vod = serializers.BooleanField(required=False, write_only=True) class Meta: model = M3UAccount @@ -109,6 +111,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): "stale_stream_days", "status", "last_message", + "enable_vod", ] extra_kwargs = { "password": { @@ -117,7 +120,37 @@ class M3UAccountSerializer(serializers.ModelSerializer): }, } + def to_representation(self, instance): + data = super().to_representation(instance) + + # Parse custom_properties to get VOD preference + custom_props = {} + if instance.custom_properties: + try: + custom_props = json.loads(instance.custom_properties) + except (json.JSONDecodeError, TypeError): + custom_props = {} + + data["enable_vod"] = custom_props.get("enable_vod", False) + return data + def update(self, instance, validated_data): + # Handle enable_vod preference + enable_vod = validated_data.pop("enable_vod", None) + + if enable_vod is not None: + # Parse existing custom_properties + custom_props = {} + if instance.custom_properties: + try: + custom_props = json.loads(instance.custom_properties) + except (json.JSONDecodeError, TypeError): + custom_props = {} + + # Update VOD preference + custom_props["enable_vod"] = enable_vod + validated_data["custom_properties"] = json.dumps(custom_props) + # Pop out channel group memberships so we can handle them manually channel_group_data = validated_data.pop("channel_group", []) @@ -149,6 +182,24 @@ class M3UAccountSerializer(serializers.ModelSerializer): return instance + def create(self, validated_data): + # Handle enable_vod preference during creation + enable_vod = validated_data.pop("enable_vod", False) + + # Parse existing custom_properties or create new + custom_props = {} + if validated_data.get("custom_properties"): + try: + custom_props = json.loads(validated_data["custom_properties"]) + except (json.JSONDecodeError, TypeError): + custom_props = {} + + # Set VOD preference + custom_props["enable_vod"] = enable_vod + validated_data["custom_properties"] = json.dumps(custom_props) + + return super().create(validated_data) + class ServerGroupSerializer(serializers.ModelSerializer): """Serializer for Server Group""" diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 7598211a..bea81c49 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1265,6 +1265,16 @@ def refresh_single_m3u_account(account_id): account.save(update_fields=['status']) filters = list(account.filters.all()) + + # Check if VOD is enabled for this account + vod_enabled = False + if account.custom_properties: + try: + custom_props = json.loads(account.custom_properties) + vod_enabled = custom_props.get('enable_vod', False) + except (json.JSONDecodeError, TypeError): + vod_enabled = False + except M3UAccount.DoesNotExist: # The M3U account doesn't exist, so delete the periodic task if it exists logger.warning(f"M3U account with ID {account_id} not found, but task was triggered. Cleaning up orphaned task.") @@ -1531,6 +1541,16 @@ def refresh_single_m3u_account(account_id): streams_deleted=streams_deleted, message=account.last_message ) + + # Trigger VOD refresh if enabled and account is XtreamCodes type + if vod_enabled and account.account_type == M3UAccount.Types.XC: + logger.info(f"VOD is enabled for account {account_id}, triggering VOD refresh") + try: + from apps.vod.tasks import refresh_vod_content + refresh_vod_content.delay(account_id) + logger.info(f"VOD refresh task queued for account {account_id}") + except Exception as e: + logger.error(f"Failed to queue VOD refresh for account {account_id}: {str(e)}") except Exception as e: logger.error(f"Error processing M3U for account {account_id}: {str(e)}") diff --git a/apps/m3u/views.py b/apps/m3u/views.py index f69dd6c4..0fab8c10 100644 --- a/apps/m3u/views.py +++ b/apps/m3u/views.py @@ -3,6 +3,7 @@ from django.views import View from django.utils.decorators import method_decorator from django.contrib.auth.decorators import login_required from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse from apps.m3u.models import M3UAccount import json diff --git a/apps/vod/admin.py b/apps/vod/admin.py index 6aa8cd3d..4ef113fe 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -4,8 +4,8 @@ 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'] + list_display = ['name', 'category_type', 'm3u_account', 'created_at'] + list_filter = ['category_type', 'm3u_account', 'created_at'] search_fields = ['name'] diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 142dfad3..ada3f55a 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -120,12 +120,23 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet): return Response(serializer.data) +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 = [SearchFilter, OrderingFilter] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = VODCategoryFilter search_fields = ['name'] ordering = ['name'] diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py index af882079..6d1b7a98 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 15:33 +# Generated by Django 5.2.4 on 2025-08-02 16:59 import django.db.models.deletion import uuid @@ -45,6 +45,7 @@ class Migration(migrations.Migration): 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')), @@ -53,6 +54,7 @@ class Migration(migrations.Migration): 'verbose_name': 'VOD Category', 'verbose_name_plural': 'VOD Categories', 'ordering': ['name'], + 'unique_together': {('name', 'm3u_account', 'category_type')}, }, ), migrations.CreateModel( diff --git a/apps/vod/models.py b/apps/vod/models.py index 7302bfcd..a12eecb4 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -7,7 +7,19 @@ 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) + category_type = models.CharField( + max_length=10, + choices=CATEGORY_TYPE_CHOICES, + default='movie', + help_text="Type of content this category contains" + ) m3u_account = models.ForeignKey( M3UAccount, on_delete=models.CASCADE, @@ -22,9 +34,10 @@ class VODCategory(models.Model): verbose_name = "VOD Category" verbose_name_plural = "VOD Categories" ordering = ['name'] + unique_together = ['name', 'm3u_account', 'category_type'] def __str__(self): - return self.name + return f"{self.name} ({self.get_category_type_display()})" class Series(models.Model): diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py index 1e070e9c..f1a19293 100644 --- a/apps/vod/serializers.py +++ b/apps/vod/serializers.py @@ -5,6 +5,8 @@ from apps.m3u.serializers import M3UAccountSerializer class VODCategorySerializer(serializers.ModelSerializer): + category_type_display = serializers.CharField(source='get_category_type_display', read_only=True) + class Meta: model = VODCategory fields = '__all__' diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index 89c08b11..4967f2ae 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -52,7 +52,11 @@ def refresh_movies(account): VODCategory.objects.get_or_create( name=cat_data['category_name'], m3u_account=account, - defaults={'name': cat_data['category_name']} + category_type='movie', + defaults={ + 'name': cat_data['category_name'], + 'category_type': 'movie' + } ) # Get movies @@ -69,12 +73,24 @@ def refresh_movies(account): 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 + # First try exact match by category_id if available + category = VODCategory.objects.filter( + m3u_account=account, + category_type='movie' + ).filter( + name__iexact=movie_data.get('category_name', '') + ).first() + + # If no exact match, try contains but limit to first result + if not category and movie_data.get('category_name'): + category = VODCategory.objects.filter( + name__icontains=movie_data.get('category_name', ''), + m3u_account=account, + category_type='movie' + ).first() + except Exception as e: + logger.warning(f"Error finding category for movie {movie_data.get('name', 'Unknown')}: {e}") + category = None # Create/update movie stream_url = f"{account.server_url}/movie/{account.username}/{account.password}/{movie_data['stream_id']}.{movie_data.get('container_extension', 'mp4')}" @@ -137,7 +153,11 @@ def refresh_series(account): VODCategory.objects.get_or_create( name=cat_data['category_name'], m3u_account=account, - defaults={'name': cat_data['category_name']} + category_type='series', + defaults={ + 'name': cat_data['category_name'], + 'category_type': 'series' + } ) # Get series list @@ -154,12 +174,24 @@ def refresh_series(account): 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 + # First try exact match + category = VODCategory.objects.filter( + m3u_account=account, + category_type='series' + ).filter( + name__iexact=series_item.get('category_name', '') + ).first() + + # If no exact match, try contains but limit to first result + if not category and series_item.get('category_name'): + category = VODCategory.objects.filter( + name__icontains=series_item.get('category_name', ''), + m3u_account=account, + category_type='series' + ).first() + except Exception as e: + logger.warning(f"Error finding category for series {series_item.get('name', 'Unknown')}: {e}") + category = None # Create/update series series_data_dict = { diff --git a/frontend/src/api.js b/frontend/src/api.js index cfdf1a90..ae76fe75 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -789,7 +789,6 @@ export default class API { errorNotification('Failed to refresh M3U account', e); } } - static async refreshAllPlaylist() { try { const response = await request(`${host}/api/m3u/refresh/`, { @@ -801,6 +800,16 @@ export default class API { errorNotification('Failed to refresh all M3U accounts', e); } } + static async refreshVODContent(accountId) { + try { + const response = await request(`${host}/api/m3u/accounts/${accountId}/refresh-vod/`, { + method: 'POST' + }); + return response; + } catch (e) { + errorNotification('Failed to refresh VOD content', e); + } + } static async deletePlaylist(id) { try { diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index c946d001..4420f13e 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -126,6 +126,10 @@ export default function M3URefreshNotification() { case 'processing_groups': message = 'Group parsing'; break; + + case 'vod_refresh': + message = 'VOD content refresh'; + break; } if (taskProgress == 0) { @@ -143,6 +147,9 @@ export default function M3URefreshNotification() { fetchChannelGroups(); fetchEPGData(); fetchPlaylists(); + } else if (data.action == 'vod_refresh') { + // VOD refresh completed, could trigger additional UI updates if needed + fetchPlaylists(); // Refresh playlist data to show updated VOD info } } diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 0e4d5643..8b3ddfa0 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -62,6 +62,7 @@ const M3U = ({ username: '', password: '', stale_stream_days: 7, + enable_vod: false, }, validate: { @@ -86,6 +87,7 @@ const M3U = ({ username: m3uAccount.username ?? '', password: '', stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7, + enable_vod: m3uAccount.enable_vod || false, }); if (m3uAccount.account_type == 'XC') { @@ -256,6 +258,19 @@ const M3U = ({ )} + + Enable VOD Scanning + + +