Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
View File
+190
View File
@@ -0,0 +1,190 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent, M3UAccountProfile
import json
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
extra = 1
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = (
"name",
"server_url",
"server_group",
"max_streams",
"priority",
"is_active",
"user_agent_display",
"uploaded_file_link",
"created_at",
"updated_at",
)
list_filter = ("is_active", "server_group")
search_fields = ("name", "server_url", "server_group__name")
inlines = [M3UFilterInline]
actions = ["activate_accounts", "deactivate_accounts"]
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
if hasattr(obj, "user_agent"): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
elif hasattr(obj, "user_agents"): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
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:
custom_props = obj.custom_properties or {}
return "Yes" if custom_props.get('enable_vod', False) else "No"
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
)
return "No file uploaded"
uploaded_file_link.short_description = "Uploaded File"
@admin.action(description="Activate selected accounts")
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description="Deactivate selected accounts")
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
if hasattr(M3UAccount, "user_agents"):
filter_horizontal = ("user_agents",) # Only for ManyToManyField
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ("m3u_account", "filter_type", "regex_pattern", "exclude")
list_filter = ("filter_type", "exclude")
search_fields = ("regex_pattern",)
ordering = ("m3u_account",)
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(M3UAccountProfile)
class M3UAccountProfileAdmin(admin.ModelAdmin):
list_display = (
"name",
"m3u_account",
"is_default",
"is_active",
"max_streams",
"current_viewers",
"account_status_display",
"account_expiration_display",
"last_refresh_display",
)
list_filter = ("is_active", "is_default", "m3u_account__account_type")
search_fields = ("name", "m3u_account__name")
readonly_fields = ("account_info_display",)
def account_status_display(self, obj):
"""Display account status from custom properties"""
status = obj.get_account_status()
if status:
# Create colored status display
color_map = {
'Active': 'green',
'Expired': 'red',
'Disabled': 'red',
'Banned': 'red',
}
color = color_map.get(status, 'black')
return format_html(
'<span style="color: {};">{}</span>',
color,
status
)
return "Unknown"
account_status_display.short_description = "Account Status"
def account_expiration_display(self, obj):
"""Display account expiration from custom properties"""
expiration = obj.get_account_expiration()
if expiration:
from datetime import datetime
if expiration < datetime.now():
return format_html(
'<span style="color: red;">{}</span>',
expiration.strftime('%Y-%m-%d %H:%M')
)
else:
return format_html(
'<span style="color: green;">{}</span>',
expiration.strftime('%Y-%m-%d %H:%M')
)
return "Unknown"
account_expiration_display.short_description = "Expires"
def last_refresh_display(self, obj):
"""Display last refresh time from custom properties"""
last_refresh = obj.get_last_refresh()
if last_refresh:
return last_refresh.strftime('%Y-%m-%d %H:%M:%S')
return "Never"
last_refresh_display.short_description = "Last Refresh"
def account_info_display(self, obj):
"""Display formatted account information from custom properties"""
if not obj.custom_properties:
return "No account information available"
html_parts = []
# User Info
user_info = obj.custom_properties.get('user_info', {})
if user_info:
html_parts.append("<h3>User Information:</h3>")
html_parts.append("<ul>")
for key, value in user_info.items():
if key == 'exp_date' and value:
try:
from datetime import datetime
exp_date = datetime.fromtimestamp(float(value))
value = exp_date.strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
pass
html_parts.append(f"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Server Info
server_info = obj.custom_properties.get('server_info', {})
if server_info:
html_parts.append("<h3>Server Information:</h3>")
html_parts.append("<ul>")
for key, value in server_info.items():
html_parts.append(f"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Last Refresh
last_refresh = obj.custom_properties.get('last_refresh')
if last_refresh:
html_parts.append(f"<p><strong>Last Refresh:</strong> {last_refresh}</p>")
return format_html(''.join(html_parts)) if html_parts else "No account information available"
account_info_display.short_description = "Account Information"
+44
View File
@@ -0,0 +1,44 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
M3UAccountViewSet,
M3UFilterViewSet,
ServerGroupViewSet,
RefreshM3UAPIView,
RefreshSingleM3UAPIView,
RefreshAccountInfoAPIView,
UserAgentViewSet,
M3UAccountProfileViewSet,
)
app_name = "m3u"
router = DefaultRouter()
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register(
r"accounts\/(?P<account_id>\d+)\/profiles",
M3UAccountProfileViewSet,
basename="m3u-account-profiles",
)
router.register(
r"accounts\/(?P<account_id>\d+)\/filters",
M3UFilterViewSet,
basename="m3u-filters",
)
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
urlpatterns = [
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
path(
"refresh/<int:account_id>/",
RefreshSingleM3UAPIView.as_view(),
name="m3u_refresh_single",
),
path(
"refresh-account-info/<int:profile_id>/",
RefreshAccountInfoAPIView.as_view(),
name="m3u_refresh_account_info",
),
]
urlpatterns += router.urls
+501
View File
@@ -0,0 +1,501 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
permission_classes_by_method,
)
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
import os
from rest_framework.decorators import action
from django.conf import settings
from .tasks import refresh_m3u_groups
import json
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from core.utils import safe_upload_path
from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation
from .serializers import (
M3UAccountSerializer,
M3UFilterSerializer,
ServerGroupSerializer,
M3UAccountProfileSerializer,
)
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts, refresh_account_info
import json
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).prefetch_related("channel_group", "profiles")
serializer_class = M3UAccountSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def create(self, request, *args, **kwargs):
# Handle file upload first, if any
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
# Add file_path to the request data so it's available during creation
request.data._mutable = True # Allow modification of the request data
request.data["file_path"] = (
file_path # Include the file path if a file was uploaded
)
# Handle the user_agent field - convert "null" string to None
if "user_agent" in request.data and request.data["user_agent"] == "null":
request.data["user_agent"] = None
# Handle server_url appropriately
if "server_url" in request.data and not request.data["server_url"]:
request.data.pop("server_url")
request.data._mutable = False # Make the request data immutable again
# Now call super().create() to create the instance
response = super().create(request, *args, **kwargs)
account_type = response.data.get("account_type")
account_id = response.data.get("id")
# Notify frontend that a new playlist was created
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
'type': 'playlist_created',
'playlist_id': account_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_categories
refresh_categories(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:
custom_props = instance.custom_properties or {}
old_vod_enabled = custom_props.get("enable_vod", False)
# Handle file upload first, if any
file_path = None
if "file" in request.FILES:
file = request.FILES["file"]
try:
file_path = safe_upload_path(file.name, "/data/uploads/m3us")
except ValueError:
return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST)
os.makedirs("/data/uploads/m3us", exist_ok=True)
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
# Add file_path to the request data so it's available during creation
request.data._mutable = True # Allow modification of the request data
request.data["file_path"] = (
file_path # Include the file path if a file was uploaded
)
# Handle the user_agent field - convert "null" string to None
if "user_agent" in request.data and request.data["user_agent"] == "null":
request.data["user_agent"] = None
# Handle server_url appropriately
if "server_url" in request.data and not request.data["server_url"]:
request.data.pop("server_url")
request.data._mutable = False # Make the request data immutable again
if instance.file_path and os.path.exists(instance.file_path):
os.remove(instance.file_path)
# 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
):
# Create Uncategorized categories immediately so they're available in the UI
from apps.vod.models import VODCategory, M3UVODCategoryRelation
# Create movie Uncategorized category
movie_category, _ = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="movie",
defaults={}
)
# Create series Uncategorized category
series_category, _ = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="series",
defaults={}
)
# Create relations for both categories (disabled by default until first refresh)
account_custom_props = instance.custom_properties or {}
auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True)
M3UVODCategoryRelation.objects.get_or_create(
category=movie_category,
m3u_account=instance,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
M3UVODCategoryRelation.objects.get_or_create(
category=series_category,
m3u_account=instance,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
# Trigger full VOD refresh
from apps.vod.tasks import refresh_vod_content
refresh_vod_content.delay(instance.id)
# After the instance is updated, return the response
return response
def partial_update(self, request, *args, **kwargs):
"""Handle partial updates with special logic for is_active field"""
instance = self.get_object()
# Check if we're toggling is_active
if (
"is_active" in request.data
and instance.is_active != request.data["is_active"]
):
# Set appropriate status based on new is_active value
if request.data["is_active"]:
request.data["status"] = M3UAccount.Status.IDLE
else:
request.data["status"] = M3UAccount.Status.DISABLED
# 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:
custom_props = account.custom_properties or {}
vod_enabled = custom_props.get("enable_vod", False)
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"""
account = self.get_object()
group_settings = request.data.get("group_settings", [])
category_settings = request.data.get("category_settings", [])
try:
with transaction.atomic():
group_objects = [
ChannelGroupM3UAccount(
channel_group_id=setting["channel_group"],
m3u_account=account,
enabled=setting.get("enabled", True),
auto_channel_sync=setting.get("auto_channel_sync", False),
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
custom_properties=setting.get("custom_properties", {}),
)
for setting in group_settings
if setting.get("channel_group")
]
if group_objects:
ChannelGroupM3UAccount.objects.bulk_create(
group_objects,
update_conflicts=True,
unique_fields=["channel_group", "m3u_account"],
update_fields=[
"enabled",
"auto_channel_sync",
"auto_sync_channel_start",
"custom_properties",
],
)
category_objects = [
M3UVODCategoryRelation(
category_id=setting["id"],
m3u_account=account,
enabled=setting.get("enabled", True),
custom_properties=setting.get("custom_properties", {}),
)
for setting in category_settings
if setting.get("id")
]
if category_objects:
M3UVODCategoryRelation.objects.bulk_create(
category_objects,
update_conflicts=True,
unique_fields=["m3u_account", "category"],
update_fields=["enabled", "custom_properties"],
)
return Response({"message": "Group settings updated successfully"})
except Exception as e:
return Response(
{"error": f"Failed to update group settings: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)
class M3UFilterViewSet(viewsets.ModelViewSet):
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# # Get the M3UAccount instance for the account_id
# m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = account_id
# Perform the actual save
serializer.save(m3u_account_id=account_id)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""
queryset = ServerGroup.objects.all()
serializer_class = ServerGroupSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
class RefreshM3UAPIView(APIView):
"""Triggers refresh for all active M3U accounts"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Triggers a refresh of all active M3U accounts",
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
return Response(
{"success": True, "message": "M3U refresh initiated."},
status=status.HTTP_202_ACCEPTED,
)
class RefreshSingleM3UAPIView(APIView):
"""Triggers refresh for a single M3U account"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Triggers a refresh of a single M3U account",
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
return Response(
{
"success": True,
"message": f"M3U account {account_id} refresh initiated.",
},
status=status.HTTP_202_ACCEPTED,
)
class RefreshAccountInfoAPIView(APIView):
"""Triggers account info refresh for a single M3U account"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@extend_schema(
description="Triggers a refresh of account information for a specific M3U profile",
)
def post(self, request, profile_id, format=None):
try:
from .models import M3UAccountProfile
profile = M3UAccountProfile.objects.get(id=profile_id)
account = profile.m3u_account
if account.account_type != M3UAccount.Types.XC:
return Response(
{
"success": False,
"error": "Account info refresh is only available for XtreamCodes accounts",
},
status=status.HTTP_400_BAD_REQUEST,
)
refresh_account_info.delay(profile_id)
return Response(
{
"success": True,
"message": f"Account info refresh initiated for profile {profile.name}.",
},
status=status.HTTP_202_ACCEPTED,
)
except M3UAccountProfile.DoesNotExist:
return Response(
{
"success": False,
"error": "Profile not found",
},
status=status.HTTP_404_NOT_FOUND,
)
class UserAgentViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for User Agents"""
queryset = UserAgent.objects.all()
serializer_class = UserAgentSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
class M3UAccountProfileViewSet(viewsets.ModelViewSet):
queryset = M3UAccountProfile.objects.all()
serializer_class = M3UAccountProfileSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UAccountProfile.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# Get the M3UAccount instance for the account_id
m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = m3u_account
# Perform the actual save
serializer.save(m3u_account_id=m3u_account)
+10
View File
@@ -0,0 +1,10 @@
# apps/m3u/apps.py
from django.apps import AppConfig
class M3UConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.m3u'
verbose_name = "M3U Management"
def ready(self):
import apps.m3u.signals # ensures M3U signals get registered
+79
View File
@@ -0,0 +1,79 @@
# apps/m3u/forms.py
from django import forms
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 = [
'name',
'server_url',
'uploaded_file',
'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:
custom_props = self.instance.custom_properties or {}
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
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 = instance.custom_properties or {}
# Update VOD preference
custom_props['enable_vod'] = enable_vod
instance.custom_properties = custom_props
if commit:
instance.save()
return instance
def clean_uploaded_file(self):
uploaded_file = self.cleaned_data.get('uploaded_file')
if uploaded_file:
if not uploaded_file.name.endswith('.m3u'):
raise forms.ValidationError("The uploaded file must be an M3U file.")
return uploaded_file
def clean(self):
cleaned_data = super().clean()
url = cleaned_data.get('server_url')
file = cleaned_data.get('uploaded_file')
# Ensure either `server_url` or `uploaded_file` is provided
if not url and not file:
raise forms.ValidationError("Either an M3U URL or a file upload is required.")
return cleaned_data
class M3UFilterForm(forms.ModelForm):
class Meta:
model = M3UFilter
fields = ['m3u_account', 'filter_type', 'regex_pattern', 'exclude']
def clean_regex_pattern(self):
pattern = self.cleaned_data['regex_pattern']
try:
re.compile(pattern)
except re.error:
raise forms.ValidationError("Invalid regex pattern")
return pattern
+65
View File
@@ -0,0 +1,65 @@
# Generated by Django 5.1.6 on 2025-03-05 22:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ServerGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unique name for this server group.', max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='M3UAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Unique name for this M3U account', max_length=255, unique=True)),
('server_url', models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', null=True)),
('uploaded_file', models.FileField(blank=True, null=True, upload_to='m3u_uploads/')),
('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')),
('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this M3U account')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Time when this account was created')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Time when this account was last updated')),
('user_agent', models.ForeignKey(blank=True, help_text='The User-Agent associated with this M3U account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.useragent')),
('server_group', models.ForeignKey(blank=True, help_text='The server group this M3U account belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='m3u.servergroup')),
],
),
migrations.CreateModel(
name='M3UFilter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('filter_type', models.CharField(choices=[('group', 'Group Title'), ('name', 'Stream Name')], default='group', help_text='Filter based on either group title or stream name.', max_length=50)),
('regex_pattern', models.CharField(help_text='A regex pattern to match streams or groups.', max_length=200)),
('exclude', models.BooleanField(default=True, help_text='If True, matching items are excluded; if False, only matches are included.')),
('m3u_account', models.ForeignKey(help_text='The M3U account this filter is applied to.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='m3u.m3uaccount')),
],
),
migrations.CreateModel(
name='M3UAccountProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name for the M3U account profile', max_length=255)),
('is_default', models.BooleanField(default=False, help_text='Set to false to deactivate this profile')),
('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')),
('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this profile')),
('search_pattern', models.CharField(max_length=255)),
('replace_pattern', models.CharField(max_length=255)),
('current_viewers', models.PositiveIntegerField(default=0)),
('m3u_account', models.ForeignKey(help_text='The M3U account this profile belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to='m3u.m3uaccount')),
],
options={
'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_name')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-03-17 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='locked',
field=models.BooleanField(default=False, help_text="Protected - can't be deleted or modified"),
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
from core.models import CoreSettings
def create_custom_account(apps, schema_editor):
default_user_agent_id = CoreSettings.get_default_user_agent_id()
M3UAccount = apps.get_model("m3u", "M3UAccount")
m3u_account = M3UAccount.objects.create(
name="custom",
max_streams=0,
is_active=True,
user_agent_id=default_user_agent_id,
locked=True,
)
M3UAccountProfile = apps.get_model("m3u", "M3UAccountProfile")
M3UAccountProfile.objects.create(
m3u_account=m3u_account,
name=f"{m3u_account.name} Default",
max_streams=m3u_account.max_streams,
is_default=True,
is_active=True,
search_pattern="^(.*)$",
replace_pattern="$1",
)
class Migration(migrations.Migration):
dependencies = [
("m3u", "0002_m3uaccount_locked"),
("core", "0004_preload_core_settings"),
]
operations = [
migrations.RunPython(create_custom_account),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.1.6 on 2025-03-19 16:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_m3u_hash_settings'),
('m3u', '0003_create_custom_account'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='stream_profile',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.streamprofile'),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.1.6 on 2025-03-29 13:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_celery_beat", "0019_alter_periodictasks_options"),
("m3u", "0004_m3uaccount_stream_profile"),
]
operations = [
migrations.AddField(
model_name="m3uaccount",
name="custom_properties",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="m3uaccount",
name="refresh_interval",
field=models.IntegerField(default=24),
),
migrations.AddField(
model_name="m3uaccount",
name="refresh_task",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_celery_beat.periodictask",
),
),
]
@@ -0,0 +1,52 @@
from django.db import migrations
import json
def create_default_refresh_tasks(apps, schema_editor):
"""
Creates a PeriodicTask for each existing M3UAccount that doesn't have one.
"""
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
M3UAccount = apps.get_model("m3u", "M3UAccount")
default_interval, _ = IntervalSchedule.objects.get_or_create(
every=24,
period="hours",
)
for account in M3UAccount.objects.all():
if account.refresh_task:
continue
task_name = f"m3u_account-refresh-{account.id}"
refresh_task = PeriodicTask.objects.create(
name=task_name,
interval=default_interval,
task="apps.m3u.tasks.refresh_single_m3u_account",
kwargs=json.dumps({"account_id": account.id}),
)
account.refresh_task = refresh_task
account.save(update_fields=["refresh_task"])
def reverse_migration(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
M3UAccount = apps.get_model("m3u", "M3UAccount")
for account in M3UAccount.objects.all():
IntervalSchedule.objects.filter(name=f"m3u_account-refresh-interval-{account.id}").delete()
PeriodicTask.objects.filter(name=f"m3u_account-refresh-{account.id}").delete()
class Migration(migrations.Migration):
dependencies = [
("m3u", "0005_m3uaccount_custom_properties_and_more"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_default_refresh_tasks, reverse_migration),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-04-06 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0006_populate_periodic_tasks'),
]
operations = [
migrations.RemoveField(
model_name='m3uaccount',
name='uploaded_file',
),
migrations.AddField(
model_name='m3uaccount',
name='file_path',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='stale_stream_days',
field=models.PositiveIntegerField(default=7, help_text='Number of days after which a stream will be removed if not seen in the M3U source.'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-04-27 12:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0008_m3uaccount_stale_stream_days'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='account_type',
field=models.CharField(choices=[('STD', 'Standard'), ('XC', 'Xtream Codes')], default='STD'),
),
migrations.AddField(
model_name='m3uaccount',
name='password',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='m3uaccount',
name='username',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.6 on 2025-05-04 21:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0009_m3uaccount_account_type_m3uaccount_password_and_more'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='last_message',
field=models.TextField(blank=True, null=True, help_text="Last status message, including success results or error information"),
),
migrations.AddField(
model_name='m3uaccount',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20),
),
migrations.AlterField(
model_name='m3uaccount',
name='updated_at',
field=models.DateTimeField(blank=True, help_text='Time when this account was last successfully refreshed', null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-15 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0010_add_status_fields_and_remove_auto_now'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('pending_setup', 'Pending Setup'), ('disabled', 'Disabled')], default='idle', max_length=20),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-21 19:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0011_alter_m3uaccount_status'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='refresh_interval',
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-07-22 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AlterField(
model_name='m3ufilter',
name='filter_type',
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-07-31 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0013_alter_m3ufilter_filter_type'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='m3ufilter',
name='order',
field=models.PositiveIntegerField(default=0),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-02 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0014_alter_m3ufilter_options_m3ufilter_order'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={},
),
migrations.AddField(
model_name='m3ufilter',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-20 22:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0015_alter_m3ufilter_options_m3ufilter_custom_properties'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='priority',
field=models.PositiveIntegerField(default=0, help_text='Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-02 15:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0016_m3uaccount_priority'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='m3uaccount',
name='server_url',
field=models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', max_length=1000, null=True),
),
migrations.AlterField(
model_name='m3ufilter',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-09 20:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0017_alter_m3uaccount_custom_properties_and_more'),
]
operations = [
migrations.AddField(
model_name='m3uaccountprofile',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for storing account information from provider (e.g., XC account details, expiration dates)', null=True),
),
]
@@ -0,0 +1,57 @@
# Generated by Django 6.0.3 on 2026-03-14 19:41
from datetime import datetime, timezone
from django.db import migrations, models
def populate_exp_date_from_custom_properties(apps, schema_editor):
"""Backfill exp_date from custom_properties['user_info']['exp_date']."""
M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile')
profiles_to_update = []
for profile in M3UAccountProfile.objects.filter(
custom_properties__isnull=False,
).exclude(custom_properties={}):
user_info = profile.custom_properties.get('user_info', {})
raw_exp = user_info.get('exp_date')
if raw_exp is None:
continue
parsed = None
try:
if isinstance(raw_exp, (int, float)):
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
elif isinstance(raw_exp, str):
try:
parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
except ValueError:
parsed = datetime.fromisoformat(raw_exp)
except (ValueError, TypeError, OSError):
pass
if parsed is not None:
profile.exp_date = parsed
profiles_to_update.append(profile)
if profiles_to_update:
M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
('m3u', '0018_add_profile_custom_properties'),
]
operations = [
migrations.AddField(
model_name='m3uaccountprofile',
name='exp_date',
field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True),
),
migrations.RunPython(
populate_exp_date_from_custom_properties,
reverse_code=migrations.RunPython.noop,
),
]
View File
+389
View File
@@ -0,0 +1,389 @@
from datetime import datetime, timezone
from django.db import models
from django.core.exceptions import ValidationError
from core.models import UserAgent
import re
from django.dispatch import receiver
from apps.channels.models import StreamProfile
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings, UserAgent
CUSTOM_M3U_ACCOUNT_NAME = "custom"
class M3UAccount(models.Model):
class Types(models.TextChoices):
STADNARD = "STD", "Standard"
XC = "XC", "Xtream Codes"
class Status(models.TextChoices):
IDLE = "idle", "Idle"
FETCHING = "fetching", "Fetching"
PARSING = "parsing", "Parsing"
ERROR = "error", "Error"
SUCCESS = "success", "Success"
PENDING_SETUP = "pending_setup", "Pending Setup"
DISABLED = "disabled", "Disabled"
"""Represents an M3U Account for IPTV streams."""
name = models.CharField(
max_length=255, unique=True, help_text="Unique name for this M3U account"
)
server_url = models.URLField(
max_length=1000,
blank=True,
null=True,
help_text="The base URL of the M3U server (optional if a file is uploaded)",
)
file_path = models.CharField(max_length=255, blank=True, null=True)
server_group = models.ForeignKey(
"ServerGroup",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
help_text="The server group this M3U account belongs to",
)
max_streams = models.PositiveIntegerField(
default=0, help_text="Maximum number of concurrent streams (0 for unlimited)"
)
is_active = models.BooleanField(
default=True, help_text="Set to false to deactivate this M3U account"
)
created_at = models.DateTimeField(
auto_now_add=True, help_text="Time when this account was created"
)
updated_at = models.DateTimeField(
null=True,
blank=True,
help_text="Time when this account was last successfully refreshed",
)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.IDLE
)
last_message = models.TextField(
null=True,
blank=True,
help_text="Last status message, including success results or error information",
)
user_agent = models.ForeignKey(
"core.UserAgent",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
help_text="The User-Agent associated with this M3U account.",
)
locked = models.BooleanField(
default=False, help_text="Protected - can't be deleted or modified"
)
stream_profile = models.ForeignKey(
StreamProfile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="m3u_accounts",
)
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
username = models.CharField(max_length=255, null=True, blank=True)
password = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
)
stale_stream_days = models.PositiveIntegerField(
default=7,
help_text="Number of days after which a stream will be removed if not seen in the M3U source.",
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.",
)
def __str__(self):
return self.name
def clean(self):
if self.max_streams < 0:
raise ValidationError("Max streams cannot be negative.")
def display_action(self):
return "Exclude" if self.exclude else "Include"
def deactivate_streams(self):
"""Deactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = False
stream.save()
def reactivate_streams(self):
"""Reactivate all streams linked to this account."""
for stream in self.streams.all():
stream.is_active = True
stream.save()
@classmethod
def get_custom_account(cls):
return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True)
def get_user_agent(self):
user_agent = self.user_agent
if not user_agent:
user_agent = UserAgent.objects.get(
id=CoreSettings.get_default_user_agent_id()
)
return user_agent
def save(self, *args, **kwargs):
# Prevent auto_now behavior by handling updated_at manually
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
# Don't modify updated_at for regular updates
kwargs.setdefault("update_fields", [])
if "updated_at" in kwargs["update_fields"]:
kwargs["update_fields"].remove("updated_at")
super().save(*args, **kwargs)
# def get_channel_groups(self):
# return ChannelGroup.objects.filter(m3u_account__m3u_account=self)
# def is_channel_group_enabled(self, channel_group):
# """Check if the specified ChannelGroup is enabled for this M3UAccount."""
# return self.channel_group.filter(channel_group=channel_group, enabled=True).exists()
# def get_enabled_streams(self):
# """Return all streams linked to this account with enabled ChannelGroups."""
# return self.streams.filter(channel_group__in=ChannelGroup.objects.filter(m3u_account__enabled=True))
class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name="filters",
help_text="The M3U account this filter is applied to.",
)
filter_type = models.CharField(
max_length=50,
choices=FILTER_TYPE_CHOICES,
default="group",
help_text="Filter based on either group title or stream name.",
)
regex_pattern = models.CharField(
max_length=200, help_text="A regex pattern to match streams or groups."
)
exclude = models.BooleanField(
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
return bool(re.search(self.regex_pattern, target, re.IGNORECASE))
def clean(self):
try:
re.compile(self.regex_pattern)
except re.error:
raise ValidationError(f"Invalid regex pattern: {self.regex_pattern}")
def __str__(self):
filter_type_display = dict(self.FILTER_TYPE_CHOICES).get(
self.filter_type, "Unknown"
)
exclude_status = "Exclude" if self.exclude else "Include"
return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})"
@staticmethod
def filter_streams(streams, filters):
included_streams = set()
excluded_streams = set()
for f in filters:
for stream in streams:
if f.applies_to(stream.name, stream.group_name):
if f.exclude:
excluded_streams.add(stream)
else:
included_streams.add(stream)
# If no include filters exist, assume all non-excluded streams are valid
if not any(not f.exclude for f in filters):
return streams.exclude(id__in=[s.id for s in excluded_streams])
return streams.filter(id__in=[s.id for s in included_streams])
class ServerGroup(models.Model):
"""Represents a logical grouping of servers or channels."""
name = models.CharField(
max_length=100, unique=True, help_text="Unique name for this server group."
)
def __str__(self):
return self.name
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""
m3u_account = models.ForeignKey(
"M3UAccount",
on_delete=models.CASCADE,
related_name="profiles",
help_text="The M3U account this profile belongs to.",
)
name = models.CharField(
max_length=255, help_text="Name for the M3U account profile"
)
is_default = models.BooleanField(
default=False, help_text="Set to false to deactivate this profile"
)
max_streams = models.PositiveIntegerField(
default=0, help_text="Maximum number of concurrent streams (0 for unlimited)"
)
is_active = models.BooleanField(
default=True, help_text="Set to false to deactivate this profile"
)
search_pattern = models.CharField(
max_length=255,
)
replace_pattern = models.CharField(
max_length=255,
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)"
)
exp_date = models.DateTimeField(
null=True,
blank=True,
help_text="Account expiration date, auto-synced from custom_properties on save",
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["m3u_account", "name"], name="unique_account_name"
)
]
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
def save(self, *args, **kwargs):
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
For non-XC accounts, exp_date is set directly and left untouched here."""
parsed = self._parse_exp_date_from_custom_properties()
if parsed is not None:
# XC account with exp_date in custom_properties — always sync
self.exp_date = parsed
# else: keep whatever exp_date is already set (manual entry for non-XC)
super().save(*args, **kwargs)
@staticmethod
def _parse_exp_date(raw_value):
"""Parse a raw exp_date value (unix timestamp or ISO string) into a datetime."""
if raw_value is None:
return None
try:
if isinstance(raw_value, (int, float)):
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
elif isinstance(raw_value, str):
try:
return datetime.fromtimestamp(float(raw_value), tz=timezone.utc)
except ValueError:
return datetime.fromisoformat(raw_value)
except (ValueError, TypeError, OSError):
pass
return None
def _parse_exp_date_from_custom_properties(self):
"""Extract exp_date from custom_properties JSON."""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return self._parse_exp_date(user_info.get('exp_date'))
def get_account_expiration(self):
"""Get account expiration date — uses the dedicated field if set, otherwise parses JSON."""
if self.exp_date:
return self.exp_date
return self._parse_exp_date_from_custom_properties()
def get_account_status(self):
"""Get account status from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('status')
def get_max_connections(self):
"""Get maximum connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('max_connections')
def get_active_connections(self):
"""Get active connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('active_cons')
def get_last_refresh(self):
"""Get last refresh timestamp from custom properties if available"""
if not self.custom_properties:
return None
last_refresh = self.custom_properties.get('last_refresh')
if last_refresh:
try:
from datetime import datetime
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
return None
@receiver(models.signals.post_save, sender=M3UAccount)
def create_profile_for_m3u_account(sender, instance, created, **kwargs):
"""Automatically create an M3UAccountProfile when M3UAccount is created."""
if created:
M3UAccountProfile.objects.create(
m3u_account=instance,
name=f"{instance.name} Default",
max_streams=instance.max_streams,
is_default=True,
is_active=True,
search_pattern="^(.*)$",
replace_pattern="$1",
)
else:
profile = M3UAccountProfile.objects.get(
m3u_account=instance,
is_default=True,
)
profile.max_streams = instance.max_streams
profile.save()
+384
View File
@@ -0,0 +1,384 @@
from core.utils import validate_flexible_url
from rest_framework import serializers, status
from rest_framework.response import Response
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
)
from datetime import timezone as dt_tz
import logging
import json
logger = logging.getLogger(__name__)
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
class Meta:
model = M3UFilter
fields = [
"id",
"filter_type",
"regex_pattern",
"exclude",
"order",
"custom_properties",
]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
account = serializers.SerializerMethodField()
def get_account(self, obj):
"""Include basic account information for frontend use"""
return {
'id': obj.m3u_account.id,
'name': obj.m3u_account.name,
'account_type': obj.m3u_account.account_type,
'is_xtream_codes': obj.m3u_account.account_type == 'XC'
}
class Meta:
model = M3UAccountProfile
fields = [
"id",
"name",
"max_streams",
"is_active",
"is_default",
"current_viewers",
"search_pattern",
"replace_pattern",
"custom_properties",
"exp_date",
"account",
]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
'exp_date': {'required': False, 'allow_null': True},
}
def create(self, validated_data):
m3u_account = self.context.get("m3u_account")
# Use the m3u_account when creating the profile
validated_data["m3u_account_id"] = m3u_account.id
return super().create(validated_data)
def validate(self, data):
"""Custom validation to handle default profiles"""
# For updates to existing instances
if self.instance and self.instance.is_default:
# For default profiles, search_pattern and replace_pattern are not required
# and we don't want to validate them since they shouldn't be changed
return data
# For non-default profiles or new profiles, ensure required fields are present
if not data.get('search_pattern'):
raise serializers.ValidationError({
'search_pattern': ['This field is required for non-default profiles.']
})
if not data.get('replace_pattern'):
raise serializers.ValidationError({
'replace_pattern': ['This field is required for non-default profiles.']
})
return data
def update(self, instance, validated_data):
if instance.is_default:
# For default profiles, only allow updating name, custom_properties, and exp_date
allowed_fields = {'name', 'custom_properties', 'exp_date'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name, notes, and expiration. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
return super().update(instance, validated_data)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.is_default:
return Response(
{"error": "Default profiles cannot be deleted."},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = serializers.SerializerMethodField()
earliest_expiration = serializers.SerializerMethodField()
all_expirations = serializers.SerializerMethodField()
exp_date = serializers.DateTimeField(
required=False, allow_null=True, write_only=True,
help_text="Expiration date for the default profile (write-through)",
)
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
required=False,
allow_null=True,
)
profiles = M3UAccountProfileSerializer(many=True, read_only=True)
read_only_fields = ["locked", "created_at", "updated_at"]
# channel_groups = serializers.SerializerMethodField()
channel_groups = ChannelGroupM3UAccountSerializer(
source="channel_group", many=True, required=False
)
server_url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url],
)
enable_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
cron_expression = serializers.CharField(required=False, allow_blank=True, default="")
class Meta:
model = M3UAccount
fields = [
"id",
"name",
"server_url",
"file_path",
"server_group",
"max_streams",
"is_active",
"created_at",
"updated_at",
"filters",
"user_agent",
"profiles",
"locked",
"channel_groups",
"refresh_interval",
"cron_expression",
"custom_properties",
"account_type",
"username",
"password",
"stale_stream_days",
"priority",
"status",
"last_message",
"enable_vod",
"auto_enable_new_groups_live",
"auto_enable_new_groups_vod",
"auto_enable_new_groups_series",
"earliest_expiration",
"all_expirations",
"exp_date",
]
extra_kwargs = {
"password": {
"required": False,
"allow_blank": True,
},
}
def to_representation(self, instance):
data = super().to_representation(instance)
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
custom_props = instance.custom_properties or {}
data["enable_vod"] = custom_props.get("enable_vod", False)
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
# Derive cron_expression from the linked PeriodicTask's crontab (single source of truth)
# But first check if we have a transient _cron_expression (from create/update before signal runs)
cron_expr = ""
if hasattr(instance, '_cron_expression'):
cron_expr = instance._cron_expression
elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
data["cron_expression"] = cron_expr
# Surface default profile's exp_date for the form.
# Use prefetch cache (obj.profiles.all()) to avoid an extra query per account.
# Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local.
default_profile = next((p for p in instance.profiles.all() if p.is_default), None)
exp = default_profile.exp_date if default_profile else None
if exp:
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
else:
data["exp_date"] = None
return data
def update(self, instance, validated_data):
# Pop exp_date — it's written to the default profile, not the account
exp_date = validated_data.pop("exp_date", "__NOT_SET__")
# Pop cron_expression before it reaches model fields
# If not present (partial update), preserve the existing cron from the PeriodicTask
if "cron_expression" in validated_data:
cron_expr = validated_data.pop("cron_expression")
else:
cron_expr = ""
if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab:
ct = instance.refresh_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
instance._cron_expression = cron_expr
# Handle enable_vod preference and auto_enable_new_groups settings
enable_vod = validated_data.pop("enable_vod", None)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", None)
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", None)
# Get existing custom_properties
custom_props = instance.custom_properties or {}
# Update preferences
if enable_vod is not None:
custom_props["enable_vod"] = enable_vod
if auto_enable_new_groups_live is not None:
custom_props["auto_enable_new_groups_live"] = auto_enable_new_groups_live
if auto_enable_new_groups_vod is not None:
custom_props["auto_enable_new_groups_vod"] = auto_enable_new_groups_vod
if auto_enable_new_groups_series is not None:
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
# Pop out channel group memberships so we can handle them manually
channel_group_data = validated_data.pop("channel_group", [])
# First, update the M3UAccount itself
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
# Prepare a list of memberships to update
memberships_to_update = []
for group_data in channel_group_data:
group = group_data.get("channel_group")
enabled = group_data.get("enabled")
try:
membership = ChannelGroupM3UAccount.objects.get(
m3u_account=instance, channel_group=group
)
membership.enabled = enabled
memberships_to_update.append(membership)
except ChannelGroupM3UAccount.DoesNotExist:
continue
# Perform the bulk update
if memberships_to_update:
ChannelGroupM3UAccount.objects.bulk_update(
memberships_to_update, ["enabled"]
)
# Write exp_date through to the default profile.
# Use a fresh DB query (not the prefetch cache) so we get the profile
# object AFTER the post_save signal (create_profile_for_m3u_account)
# has already updated max_streams, avoiding a stale-value overwrite.
if exp_date != "__NOT_SET__":
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save(update_fields=['exp_date'])
# Invalidate the profiles prefetch cache so to_representation
# sees the updated exp_date rather than the pre-request snapshot.
if '_prefetched_objects_cache' in instance.__dict__:
instance._prefetched_objects_cache.pop('profiles', None)
return instance
def create(self, validated_data):
# Pop exp_date — it's written to the default profile after creation
exp_date = validated_data.pop("exp_date", None)
# Pop cron_expression — it's not a model field
cron_expr = validated_data.pop("cron_expression", "")
# Handle enable_vod preference and auto_enable_new_groups settings during creation
enable_vod = validated_data.pop("enable_vod", False)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", True)
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
# Parse existing custom_properties or create new
custom_props = validated_data.get("custom_properties", {})
# Set preferences (default to True for auto_enable_new_groups)
custom_props["enable_vod"] = enable_vod
custom_props["auto_enable_new_groups_live"] = auto_enable_new_groups_live
custom_props["auto_enable_new_groups_vod"] = auto_enable_new_groups_vod
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
# Build instance manually so we can attach transient attr before save triggers signal
instance = M3UAccount(**validated_data)
instance._cron_expression = cron_expr
instance.save()
# Write exp_date through to the default profile created by post_save signal
if exp_date is not None:
default_profile = instance.profiles.filter(is_default=True).first()
if default_profile:
default_profile.exp_date = exp_date
default_profile.save()
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
def get_earliest_expiration(self, obj):
"""Return the soonest exp_date across all active profiles for this account."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date]
if not expiring:
return None
exp = min(expiring)
exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc)
return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ')
def get_all_expirations(self, obj):
"""Return exp_date info for every profile that has one (for tooltip)."""
# Filter in Python over the prefetch cache to avoid an extra query per account.
profiles = sorted(
(p for p in obj.profiles.all() if p.exp_date),
key=lambda p: p.exp_date,
)
return [
{
"profile_id": p.id,
"profile_name": p.name,
"exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'),
"is_active": p.is_active,
}
for p in profiles
]
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""
class Meta:
model = ServerGroup
fields = ["id", "name"]
+196
View File
@@ -0,0 +1,196 @@
# apps/m3u/signals.py
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import M3UAccount, M3UAccountProfile
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
import json
import logging
logger = logging.getLogger(__name__)
@receiver(post_save, sender=M3UAccount)
def refresh_account_on_save(sender, instance, created, **kwargs):
"""
When an M3UAccount is saved (created or updated),
call a Celery task that fetches & parses that single account
if it is active or newly created.
"""
if created and instance.account_type != M3UAccount.Types.XC:
refresh_m3u_groups.delay(instance.id)
@receiver(post_save, sender=M3UAccount)
def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs):
"""
Create or update a Celery Beat periodic task when an M3UAccount is created/updated.
Supports both interval-based and cron-based scheduling via the shared utility.
"""
# Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message
# updates from the refresh task itself). We only need to reschedule when schedule-relevant
# fields change or when _cron_expression was explicitly set by the serializer.
SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'}
if (
not created
and update_fields is not None
and not (set(update_fields) & SCHEDULE_FIELDS)
and not hasattr(instance, '_cron_expression')
):
return
task_name = f"m3u_account-refresh-{instance.id}"
should_be_enabled = instance.is_active
# Read cron_expression from transient attribute set by the serializer.
# If not set (e.g. save came from a task updating status/last_message),
# preserve the existing crontab so we don't accidentally revert to interval.
if hasattr(instance, "_cron_expression"):
cron_expr = instance._cron_expression
else:
cron_expr = ""
try:
existing_task = instance.refresh_task
if existing_task and existing_task.crontab:
ct = existing_task.crontab
cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}"
except Exception:
pass
task = create_or_update_periodic_task(
task_name=task_name,
celery_task_path="apps.m3u.tasks.refresh_single_m3u_account",
kwargs={"account_id": instance.id},
interval_hours=int(instance.refresh_interval),
cron_expression=cron_expr,
enabled=should_be_enabled,
)
# Ensure instance has the task linked
if instance.refresh_task_id != task.id:
M3UAccount.objects.filter(id=instance.id).update(refresh_task=task)
@receiver(post_save, sender=M3UAccountProfile)
def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs):
"""
When a profile's exp_date is set or changed, immediately update its expiration notification
so the frontend reflects the new state without waiting for the daily celery task.
"""
# Only act when exp_date was involved in the save
if not created and update_fields is not None and "exp_date" not in update_fields:
return
try:
if not instance.exp_date:
# exp_date was cleared — remove any existing notifications immediately
from core.models import SystemNotification
from core.utils import send_notification_dismissed
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
return
from apps.m3u.tasks import evaluate_profile_expiration_notification
evaluate_profile_expiration_notification(instance)
except Exception as e:
logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}")
@receiver(post_delete, sender=M3UAccountProfile)
def cleanup_profile_notifications(sender, instance, **kwargs):
"""
Delete expiration notifications for a profile when it is deleted.
Handles both direct deletion and cascade deletion from M3UAccount.
"""
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"]
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}")
except Exception as e:
logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}")
@receiver(post_delete, sender=M3UAccount)
def delete_refresh_task(sender, instance, **kwargs):
"""
Delete the associated Celery Beat periodic task when a Channel is deleted.
"""
try:
# First try the foreign key relationship to find the task ID
task = None
if instance.refresh_task:
logger.info(f"Found task via foreign key: {instance.refresh_task.id} for M3UAccount {instance.id}")
task = instance.refresh_task
# Use the helper function to delete the task
if task:
delete_m3u_refresh_task_by_id(instance.id)
else:
# Otherwise use the helper function
delete_m3u_refresh_task_by_id(instance.id)
except Exception as e:
logger.error(f"Error in delete_refresh_task signal handler: {str(e)}", exc_info=True)
@receiver(pre_save, sender=M3UAccount)
def update_status_on_active_change(sender, instance, **kwargs):
"""
When an M3UAccount's is_active field changes, update the status accordingly.
"""
if instance.pk: # Only for existing records, not new ones
try:
# Get the current record from the database
old_instance = M3UAccount.objects.get(pk=instance.pk)
# If is_active changed, update the status
if old_instance.is_active != instance.is_active:
if instance.is_active:
# When activating, set status to idle
instance.status = M3UAccount.Status.IDLE
else:
# When deactivating, set status to disabled
instance.status = M3UAccount.Status.DISABLED
# Clean up any expiration notifications for all profiles of this account
try:
from core.models import SystemNotification
from core.utils import send_notification_dismissed
profile_ids = list(
M3UAccountProfile.objects.filter(m3u_account=instance)
.values_list("id", flat=True)
)
keys = [
key
for pid in profile_ids
for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"]
]
if keys:
deleted_keys = list(
SystemNotification.objects.filter(notification_key__in=keys)
.values_list("notification_key", flat=True)
)
if deleted_keys:
SystemNotification.objects.filter(notification_key__in=deleted_keys).delete()
for key in deleted_keys:
send_notification_dismissed(key)
logger.debug(
f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}"
)
except Exception as notify_err:
logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}")
except M3UAccount.DoesNotExist:
# New record, will use default status
pass
+3360
View File
File diff suppressed because it is too large Load Diff
View File
@@ -0,0 +1,216 @@
"""
Tests for evaluate_profile_expiration_notification.
Covers all four branches:
- no exp_date → returns None, touches nothing
- already expired → creates/updates expired notification, removes warning
- expiring within 7d → creates/updates warning notification, removes expired
- not expiring soon → removes any stale notifications, returns None
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from django.utils import timezone
def _make_profile(exp_date, profile_id=1, profile_name="Test Profile",
account_id=10, account_name="Test Account"):
"""Return a minimal mock M3UAccountProfile."""
profile = MagicMock()
profile.id = profile_id
profile.name = profile_name
profile.exp_date = exp_date
profile.m3u_account.id = account_id
profile.m3u_account.name = account_name
return profile
class EvaluateProfileExpirationNotificationTests(SimpleTestCase):
def setUp(self):
# These three names are local imports inside evaluate_profile_expiration_notification,
# so we must patch them at their source modules rather than on apps.m3u.tasks.
self.mock_sn = MagicMock()
self.mock_send_ws = patch("core.utils.send_websocket_notification").start()
self.mock_dismissed = patch("core.utils.send_notification_dismissed").start()
patch("core.models.SystemNotification", self.mock_sn).start()
def tearDown(self):
patch.stopall()
def _run(self, profile):
from apps.m3u.tasks import evaluate_profile_expiration_notification
return evaluate_profile_expiration_notification(profile)
# ------------------------------------------------------------------ #
# No expiration date
# ------------------------------------------------------------------ #
def test_no_exp_date_returns_none(self):
profile = _make_profile(exp_date=None)
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
# ------------------------------------------------------------------ #
# Already expired
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_expired_creates_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(days=1))
# No existing warning notification to delete
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-expired-{profile.id}")
self.mock_sn.objects.update_or_create.assert_called_once()
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_expired_removes_stale_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now - timedelta(hours=1))
warning_key = f"xc-exp-warning-{profile.id}"
# Simulate an existing warning notification
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(warning_key)
# ------------------------------------------------------------------ #
# Expiring within 7 days
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_warning_window_creates_warning_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=3))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
notification = MagicMock()
self.mock_sn.objects.update_or_create.return_value = (notification, True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
call_kwargs = self.mock_sn.objects.update_or_create.call_args
self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}")
self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"])
self.mock_send_ws.assert_called_once_with(notification)
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_today_when_same_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=2))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("today", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_message_says_1_day(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(hours=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
self._run(profile)
defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"]
self.assertIn("in 1 day", defaults["message"])
@patch("apps.m3u.tasks.timezone")
def test_warning_removes_stale_expired_notification(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=5))
expired_key = f"xc-exp-expired-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key]
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False)
self._run(profile)
self.mock_dismissed.assert_any_call(expired_key)
# ------------------------------------------------------------------ #
# Not expiring soon (> 7 days away)
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_returns_none(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
result = self._run(profile)
self.assertIsNone(result)
self.mock_sn.objects.update_or_create.assert_not_called()
self.mock_send_ws.assert_not_called()
@patch("apps.m3u.tasks.timezone")
def test_not_expiring_soon_removes_stale_notifications(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
profile = _make_profile(exp_date=now + timedelta(days=30))
warning_key = f"xc-exp-warning-{profile.id}"
self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key]
self._run(profile)
self.mock_dismissed.assert_called_once_with(warning_key)
# ------------------------------------------------------------------ #
# Boundary: exactly at the 7-day warning threshold
# ------------------------------------------------------------------ #
@patch("apps.m3u.tasks.timezone")
def test_exactly_7_days_away_triggers_warning(self, mock_tz):
now = timezone.now()
mock_tz.now.return_value = now
mock_tz.timedelta = timedelta
# exp_date == now + 7 days → exp <= warning_threshold → warning
profile = _make_profile(exp_date=now + timedelta(days=7))
self.mock_sn.objects.filter.return_value.values_list.return_value = []
self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True)
result = self._run(profile)
self.assertEqual(result, f"xc-exp-warning-{profile.id}")
+41
View File
@@ -0,0 +1,41 @@
from django.test import SimpleTestCase
from apps.m3u.tasks import parse_extinf_line
class ParseExtinfLineTests(SimpleTestCase):
def test_preserves_equals_padding_in_tvg_logo(self):
line = (
'#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" '
'tvg-chno="1001" '
'tvg-name="UK Sky Sports Premier League" '
'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?'
'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" '
'group-title="Team Games",UK Sky Sports Premier League'
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==",
)
self.assertEqual(parsed["attributes"]["group-title"], "Team Games")
self.assertEqual(parsed["name"], "UK Sky Sports Premier League")
def test_supports_single_quoted_attributes(self):
line = (
"#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' "
"group-title='Sports',Channel One"
)
parsed = parse_extinf_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(
parsed["attributes"]["tvg-logo"],
"https://example.com/logo==.png",
)
self.assertEqual(parsed["attributes"]["group-title"], "Sports")
self.assertEqual(parsed["display_name"], "Channel One")
+106
View File
@@ -0,0 +1,106 @@
"""
Tests for memory cleanup behavior in M3U refresh tasks.
Verifies that database connections are properly closed, task locks are
released on all exit paths, and garbage collection runs where expected.
"""
from unittest.mock import patch, MagicMock
from django.test import SimpleTestCase
from apps.m3u.models import M3UAccount
class ProcessM3UBatchCleanupTests(SimpleTestCase):
"""Verify process_m3u_batch_direct cleans up after processing."""
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls):
"""Database connections must be closed after batch processing (thread safety)."""
from apps.m3u.tasks import process_m3u_batch_direct
mock_account = MagicMock()
mock_account.filters.order_by.return_value = []
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("django.db.connections") as mock_connections:
process_m3u_batch_direct(1, [], {}, ["name", "url"])
mock_connections.close_all.assert_called()
class LockReleaseTests(SimpleTestCase):
"""Verify task lock is released on all exit paths."""
@patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False)
def test_lock_released_on_account_not_found(self, mock_delete):
"""release_task_lock must be called when account does not exist."""
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get",
side_effect=M3UAccount.DoesNotExist,
):
from apps.m3u.tasks import refresh_single_m3u_account
refresh_single_m3u_account(99999)
mock_release.assert_called_once_with(
"refresh_single_m3u_account", 99999
)
def test_lock_released_on_exception(self):
"""release_task_lock must be called when an exception is raised."""
mock_account = MagicMock()
mock_account.is_active = True
mock_account.account_type = "STD"
mock_account.custom_properties = {}
mock_account.filters.all.return_value = []
mock_account.status = MagicMock()
with patch(
"apps.m3u.tasks.acquire_task_lock", return_value=True
), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch(
"apps.m3u.tasks.TaskLockRenewer"
):
with patch(
"apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account
):
with patch("os.path.exists", return_value=False):
with patch(
"apps.m3u.tasks.refresh_m3u_groups",
side_effect=RuntimeError("test"),
):
from apps.m3u.tasks import refresh_single_m3u_account
try:
refresh_single_m3u_account(1)
except RuntimeError:
pass
mock_release.assert_called_once_with("refresh_single_m3u_account", 1)
class XCCategoryCleanupTests(SimpleTestCase):
"""Regression guard: process_xc_category_direct must continue to clean up."""
@patch("apps.m3u.tasks.XCClient")
@patch("apps.m3u.tasks.M3UAccount")
def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client):
"""gc.collect() must be called after XC category processing."""
from apps.m3u.tasks import process_xc_category_direct
mock_account = MagicMock()
mock_account_cls.objects.get.return_value = mock_account
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
process_xc_category_direct(1, {}, {}, ["name", "url"])
mock_gc.assert_called()
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from .views import M3UDashboardView
urlpatterns = [
path('dashboard', M3UDashboardView.as_view(), name='m3u_dashboard'),
]
+118
View File
@@ -0,0 +1,118 @@
# apps/m3u/utils.py
import threading
import logging
from django.db import models
lock = threading.Lock()
# Dictionary to track usage: {m3u_account_id: current_usage}
active_streams_map = {}
logger = logging.getLogger(__name__)
def normalize_stream_url(url):
"""
Normalize stream URLs for compatibility with FFmpeg.
Handles VLC-specific syntax like udp://@239.0.0.1:1234 by removing the @ symbol.
FFmpeg doesn't recognize the @ prefix for multicast addresses.
Args:
url (str): The stream URL to normalize
Returns:
str: The normalized URL
"""
if not url:
return url
# Handle VLC-style UDP multicast URLs: udp://@239.0.0.1:1234 -> udp://239.0.0.1:1234
# The @ symbol in VLC means "listen on all interfaces" but FFmpeg doesn't use this syntax
if url.startswith('udp://@'):
normalized = url.replace('udp://@', 'udp://', 1)
logger.debug(f"Normalized VLC-style UDP URL: {url} -> {normalized}")
return normalized
# Could add other normalizations here in the future (rtp://@, etc.)
return url
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def calculate_tuner_count(minimum=1, unlimited_default=10):
"""
Calculate tuner/connection count from active M3U profiles and custom streams.
This is the centralized function used by both HDHR and XtreamCodes APIs.
Args:
minimum (int): Minimum number to return (default: 1)
unlimited_default (int): Default value when unlimited profiles exist (default: 10)
Returns:
int: Calculated tuner/connection count
"""
try:
from apps.m3u.models import M3UAccountProfile
from apps.channels.models import Stream
# Calculate tuner count from active profiles from active M3U accounts (excluding default "custom Default" profile)
profiles = M3UAccountProfile.objects.filter(
is_active=True,
m3u_account__is_active=True, # Only include profiles from enabled M3U accounts
).exclude(id=1)
# 1. Check if any profile has unlimited streams (max_streams=0)
has_unlimited = profiles.filter(max_streams=0).exists()
# 2. Calculate tuner count from limited profiles
limited_tuners = 0
if not has_unlimited:
limited_tuners = (
profiles.filter(max_streams__gt=0)
.aggregate(total=models.Sum("max_streams"))
.get("total", 0)
or 0
)
# 3. Add custom stream count to tuner count
custom_stream_count = Stream.objects.filter(is_custom=True).count()
logger.debug(f"Found {custom_stream_count} custom streams")
# 4. Calculate final tuner count
if has_unlimited:
# If there are unlimited profiles, start with unlimited_default plus custom streams
tuner_count = unlimited_default + custom_stream_count
else:
# Otherwise use the limited profile sum plus custom streams
tuner_count = limited_tuners + custom_stream_count
# 5. Ensure minimum number
tuner_count = max(minimum, tuner_count)
logger.debug(
f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})"
)
return tuner_count
except Exception as e:
logger.error(f"Error calculating tuner count: {e}")
return minimum # Fallback to minimum value
+35
View File
@@ -0,0 +1,35 @@
from django.shortcuts import render
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
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(login_required, name='dispatch')
class M3UDashboardView(View):
def get(self, request, *args, **kwargs):
"""
Handles GET requests for the M3U dashboard.
Renders the m3u.html template with M3U account data.
"""
m3u_accounts = M3UAccount.objects.all()
return render(request, 'm3u/m3u.html', {'m3u_accounts': m3u_accounts})
def post(self, request, *args, **kwargs):
"""
Handles POST requests to create a new M3U account.
Expects JSON data in the request body.
"""
try:
data = json.loads(request.body)
new_account = M3UAccount.objects.create(**data)
return JsonResponse({
'id': new_account.id,
'message': 'M3U account created successfully!'
}, status=201)
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)