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
+
+
+