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
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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import M3UDashboardView
|
||||
|
||||
urlpatterns = [
|
||||
path('dashboard', M3UDashboardView.as_view(), name='m3u_dashboard'),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user