refactor(permissions): enhance permission handling across various viewsets

This update modifies permission classes in multiple viewsets to restrict access based on user roles. The `IsAdmin` permission is now enforced for several actions, including group management and permission listing, ensuring that only administrators can perform sensitive operations. Additionally, a new utility function, `resolve_safe_local_data_path`, is introduced to enhance security when accessing local file paths. The changes improve overall security and maintainability of the codebase.
This commit is contained in:
SergeantPanda 2026-07-18 20:03:03 +00:00
parent b6442e6421
commit 66089f14ee
19 changed files with 400 additions and 254 deletions

View file

@ -45,6 +45,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration.
- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs.
### Security
- **Authentication boundaries and local logo serving.**
- Logo and VOD logo `cache` endpoints no longer serve arbitrary filesystem paths: local URLs must resolve under `/data/logos` (blocks `/data/../…` traversal). Empty logo URLs return 404 instead of attempting a remote fetch.
- EPG source file upload API (`POST /api/epg/sources/upload/`) is admin-only and uses `safe_upload_path` so uploaded filenames cannot escape `/data/uploads/epgs`. The web UI does not expose this endpoint (EPG sources are added via URL, Schedules Direct, or dummy); the fix hardens the API-only path.
- M3U account passwords are omitted from API responses for non-admins; admins still receive them (the M3U edit form already blanks the password field on load).
- Channel bulk-regex rename, EPG name/logo/tvg-id apply, channel reorder, channel-group cleanup, recording stop/extend/comskip/metadata actions, and Connect integration APIs require admin. Guide-facing channel read helpers (`summary`, `ids`, etc.) remain available to standard users.
- System notifications: any authenticated user can still list and dismiss notifications visible to them; create/update/delete require admin (previously any authenticated user could create notifications).
- Django `auth.Group` CRUD (`/api/accounts/groups/`) and the permissions list endpoint require admin. Dispatcharr authorization uses `user_level`, not these groups; the React UI does not call these endpoints.
- Removed unused unauthenticated routes: `/proxy/hls/` (HLS proxy package retained for possible future use) and legacy `/output/stream/<uuid>/` (including the nginx location). Live playback continues via `/proxy/ts/stream/`.
### Fixed
- **Live channel Redis `state` no longer stays latched at `buffering` after a buffering-timeout failover.** When FFmpeg speed dipped below the buffering threshold and the timeout triggered a stream switch, the in-memory buffering flag was cleared without rewriting Redis, and the same stats sample could re-write `buffering`. After the new stream recovered, the speed-good path only cleared Redis when that flag was still set, so a healthy session could report `buffering` until restart or retune. A successful buffering-timeout switch now writes `active` and skips the fallthrough `buffering` write. (Fixes #1449)

View file

@ -268,13 +268,18 @@ class UserViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# 🔹 3) Group Management APIs
# 🔹 3) Group Management APIs (Django auth.Group; unused by the React UI)
class GroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Groups"""
"""CRUD for Django auth groups and their permissions.
Dispatcharr authorization uses ``user_level``, not these groups. The
endpoint is kept for compatibility but restricted to admins so
non-admins cannot invent auth groups or attach Django permissions.
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [Authenticated]
permission_classes = [IsAdmin]
@extend_schema(
description="Retrieve a list of groups",
@ -359,9 +364,9 @@ class APIKeyViewSet(viewsets.ViewSet):
responses={200: PermissionSerializer(many=True)},
)
@api_view(["GET"])
@permission_classes([Authenticated])
@permission_classes([IsAdmin])
def list_permissions(request):
"""Returns a list of all available permissions"""
"""Returns a list of all available Django permissions (admin only)."""
permissions = Permission.objects.all()
serializer = PermissionSerializer(permissions, many=True)
return Response(serializer.data)

View file

@ -0,0 +1,44 @@
"""Django auth.Group API is admin-only (unused by the React UI)."""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.accounts.api_views import GroupViewSet, list_permissions
class AuthGroupPermissionTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="auth_group_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="auth_group_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.factory = APIRequestFactory()
self.group = Group.objects.create(name="unused-role")
def test_standard_user_cannot_list_groups(self):
request = self.factory.get("/api/accounts/groups/")
force_authenticate(request, user=self.standard)
view = GroupViewSet.as_view({"get": "list"})
response = view(request)
self.assertEqual(response.status_code, 403)
def test_admin_can_list_groups(self):
request = self.factory.get("/api/accounts/groups/")
force_authenticate(request, user=self.admin)
view = GroupViewSet.as_view({"get": "list"})
response = view(request)
self.assertEqual(response.status_code, 200)
def test_standard_user_cannot_list_permissions(self):
request = self.factory.get("/api/accounts/permissions/")
force_authenticate(request, user=self.standard)
response = list_permissions(request)
self.assertEqual(response.status_code, 403)

View file

@ -21,12 +21,13 @@ from apps.accounts.permissions import (
Authenticated,
IsAdmin,
IsOwnerOfObject,
IsStandardUser,
permission_classes_by_action,
permission_classes_by_method,
)
from core.models import UserAgent, CoreSettings
from core.utils import RedisClient, safe_upload_path
from core.utils import RedisClient, safe_upload_path, resolve_safe_local_data_path
from apps.m3u.utils import convert_js_numbered_backreferences
from .models import (
@ -611,10 +612,12 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
serializer_class = ChannelGroupSerializer
def get_permissions(self):
if self.action == "cleanup_unused_groups":
return [IsAdmin()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
return [IsAdmin()]
def get_queryset(self):
# Annotate both counts at the SQL level so the serializer methods
@ -899,13 +902,26 @@ class ChannelViewSet(viewsets.ModelViewSet):
"match_epg",
"set_epg",
"batch_set_epg",
"bulk_regex_rename",
"set_names_from_epg",
"set_logos_from_epg",
"set_tvg_ids_from_epg",
"reorder",
]:
return [IsAdmin()]
if self.action in (
"get_ids",
"summary",
"numbers_in_range",
"by_uuids",
):
return [IsStandardUser()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
return [IsAdmin()]
def get_queryset(self):
# get_ids and summary only need the filter conditions, not the full
@ -2765,23 +2781,26 @@ class LogoViewSet(viewsets.ModelViewSet):
"""Streams the logo file, whether it's local or remote."""
logo = self.get_object()
logo_url = logo.url
if not logo_url:
raise Http404("Image not found")
if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url):
safe_path = resolve_safe_local_data_path(logo_url)
if safe_path is None or not os.path.exists(safe_path):
raise Http404("Image not found")
stat = os.stat(logo_url)
stat = os.stat(safe_path)
# Get proper mime type (first item of the tuple)
content_type, _ = mimetypes.guess_type(logo_url)
content_type, _ = mimetypes.guess_type(safe_path)
if not content_type:
content_type = "image/jpeg" # Default to a common image type
# Use context manager and set Content-Disposition to inline
# StreamingHttpResponse closes the file when the response finishes.
response = StreamingHttpResponse(
open(logo_url, "rb"), content_type=content_type
open(safe_path, "rb"), content_type=content_type
)
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
response["Last-Modified"] = http_date(stat.st_mtime)
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)
os.path.basename(safe_path)
)
return response
@ -3298,10 +3317,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
# classes run; _user_can_play_recording enforces authenticated access.
if self.action in ('file', 'hls'):
return [AllowAny()]
if self.action in (
'stop',
'extend',
'comskip',
'refresh_artwork',
'update_metadata',
):
return [IsAdmin()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]
return [IsAdmin()]
def _user_can_play_recording(self, request, recording):
"""Authorization gate for recording playback (file/hls actions).

View file

@ -0,0 +1,77 @@
"""Security-focused tests for local logo path jailing and related helpers."""
import tempfile
import uuid
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.api_views import LogoViewSet
from apps.channels.models import Logo
from core.utils import resolve_safe_local_data_path, safe_upload_path
class ResolveSafeLocalDataPathTests(TestCase):
def test_accepts_file_under_logos_root(self):
name = f"_jail_test_{uuid.uuid4().hex}.png"
target = Path("/data/logos") / name
target.write_bytes(b"x")
try:
resolved = resolve_safe_local_data_path(
str(target), allowed_roots=("/data/logos",)
)
self.assertEqual(resolved, str(target.resolve()))
finally:
target.unlink(missing_ok=True)
def test_rejects_path_traversal_outside_root(self):
self.assertIsNone(
resolve_safe_local_data_path(
"/data/../etc/passwd", allowed_roots=("/data/logos",)
)
)
def test_rejects_non_data_prefix(self):
self.assertIsNone(resolve_safe_local_data_path("/etc/passwd"))
def test_safe_upload_path_strips_directory_components(self):
with tempfile.TemporaryDirectory() as tmp:
path = safe_upload_path("../../evil.xml", tmp)
self.assertEqual(Path(path).name, "evil.xml")
self.assertTrue(Path(path).is_relative_to(Path(tmp).resolve()))
class LogoCachePathJailTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
User = get_user_model()
self.user, _ = User.objects.get_or_create(
username="logo_cache_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
def test_traversal_url_returns_404(self):
logo = Logo.objects.create(
name="Traversal",
url="/data/../etc/passwd",
)
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
force_authenticate(request, user=self.user)
view = LogoViewSet.as_view({"get": "cache"})
response = view(request, pk=logo.id)
self.assertEqual(response.status_code, 404)
def test_valid_local_logo_is_served(self):
name = f"_jail_serve_{uuid.uuid4().hex}.png"
file_path = Path("/data/logos") / name
file_path.write_bytes(b"\x89PNG\r\n\x1a\n")
logo = Logo.objects.create(name="Ok", url=str(file_path))
try:
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
view = LogoViewSet.as_view({"get": "cache"})
response = view(request, pk=logo.id)
self.assertEqual(response.status_code, 200)
b"".join(response.streaming_content)
finally:
file_path.unlink(missing_ok=True)

View file

@ -12,8 +12,6 @@ from .serializers import (
DeliveryLogSerializer,
)
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
IsAdmin,
)
from .handlers.webhook import WebhookHandler
@ -25,12 +23,8 @@ class IntegrationViewSet(viewsets.ModelViewSet):
serializer_class = IntegrationSerializer
def get_permissions(self):
try:
perms = permission_classes_by_action[self.action]
except KeyError:
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
perms = getattr(self, "permission_classes", [Authenticated])
return [perm() for perm in perms]
# Integrations expose webhook URLs / script paths in config; admin only.
return [IsAdmin()]
@action(detail=True, methods=["get"], url_path="subscriptions")
def list_subscriptions(self, request, pk=None):

View file

@ -32,6 +32,7 @@ from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
from core.utils import safe_upload_path
logger = logging.getLogger(__name__)
@ -50,6 +51,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
serializer_class = EPGSourceSerializer
def get_permissions(self):
if self.action == "upload":
return [IsAdmin()]
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
@ -57,7 +60,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if self.request.method == 'GET':
return [IsStandardUser()]
return [IsAdmin()]
return [Authenticated()]
return [IsAdmin()]
def get_queryset(self):
from django.db.models import Exists, OuterRef
@ -82,8 +85,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
)
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join("/data/uploads/epgs", file_name)
try:
file_path = safe_upload_path(file.name, "/data/uploads/epgs")
except ValueError:
return Response(
{"error": "Invalid filename"}, status=status.HTTP_400_BAD_REQUEST
)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb+") as destination:

View file

@ -0,0 +1,49 @@
"""EPG upload path safety and admin-only permission."""
from io import BytesIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.epg.api_views import EPGSourceViewSet
class EPGUploadSecurityTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="epg_upload_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="epg_upload_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.factory = APIRequestFactory()
def _upload(self, user, filename="guide.xml"):
content = SimpleUploadedFile(
filename, b"<?xml version='1.0'?><tv></tv>", content_type="application/xml"
)
request = self.factory.post(
"/api/epg/sources/upload/",
{"file": content, "name": "Uploaded", "source_type": "xmltv"},
format="multipart",
)
force_authenticate(request, user=user)
view = EPGSourceViewSet.as_view({"post": "upload"})
return view(request)
def test_standard_user_forbidden(self):
response = self._upload(self.standard)
self.assertEqual(response.status_code, 403)
@patch("apps.epg.api_views.safe_upload_path", side_effect=ValueError("bad"))
def test_traversal_filename_rejected(self, _mock):
response = self._upload(self.admin, filename="../../evil.xml")
self.assertEqual(response.status_code, 400)
self.assertIn("Invalid filename", response.data.get("error", ""))

View file

@ -189,6 +189,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"password": {
"required": False,
"allow_blank": True,
"write_only": True,
},
}
@ -213,6 +214,13 @@ class M3UAccountSerializer(serializers.ModelSerializer):
data = super().to_representation(instance)
# write_only strips password for everyone; re-add only for admins so
# operator tooling / profile regex helpers still see credentials.
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is not None and getattr(user, "user_level", 0) >= 10:
data["password"] = instance.password or ""
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
custom_props = instance.custom_properties or {}

View file

@ -0,0 +1,46 @@
"""M3U account password visibility for admin vs standard users."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory
from apps.m3u.models import M3UAccount
from apps.m3u.serializers import M3UAccountSerializer
class M3UPasswordVisibilityTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="m3u_pwd_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="m3u_pwd_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.account = M3UAccount.objects.create(
name="XC Acc",
server_url="http://example.test",
account_type="XC",
username="xcuser",
password="super-secret",
)
self.factory = APIRequestFactory()
def test_admin_sees_password(self):
request = self.factory.get("/api/m3u/accounts/")
request.user = self.admin
data = M3UAccountSerializer(
self.account, context={"request": request}
).data
self.assertEqual(data.get("password"), "super-secret")
def test_standard_user_does_not_see_password(self):
request = self.factory.get("/api/m3u/accounts/")
request.user = self.standard
data = M3UAccountSerializer(
self.account, context={"request": request}
).data
self.assertNotIn("password", data)

