Dispatcharr/apps/m3u/api_views.py

760 lines
28 KiB
Python

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
import logging
logger = logging.getLogger(__name__)
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", "filters")
serializer_class = M3UAccountSerializer
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
# Pre-aggregate stream counts for all accounts in one query so the
# nested ChannelGroupM3UAccountSerializer never issues a COUNT per
# group row. The serializer checks for this key and skips its own
# per-instance query when it is present.
from apps.channels.models import Stream
from django.db.models import Count
account_ids = list(queryset.values_list("id", flat=True))
counts_qs = (
Stream.objects.filter(m3u_account_id__in=account_ids)
.values("m3u_account_id", "channel_group_id")
.annotate(c=Count("id"))
)
stream_counts = {
(row["m3u_account_id"], row["channel_group_id"]): row["c"]
for row in counts_qs
}
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(
page, many=True,
context={**self.get_serializer_context(), "stream_counts": stream_counts},
)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(
queryset, many=True,
context={**self.get_serializer_context(), "stream_counts": stream_counts},
)
return Response(serializer.data)
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)
def destroy(self, request, *args, **kwargs):
"""
Delete an M3U account and all auto-created channels attributed
to it. Auto-created channels with no surviving provider have no
useful state (they cannot sync, their streams are about to
cascade away), so the delete is unconditional: the only
question for the user is whether to confirm. Manual channels
are untouched, even if they include streams from this account;
those streams cascade away independently and the channels
survive with their other streams. The legacy
``?cleanup_channels`` query parameter is accepted for backward
compatibility but ignored.
"""
instance = self.get_object()
from apps.channels.models import Channel
from apps.proxy.live_proxy.services.channel_service import (
ChannelService,
)
# Snapshot channels so proxy sessions can be stopped outside
# the DB transaction. The pre_delete signal would otherwise
# fire ChannelService.stop_channel (Redis pub / hgetall /
# setex) per channel inside the atomic, holding the DB
# connection across thousands of blocking RPCs and gumming up
# the connection pool.
channels_to_delete = list(
Channel.objects.filter(
auto_created=True,
auto_created_by=instance,
).values_list("id", "uuid")
)
for _, channel_uuid in channels_to_delete:
if not channel_uuid:
continue
try:
ChannelService.stop_channel(str(channel_uuid))
except Exception as e:
logger.warning(
"Failed to stop proxy session for channel %s "
"during account cleanup: %s",
channel_uuid,
e,
)
channel_ids = [cid for cid, _ in channels_to_delete]
# Channel + account writes share an atomic so an account
# delete failure rolls back the channel deletes too. The
# pre_delete signal will fire again here but its proxy stop
# is fast on already-stopped channels (a single Redis check
# returns "not found" immediately).
with transaction.atomic():
if channel_ids:
_, per_model = Channel.objects.filter(
id__in=channel_ids
).delete()
deleted_channels = per_model.get(
"dispatcharr_channels.Channel", 0
)
else:
deleted_channels = 0
response = super().destroy(request, *args, **kwargs)
# Surface the channel count alongside the standard 204; the
# confirmation toast renders the number to acknowledge what
# the cascade actually removed.
if response.status_code == status.HTTP_204_NO_CONTENT:
return Response(
{"deleted_channels": deleted_channels},
status=status.HTTP_200_OK,
)
return response
@extend_schema(
responses={
200: {
"type": "object",
"properties": {
"count": {"type": "integer"},
"sample_names": {"type": "array", "items": {"type": "string"}},
},
},
},
)
@action(detail=True, methods=["get"], url_path="auto-created-channels-count")
def auto_created_channels_count(self, request, pk=None):
"""
Preview how many auto-created channels would be removed if the account
were deleted with cleanup_channels=true. The frontend calls this when
the user clicks Delete, to render a truthful confirmation dialog
("Also delete N channels auto-created by this provider?").
"""
account = self.get_object()
from apps.channels.models import Channel
qs = Channel.objects.filter(
auto_created=True, auto_created_by=account
)
count = qs.count()
sample_names = list(qs.values_list("name", flat=True)[:5])
return Response({"count": count, "sample_names": sample_names})
@extend_schema(
parameters=[
OpenApiParameter(
name="channel_group_id",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=True,
description=(
"ID of the ChannelGroup whose auto-created channels "
"should be repacked."
),
),
],
responses={
200: {
"type": "object",
"properties": {
"assigned": {"type": "integer"},
"released": {"type": "integer"},
"failed": {"type": "integer"},
},
},
},
)
@action(detail=True, methods=["post"], url_path="repack-group")
def repack_group(self, request, pk=None):
"""
Manually re-pack visible channels in one of this account's
groups into the group's [start, end] range. Override-pinned
numbers are treated as reservations and skipped. Hidden channels
without overrides have their channel_number set to NULL.
Useful when the user has just finished customizing channels
(setting overrides as pins, hiding unwanted streams) and wants
the result reflected immediately rather than on the next M3U
refresh. Also acts as a one-shot cleanup for groups that aren't
running in compact mode but have accumulated gaps.
"""
account = self.get_object()
group_id_raw = request.query_params.get("channel_group_id")
if not group_id_raw:
return Response(
{"detail": "channel_group_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
group_id = int(group_id_raw)
except (TypeError, ValueError):
return Response(
{"detail": "channel_group_id must be an integer"},
status=status.HTTP_400_BAD_REQUEST,
)
from apps.channels.models import ChannelGroupM3UAccount
from apps.channels.compact_numbering import repack_group as _repack
from core.utils import acquire_task_lock, release_task_lock
# Share the lock that wraps the entire refresh-plus-sync pipeline
# (`refresh_single_m3u_account`). The narrower
# `refresh_m3u_account_groups` lock is released before
# `sync_auto_channels` runs, so it would not protect this writer
# from racing against the channel_number writes inside sync.
if not acquire_task_lock("refresh_single_m3u_account", account.id):
return Response(
{"detail": "An M3U refresh is in progress for this account."},
status=status.HTTP_409_CONFLICT,
)
try:
# Re-fetch under the lock so a sync that just released its lock
# cannot leave the cached group_relation reflecting pre-sync
# custom_properties (auto_sync_channel_start/end, etc.).
try:
group_relation = ChannelGroupM3UAccount.objects.get(
m3u_account=account, channel_group_id=group_id
)
except ChannelGroupM3UAccount.DoesNotExist:
return Response(
{"detail": "Group is not associated with this account"},
status=status.HTTP_404_NOT_FOUND,
)
result = _repack(group_relation)
finally:
try:
release_task_lock("refresh_single_m3u_account", account.id)
except Exception as e:
logger.warning(
f"Failed to release repack lock for account "
f"{account.id}: {e}"
)
return Response(result)
@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:
for setting in group_settings:
start = setting.get("auto_sync_channel_start")
end = setting.get("auto_sync_channel_end")
if (start is not None and start < 1) or (
end is not None and end < 1
):
return Response(
{
"error": (
f"Channel group {setting.get('channel_group')}: "
f"channel range must be >= 1."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
if start is not None and end is not None and end < start:
return Response(
{
"error": (
f"Channel group {setting.get('channel_group')}: "
f"auto_sync_channel_end must be >= "
f"auto_sync_channel_start."
)
},
status=status.HTTP_400_BAD_REQUEST,
)
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"),
auto_sync_channel_end=setting.get("auto_sync_channel_end"),
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",
"auto_sync_channel_end",
"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)