Add ability to scan for vods during m3u refresh.

This commit is contained in:
SergeantPanda 2025-08-02 12:01:26 -05:00
parent 386a03381c
commit bcebcadfaa
15 changed files with 315 additions and 27 deletions

View file

@ -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("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)

View file

@ -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"""

View file

@ -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:

View file

@ -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"""

View file

@ -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)}")

View file

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

View file

@ -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']

View file

@ -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']

View file

@ -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(

View file

@ -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):

View file

@ -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__'

View file

@ -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 = {

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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 = ({
</Group>
)}
<Group justify="space-between">
<Box>Enable VOD Scanning</Box>
<Switch
id="enable_vod"
name="enable_vod"
description="Scan and import VOD content (movies/series) from this Xtream account"
key={form.key('enable_vod')}
{...form.getInputProps('enable_vod', {
type: 'checkbox',
})}
/>
</Group>
<TextInput
id="username"
name="username"