View file

@ -1,6 +1,5 @@
from django.urls import path, re_path, include
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
from core.views import stream_view
app_name = "output"
@ -9,6 +8,4 @@ urlpatterns = [
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
re_path(r"^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$", stream_view, name="stream"),
]

View file

@ -108,7 +108,7 @@ def epg_endpoint(request, profile_name=None, user=None):
def generate_m3u(request, profile_name=None, user=None):
"""
Dynamically generate an M3U file from channels.
The stream URL now points to the new stream_view that uses StreamProfile.
The stream URL points to the live TS proxy (``/proxy/ts/stream/``).
Supports both GET and POST methods for compatibility with IPTVSmarters.
"""
# Check if this is a POST request and the body is not empty (which we don't want to allow)

View file

@ -8,6 +8,5 @@ urlpatterns = [
path('stats/', stats_views.combined_stats, name='combined_stats'),
path('ts/', include('apps.proxy.live_proxy.urls')),
path('catchup/', include('apps.timeshift.urls')),
path('hls/', include('apps.proxy.hls_proxy.urls')),
path('vod/', include('apps.proxy.vod_proxy.urls')),
]

View file

@ -17,6 +17,7 @@ from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
)
from core.utils import resolve_safe_local_data_path
from .models import (
Series, VODCategory, Movie, Episode, VODLogo,
M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation
@ -860,17 +861,16 @@ class VODLogoViewSet(viewsets.ModelViewSet):
return HttpResponse(status=404)
# Check if this is a local file path
if logo.url.startswith('/data/'):
# It's a local file
file_path = logo.url
if not os.path.exists(file_path):
logger.error(f"VOD logo file not found: {file_path}")
if logo.url.startswith('/data'):
safe_path = resolve_safe_local_data_path(logo.url)
if safe_path is None or not os.path.exists(safe_path):
logger.error(f"VOD logo file not found or unsafe path: {logo.url}")
return HttpResponse(status=404)
try:
return FileResponse(open(file_path, 'rb'), content_type='image/png')
return FileResponse(open(safe_path, 'rb'), content_type='image/png')
except Exception as e:
logger.error(f"Error serving VOD logo file {file_path}: {str(e)}")
logger.error(f"Error serving VOD logo file {safe_path}: {str(e)}")
return HttpResponse(status=500)
else:
# It's a remote URL - proxy it

View file

@ -584,7 +584,17 @@ class SystemNotificationViewSet(viewsets.ModelViewSet):
Admins can create and manage notifications.
"""
serializer_class = SystemNotificationSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
if self.action in (
"create",
"update",
"partial_update",
"destroy",
):
return [IsAdmin()]
# list, retrieve, dismiss, dismiss_all, unread_count
return [IsAuthenticated()]
def get_queryset(self):
"""

View file

@ -0,0 +1,62 @@
"""SystemNotification write vs dismiss permission split."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from core.api_views import SystemNotificationViewSet
from core.models import SystemNotification
class SystemNotificationPermissionTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="notif_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="notif_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.notif = SystemNotification.objects.create(
notification_key="test.notif",
title="Hello",
message="World",
is_active=True,
admin_only=False,
)
self.factory = APIRequestFactory()
def test_standard_user_can_list(self):
request = self.factory.get("/api/system/notifications/")
force_authenticate(request, user=self.standard)
view = SystemNotificationViewSet.as_view({"get": "list"})
response = view(request)
self.assertEqual(response.status_code, 200)
def test_standard_user_cannot_create(self):
request = self.factory.post(
"/api/system/notifications/",
{
"notification_key": "evil",
"title": "Phish",
"message": "Click",
"is_active": True,
},
format="json",
)
force_authenticate(request, user=self.standard)
view = SystemNotificationViewSet.as_view({"post": "create"})
response = view(request)
self.assertEqual(response.status_code, 403)
def test_standard_user_can_dismiss(self):
request = self.factory.post(
f"/api/system/notifications/{self.notif.id}/dismiss/"
)
force_authenticate(request, user=self.standard)
view = SystemNotificationViewSet.as_view({"post": "dismiss"})
response = view(request, pk=self.notif.id)
self.assertEqual(response.status_code, 200)

View file

@ -660,6 +660,31 @@ def safe_upload_path(filename: str, base_dir) -> str:
return str(file_path)
def resolve_safe_local_data_path(path: str, allowed_roots=("/data/logos",)):
"""Return a realpath under one of *allowed_roots*, or None if unsafe.
Rejects path traversal (``/data/../etc/passwd``), symlinks that escape
the allowlisted roots, and non-``/data`` paths. Used by logo cache
endpoints that must remain AllowAny for player clients.
"""
if not path or not isinstance(path, str):
return None
if not path.startswith("/data"):
return None
try:
real = Path(path).resolve()
except (OSError, RuntimeError, ValueError):
return None
for root in allowed_roots:
try:
root_real = Path(root).resolve()
except (OSError, RuntimeError, ValueError):
continue
if real == root_real or real.is_relative_to(root_real):
return str(real)
return None
def is_protected_path(file_path):
"""
Determine if a file path is in a protected directory that shouldn't be deleted.

View file

@ -1,214 +1,9 @@
# core/views.py
import os
import signal
from shlex import split as shlex_split
import sys
import logging
import regex
import redis
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
from django.shortcuts import render
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccountProfile
from core.models import StreamProfile, CoreSettings
# Import the persistent lock (the “real” lock)
from dispatcharr.persistent_lock import PersistentLock
# Configure logging to output to the console.
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)
def settings_view(request):
"""
Renders the settings page.
"""
return render(request, 'settings.html')
def stream_view(request, channel_uuid):
"""
Streams the first available stream for the given channel.
It uses the channels assigned StreamProfile.
A persistent Redis lock is used to prevent concurrent streaming on the same channel.
"""
try:
redis_host = getattr(settings, "REDIS_HOST", "localhost")
redis_port = int(getattr(settings, "REDIS_PORT", 6379))
redis_db = int(getattr(settings, "REDIS_DB", "0"))
redis_password = getattr(settings, "REDIS_PASSWORD", "")
redis_user = getattr(settings, "REDIS_USER", "")
ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {})
redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
**ssl_params
)
# Retrieve the channel by the provided stream_id.
channel = Channel.objects.get(uuid=channel_uuid)
logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name)
# Ensure the channel has at least one stream.
if not channel.streams.exists():
logger.error("No streams found for channel ID=%s", channel.id)
return HttpResponseServerError("No stream found for this channel.")
active_stream = None
m3u_account = None
active_profile = None
lock_key = None
persistent_lock = None
streams = channel.streams.all().order_by('channelstream__order')
logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}')
for stream in streams:
# Get the first available stream.
logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name)
# Retrieve the M3U account associated with the stream.
m3u_account = stream.m3u_account
logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name)
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.url
logger.debug("Input URL: %s", input_url)
# Determine which profile we can use.
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
profiles = [obj for obj in m3u_profiles if not obj.is_default]
# -- Loop through profiles and pick the first active one --
for profile in [default_profile] + profiles:
logger.debug(f'Checking profile {profile.name}...')
if not profile.is_active:
logger.debug('Profile is not active, skipping.')
continue
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
stream_index = 0
max_streams = profile.max_streams
if max_streams == 0:
max_streams = 999999 # maybe a better way than just hardcoding a high number...
while stream_index < max_streams:
stream_index += 1
lock_key = f"lock:{profile.id}:{stream_index}"
persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120)
logger.debug(f'Attempting to acquire lock: {lock_key}')
if not persistent_lock.acquire():
logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.")
persistent_lock = None
continue
break
if persistent_lock is not None:
logger.debug(f'Successfully acquired lock: {lock_key}')
active_profile = M3UAccountProfile.objects.get(id=profile.id)
break
if active_profile is None or persistent_lock is None:
logger.exception("No available profiles for the stream")
continue
logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}")
break
if not active_profile:
logger.exception("No available streams for this channel")
return HttpResponseServerError("No available streams for this channel")
logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)")
# Prepare the pattern replacement.
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {active_profile.search_pattern}")
# Convert JS-style backreferences in replace: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {active_profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# regex module accepts JS-style (?<name>...) named groups natively
stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
# Get the stream profile set on the channel.
stream_profile = channel.stream_profile
if not stream_profile:
logger.error("No stream profile set for channel ID=%s, using default", channel.id)
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
logger.debug("Stream profile used: %s", stream_profile.name)
# Determine the user agent to use.
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
logger.debug("User agent: %s", user_agent)
# Substitute placeholders in the parameters template.
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
logger.debug("Formatted parameters: %s", parameters)
# Build the final command.
cmd = [stream_profile.command] + shlex_split(parameters)
logger.debug("Executing command: %s", cmd)
try:
stdout_r, stdout_w = os.pipe()
devnull_r = os.open(os.devnull, os.O_RDONLY)
devnull_w = os.open(os.devnull, os.O_WRONLY)
file_actions = [
(os.POSIX_SPAWN_DUP2, devnull_r, 0),
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
(os.POSIX_SPAWN_DUP2, devnull_w, 2),
(os.POSIX_SPAWN_CLOSE, devnull_r),
(os.POSIX_SPAWN_CLOSE, devnull_w),
(os.POSIX_SPAWN_CLOSE, stdout_w),
(os.POSIX_SPAWN_CLOSE, stdout_r),
]
proc_pid = os.posix_spawn(cmd[0], cmd, dict(os.environ), file_actions=file_actions)
for fd in (devnull_r, devnull_w, stdout_w):
os.close(fd)
proc_stdout = os.fdopen(stdout_r, 'rb')
except Exception as e:
persistent_lock.release()
logger.exception("Error starting stream for channel ID=%s", channel_uuid)
return HttpResponseServerError(f"Error starting stream: {e}")
except Exception as e:
logger.exception("Error preparing stream for channel ID=%s", channel_uuid)
return HttpResponseServerError(f"Error preparing stream: {e}")
def stream_generator(stdout_file, pid, persistent_lock):
try:
while True:
chunk = stdout_file.read(8192)
if not chunk:
break
yield chunk
finally:
try:
os.kill(pid, signal.SIGTERM)
logger.debug("Streaming process terminated for channel ID=%s", channel.id)
except OSError:
pass
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
stdout_file.close()
persistent_lock.release()
logger.debug("Persistent lock released for channel ID=%s", channel.id)
return StreamingHttpResponse(
stream_generator(proc_stdout, proc_pid, persistent_lock),
content_type="video/MP2T"
)

View file

@ -77,16 +77,6 @@ server {
uwsgi_pass unix:/app/uwsgi.sock;
}
# Serve FFmpeg streams efficiently
location /output/stream/ {
proxy_pass http://127.0.0.1:5656;
proxy_buffering off;
proxy_set_header Connection keep-alive;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
# WebSockets for real-time communication
location /ws/ {
proxy_pass http://127.0.0.1:8001;