user management, user levels, user level channel access

This commit is contained in:
dekzter 2025-05-18 11:19:34 -04:00
parent eecf879119
commit 74d58515d0
42 changed files with 3788 additions and 1268 deletions

View file

@ -1,41 +1,37 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
AuthViewSet, UserViewSet, GroupViewSet,
list_permissions, initialize_superuser
AuthViewSet,
UserViewSet,
GroupViewSet,
list_permissions,
initialize_superuser,
)
from rest_framework_simplejwt import views as jwt_views
app_name = 'accounts'
app_name = "accounts"
# 🔹 Register ViewSets with a Router
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'groups', GroupViewSet, basename='group')
router.register(r"users", UserViewSet, basename="user")
router.register(r"groups", GroupViewSet, basename="group")
# 🔹 Custom Authentication Endpoints
auth_view = AuthViewSet.as_view({
'post': 'login'
})
auth_view = AuthViewSet.as_view({"post": "login"})
logout_view = AuthViewSet.as_view({
'post': 'logout'
})
logout_view = AuthViewSet.as_view({"post": "logout"})
# 🔹 Define API URL patterns
urlpatterns = [
# Authentication
path('auth/login/', auth_view, name='user-login'),
path('auth/logout/', logout_view, name='user-logout'),
path("auth/login/", auth_view, name="user-login"),
path("auth/logout/", logout_view, name="user-logout"),
# Superuser API
path('initialize-superuser/', initialize_superuser, name='initialize_superuser'),
path("initialize-superuser/", initialize_superuser, name="initialize_superuser"),
# Permissions API
path('permissions/', list_permissions, name='list-permissions'),
path('token/', jwt_views.TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'),
path("permissions/", list_permissions, name="list-permissions"),
path("token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
]
# 🔹 Include ViewSet routes

View file

@ -2,16 +2,20 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import Group, Permission
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import viewsets
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
import json
from .permissions import ReadOnly, IsAdmin
from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists.
def initialize_superuser(request):
@ -26,15 +30,20 @@ def initialize_superuser(request):
password = data.get("password")
email = data.get("email", "")
if not username or not password:
return JsonResponse({"error": "Username and password are required."}, status=400)
return JsonResponse(
{"error": "Username and password are required."}, status=400
)
# Create the superuser
User.objects.create_superuser(username=username, password=password, email=email)
User.objects.create_superuser(
username=username, password=password, email=email, user_level=10
)
return JsonResponse({"superuser_exists": True})
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
# For GET requests, indicate no superuser exists
return JsonResponse({"superuser_exists": False})
# 🔹 1) Authentication APIs
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
@ -43,36 +52,40 @@ class AuthViewSet(viewsets.ViewSet):
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'password'],
required=["username", "password"],
properties={
'username': openapi.Schema(type=openapi.TYPE_STRING),
'password': openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD)
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(
type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
),
},
),
responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
username = request.data.get('username')
password = request.data.get('password')
username = request.data.get("username")
password = request.data.get("password")
user = authenticate(request, username=username, password=password)
if user:
login(request, user)
return Response({
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list('name', flat=True))
return Response(
{
"message": "Login successful",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"groups": list(user.groups.values_list("name", flat=True)),
},
}
})
)
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"}
responses={200: "Logout successful"},
)
def logout(self, request):
"""Logs out the authenticated user"""
@ -83,13 +96,19 @@ class AuthViewSet(viewsets.ViewSet):
# 🔹 2) User Management APIs
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
if self.action == "me":
return [IsAuthenticated()]
return [IsAdmin()]
@swagger_auto_schema(
operation_description="Retrieve a list of users",
responses={200: UserSerializer(many=True)}
responses={200: UserSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@ -110,17 +129,28 @@ class UserViewSet(viewsets.ModelViewSet):
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@swagger_auto_schema(
method="get",
operation_description="Get active user information",
)
@action(detail=False, methods=["get"], url_path="me")
def me(self, request):
user = request.user
serializer = UserSerializer(user)
return Response(serializer.data)
# 🔹 3) Group Management APIs
class GroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Groups"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)}
responses={200: GroupSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@ -144,11 +174,11 @@ class GroupViewSet(viewsets.ModelViewSet):
# 🔹 4) Permissions List API
@swagger_auto_schema(
method='get',
method="get",
operation_description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)}
responses={200: PermissionSerializer(many=True)},
)
@api_view(['GET'])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def list_permissions(request):
"""Returns a list of all available permissions"""

View file

@ -1,6 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.accounts'
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
verbose_name = "Accounts & Authentication"

View file

@ -0,0 +1,42 @@
# Generated by Django 5.1.6 on 2025-05-13 16:59
from django.db import migrations, models
def set_user_level_to_10(apps, schema_editor):
User = apps.get_model(
"accounts", "User"
) # Use 'auth' if you're using the default User model
User.objects.update(user_level=10)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("dispatcharr_channels", "0019_channel_tvc_guide_stationid"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="channel_groups",
),
migrations.AddField(
model_name="user",
name="channel_profiles",
field=models.ManyToManyField(
blank=True,
related_name="users",
to="dispatcharr_channels.channelprofile",
),
),
migrations.AddField(
model_name="user",
name="user_level",
field=models.IntegerField(
choices=[(0, "Streamer"), (1, "ReadOnly"), (10, "Admin")], default=0
),
),
migrations.RunPython(set_user_level_to_10),
]

View file

@ -2,17 +2,25 @@
from django.db import models
from django.contrib.auth.models import AbstractUser, Permission
class User(AbstractUser):
"""
Custom user model for Dispatcharr.
Inherits from Django's AbstractUser to add additional fields if needed.
"""
class UserLevel(models.IntegerChoices):
STREAMER = 0, "Streamer"
READ_ONLY = 1, "ReadOnly"
ADMIN = 10, "Admin"
avatar_config = models.JSONField(default=dict, blank=True, null=True)
channel_groups = models.ManyToManyField(
'dispatcharr_channels.ChannelGroup', # Updated reference to renamed model
channel_profiles = models.ManyToManyField(
"dispatcharr_channels.ChannelProfile",
blank=True,
related_name="users"
related_name="users",
)
user_level = models.IntegerField(default=UserLevel.STREAMER)
def __str__(self):
return self.username

View file

@ -0,0 +1,38 @@
from rest_framework.permissions import BasePermission, IsAuthenticated
from .models import User
class ReadOnly(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.user_level >= User.UserLevel.READ_ONLY
class IsAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.user_level >= 10
class IsOwnerOfObject(BasePermission):
def has_object_permission(self, request, view, obj):
is_admin = IsAdmin().has_permission(request, view)
is_owner = request.user in obj.users.all()
return is_admin or is_owner
permission_classes_by_action = {
"list": [ReadOnly],
"create": [IsAdmin],
"retrieve": [ReadOnly],
"update": [IsAdmin],
"partial_update": [IsAdmin],
"destroy": [IsAdmin],
}
permission_classes_by_method = {
"GET": [ReadOnly],
"POST": [IsAdmin],
"PATCH": [IsAdmin],
"PUT": [IsAdmin],
"DELETE": [IsAdmin],
}

View file

@ -1,13 +1,14 @@
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
from .models import User
from apps.channels.models import ChannelProfile
# 🔹 Fix for Permission serialization
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = ['id', 'name', 'codename']
fields = ["id", "name", "codename"]
# 🔹 Fix for Group serialization
@ -18,15 +19,54 @@ class GroupSerializer(serializers.ModelSerializer):
class Meta:
model = Group
fields = ['id', 'name', 'permissions']
fields = ["id", "name", "permissions"]
# 🔹 Fix for User serialization
class UserSerializer(serializers.ModelSerializer):
groups = serializers.SlugRelatedField(
many=True, queryset=Group.objects.all(), slug_field="name"
) # ✅ Fix ManyToMany `_meta` error
password = serializers.CharField(write_only=True)
channel_profiles = serializers.PrimaryKeyRelatedField(
queryset=ChannelProfile.objects.all(), many=True, required=False
)
class Meta:
model = User
fields = ['id', 'username', 'email', 'groups']
fields = [
"id",
"username",
"email",
"user_level",
"password",
"channel_profiles",
]
def create(self, validated_data):
channel_profiles = validated_data.pop("channel_profiles", [])
user = User(
username=validated_data["username"], email=validated_data.get("email", "")
)
user.set_password(validated_data["password"])
user.is_active = True
user.save()
user.channel_profiles.set(channel_profiles)
return user
def update(self, instance, validated_data):
password = validated_data.pop("password", None)
channel_profiles = validated_data.pop("channel_profiles", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
if channel_profiles is not None:
instance.channel_profiles.set(channel_profiles)
return instance

View file

@ -5,6 +5,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import User
@receiver(post_save, sender=User)
def handle_new_user(sender, instance, created, **kwargs):
if created:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-18 14:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0020_alter_channel_channel_number'),
]
operations = [
migrations.AddField(
model_name='channel',
name='user_level',
field=models.IntegerField(default=0),
),
]

View file

@ -9,12 +9,14 @@ from datetime import datetime
import hashlib
import json
from apps.epg.models import EPGData
from apps.accounts.models import User
logger = logging.getLogger(__name__)
# If you have an M3UAccount model in apps.m3u, you can still import it:
from apps.m3u.models import M3UAccount
# Add fallback functions if Redis isn't available
def get_total_viewers(channel_id):
"""Get viewer count from Redis or return 0 if Redis isn't available"""
@ -25,6 +27,7 @@ def get_total_viewers(channel_id):
except Exception:
return 0
class ChannelGroup(models.Model):
name = models.TextField(unique=True, db_index=True)
@ -45,10 +48,12 @@ class ChannelGroup(models.Model):
return created_objects
class Stream(models.Model):
"""
Represents a single stream (e.g. from an M3U source or custom URL).
"""
name = models.CharField(max_length=255, default="Default Stream")
url = models.URLField(max_length=2000, blank=True, null=True)
m3u_account = models.ForeignKey(
@ -60,7 +65,7 @@ class Stream(models.Model):
)
logo_url = models.TextField(blank=True, null=True)
tvg_id = models.CharField(max_length=255, blank=True, null=True)
local_file = models.FileField(upload_to='uploads/', blank=True, null=True)
local_file = models.FileField(upload_to="uploads/", blank=True, null=True)
current_viewers = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
channel_group = models.ForeignKey(
@ -68,18 +73,18 @@ class Stream(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='streams'
related_name="streams",
)
stream_profile = models.ForeignKey(
StreamProfile,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='streams'
related_name="streams",
)
is_custom = models.BooleanField(
default=False,
help_text="Whether this is a user-created stream or from an M3U account"
help_text="Whether this is a user-created stream or from an M3U account",
)
stream_hash = models.CharField(
max_length=255,
@ -95,7 +100,7 @@ class Stream(models.Model):
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
verbose_name = "Stream"
verbose_name_plural = "Streams"
ordering = ['-updated_at']
ordering = ["-updated_at"]
def __str__(self):
return self.name or self.url or f"Stream ID {self.id}"
@ -105,14 +110,14 @@ class Stream(models.Model):
if keys is None:
keys = CoreSettings.get_m3u_hash_key().split(",")
stream_parts = {
"name": name, "url": url, "tvg_id": tvg_id
}
stream_parts = {"name": name, "url": url, "tvg_id": tvg_id}
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
# Serialize and hash the dictionary
serialized_obj = json.dumps(hash_parts, sort_keys=True) # sort_keys ensures consistent ordering
serialized_obj = json.dumps(
hash_parts, sort_keys=True
) # sort_keys ensures consistent ordering
hash_object = hashlib.sha256(serialized_obj.encode())
return hash_object.hexdigest()
@ -128,13 +133,17 @@ class Stream(models.Model):
return stream, False # False means it was updated, not created
except cls.DoesNotExist:
# If it doesn't exist, create a new object with the given hash
fields_to_update['stream_hash'] = hash_value # Make sure the hash field is set
fields_to_update["stream_hash"] = (
hash_value # Make sure the hash field is set
)
stream = cls.objects.create(**fields_to_update)
return stream, True # True means it was created
# @TODO: honor stream's stream profile
def get_stream_profile(self):
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()
)
return stream_profile
@ -152,7 +161,9 @@ class Stream(models.Model):
m3u_account = self.m3u_account
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
profiles = [default_profile] + [
obj for obj in m3u_profiles if not obj.is_default
]
for profile in profiles:
logger.info(profile)
@ -167,13 +178,19 @@ class Stream(models.Model):
if profile.max_streams == 0 or current_connections < profile.max_streams:
# Start a new stream
redis_client.set(f"channel_stream:{self.id}", self.id)
redis_client.set(f"stream_profile:{self.id}", profile.id) # Store only the matched profile
redis_client.set(
f"stream_profile:{self.id}", profile.id
) # Store only the matched profile
# Increment connection count for profiles with limits
if profile.max_streams > 0:
redis_client.incr(profile_connections_key)
return self.id, profile.id, None # Return newly assigned stream and matched profile
return (
self.id,
profile.id,
None,
) # Return newly assigned stream and matched profile
# 4. No available streams
return None, None, None
@ -194,7 +211,9 @@ class Stream(models.Model):
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
profile_id = int(profile_id)
logger.debug(f"Found profile ID {profile_id} associated with stream {stream_id}")
logger.debug(
f"Found profile ID {profile_id} associated with stream {stream_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
@ -203,6 +222,7 @@ class Stream(models.Model):
if current_count > 0:
redis_client.decr(profile_connections_key)
class ChannelManager(models.Manager):
def active(self):
return self.all()
@ -212,38 +232,35 @@ class Channel(models.Model):
channel_number = models.FloatField(db_index=True)
name = models.CharField(max_length=255)
logo = models.ForeignKey(
'Logo',
"Logo",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels',
related_name="channels",
)
# M2M to Stream now in the same file
streams = models.ManyToManyField(
Stream,
blank=True,
through='ChannelStream',
related_name='channels'
Stream, blank=True, through="ChannelStream", related_name="channels"
)
channel_group = models.ForeignKey(
'ChannelGroup',
"ChannelGroup",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels',
help_text="Channel group this channel belongs to."
related_name="channels",
help_text="Channel group this channel belongs to.",
)
tvg_id = models.CharField(max_length=255, blank=True, null=True)
tvc_guide_stationid = models.CharField(max_length=255, blank=True, null=True)
epg_data = models.ForeignKey(
EPGData,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels'
related_name="channels",
)
stream_profile = models.ForeignKey(
@ -251,16 +268,19 @@ class Channel(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels'
related_name="channels",
)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)
uuid = models.UUIDField(
default=uuid.uuid4, editable=False, unique=True, db_index=True
)
user_level = models.IntegerField(default=0)
def clean(self):
# Enforce unique channel_number within a given group
existing = Channel.objects.filter(
channel_number=self.channel_number,
channel_group=self.channel_group
channel_number=self.channel_number, channel_group=self.channel_group
).exclude(id=self.id)
if existing.exists():
raise ValidationError(
@ -272,7 +292,7 @@ class Channel(models.Model):
@classmethod
def get_next_available_channel_number(cls, starting_from=1):
used_numbers = set(cls.objects.all().values_list('channel_number', flat=True))
used_numbers = set(cls.objects.all().values_list("channel_number", flat=True))
n = starting_from
while n in used_numbers:
n += 1
@ -282,7 +302,9 @@ class Channel(models.Model):
def get_stream_profile(self):
stream_profile = self.stream_profile
if not stream_profile:
stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id())
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()
)
return stream_profile
@ -312,16 +334,20 @@ class Channel(models.Model):
profile_id = int(profile_id_bytes)
return stream_id, profile_id, None
except (ValueError, TypeError):
logger.debug(f"Invalid profile ID retrieved from Redis: {profile_id_bytes}")
logger.debug(
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
)
except (ValueError, TypeError):
logger.debug(f"Invalid stream ID retrieved from Redis: {stream_id_bytes}")
logger.debug(
f"Invalid stream ID retrieved from Redis: {stream_id_bytes}"
)
# No existing active stream, attempt to assign a new one
has_streams_but_maxed_out = False
has_active_profiles = False
# Iterate through channel streams and their profiles
for stream in self.streams.all().order_by('channelstream__order'):
for stream in self.streams.all().order_by("channelstream__order"):
# Retrieve the M3U account associated with the stream.
m3u_account = stream.m3u_account
if not m3u_account:
@ -329,13 +355,17 @@ class Channel(models.Model):
continue
m3u_profiles = m3u_account.profiles.all()
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
default_profile = next(
(obj for obj in m3u_profiles if obj.is_default), None
)
if not default_profile:
logger.debug(f"M3U account {m3u_account.id} has no default profile")
continue
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
profiles = [default_profile] + [
obj for obj in m3u_profiles if not obj.is_default
]
for profile in profiles:
# Skip inactive profiles
@ -346,10 +376,15 @@ class Channel(models.Model):
has_active_profiles = True
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
current_connections = int(
redis_client.get(profile_connections_key) or 0
)
# Check if profile has available slots (or unlimited connections)
if profile.max_streams == 0 or current_connections < profile.max_streams:
if (
profile.max_streams == 0
or current_connections < profile.max_streams
):
# Start a new stream
redis_client.set(f"channel_stream:{self.id}", stream.id)
redis_client.set(f"stream_profile:{stream.id}", profile.id)
@ -358,11 +393,17 @@ class Channel(models.Model):
if profile.max_streams > 0:
redis_client.incr(profile_connections_key)
return stream.id, profile.id, None # Return newly assigned stream and matched profile
return (
stream.id,
profile.id,
None,
) # Return newly assigned stream and matched profile
else:
# This profile is at max connections
has_streams_but_maxed_out = True
logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}")
logger.debug(
f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}"
)
# No available streams - determine specific reason
if has_streams_but_maxed_out:
@ -388,7 +429,9 @@ class Channel(models.Model):
redis_client.delete(f"channel_stream:{self.id}") # Remove active stream
stream_id = int(stream_id)
logger.debug(f"Found stream ID {stream_id} associated with channel stream {self.id}")
logger.debug(
f"Found stream ID {stream_id} associated with channel stream {self.id}"
)
# Get the matched profile for cleanup
profile_id = redis_client.get(f"stream_profile:{stream_id}")
@ -399,7 +442,9 @@ class Channel(models.Model):
redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association
profile_id = int(profile_id)
logger.debug(f"Found profile ID {profile_id} associated with stream {stream_id}")
logger.debug(
f"Found profile ID {profile_id} associated with stream {stream_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
@ -452,20 +497,26 @@ class Channel(models.Model):
# Increment connection count for new profile
new_profile_connections_key = f"profile_connections:{new_profile_id}"
redis_client.incr(new_profile_connections_key)
logger.info(f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}")
logger.info(
f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}"
)
return True
class ChannelProfile(models.Model):
name = models.CharField(max_length=100, unique=True)
class ChannelProfileMembership(models.Model):
channel_profile = models.ForeignKey(ChannelProfile, on_delete=models.CASCADE)
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
enabled = models.BooleanField(default=True) # Track if the channel is enabled for this group
enabled = models.BooleanField(
default=True
) # Track if the channel is enabled for this group
class Meta:
unique_together = ('channel_profile', 'channel')
unique_together = ("channel_profile", "channel")
class ChannelStream(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
@ -473,27 +524,26 @@ class ChannelStream(models.Model):
order = models.PositiveIntegerField(default=0) # Ordering field
class Meta:
ordering = ['order'] # Ensure streams are retrieved in order
ordering = ["order"] # Ensure streams are retrieved in order
constraints = [
models.UniqueConstraint(fields=['channel', 'stream'], name='unique_channel_stream')
models.UniqueConstraint(
fields=["channel", "stream"], name="unique_channel_stream"
)
]
class ChannelGroupM3UAccount(models.Model):
channel_group = models.ForeignKey(
ChannelGroup,
on_delete=models.CASCADE,
related_name='m3u_account'
ChannelGroup, on_delete=models.CASCADE, related_name="m3u_account"
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='channel_group'
M3UAccount, on_delete=models.CASCADE, related_name="channel_group"
)
custom_properties = models.TextField(null=True, blank=True)
enabled = models.BooleanField(default=True)
class Meta:
unique_together = ('channel_group', 'm3u_account')
unique_together = ("channel_group", "m3u_account")
def __str__(self):
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
@ -506,8 +556,11 @@ class Logo(models.Model):
def __str__(self):
return self.name
class Recording(models.Model):
channel = models.ForeignKey("Channel", on_delete=models.CASCADE, related_name="recordings")
channel = models.ForeignKey(
"Channel", on_delete=models.CASCADE, related_name="recordings"
)
start_time = models.DateTimeField()
end_time = models.DateTimeField()
task_id = models.CharField(max_length=255, null=True, blank=True)

View file

@ -1,5 +1,15 @@
from rest_framework import serializers
from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3UAccount, Logo, ChannelProfile, ChannelProfileMembership, Recording
from .models import (
Stream,
Channel,
ChannelGroup,
ChannelStream,
ChannelGroupM3UAccount,
Logo,
ChannelProfile,
ChannelProfileMembership,
Recording,
)
from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
from apps.epg.models import EPGData
@ -7,19 +17,23 @@ from django.urls import reverse
from rest_framework import serializers
from django.utils import timezone
class LogoSerializer(serializers.ModelSerializer):
cache_url = serializers.SerializerMethodField()
class Meta:
model = Logo
fields = ['id', 'name', 'url', 'cache_url']
fields = ["id", "name", "url", "cache_url"]
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
request = self.context.get('request')
request = self.context.get("request")
if request:
return request.build_absolute_uri(reverse('api:channels:logo-cache', args=[obj.id]))
return reverse('api:channels:logo-cache', args=[obj.id])
return request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[obj.id])
)
return reverse("api:channels:logo-cache", args=[obj.id])
#
# Stream
@ -27,43 +41,46 @@ class LogoSerializer(serializers.ModelSerializer):
class StreamSerializer(serializers.ModelSerializer):
stream_profile_id = serializers.PrimaryKeyRelatedField(
queryset=StreamProfile.objects.all(),
source='stream_profile',
source="stream_profile",
allow_null=True,
required=False
required=False,
)
read_only_fields = ['is_custom', 'm3u_account', 'stream_hash']
read_only_fields = ["is_custom", "m3u_account", "stream_hash"]
class Meta:
model = Stream
fields = [
'id',
'name',
'url',
'm3u_account', # Uncomment if using M3U fields
'logo_url',
'tvg_id',
'local_file',
'current_viewers',
'updated_at',
'last_seen',
'stream_profile_id',
'is_custom',
'channel_group',
'stream_hash',
"id",
"name",
"url",
"m3u_account", # Uncomment if using M3U fields
"logo_url",
"tvg_id",
"local_file",
"current_viewers",
"updated_at",
"last_seen",
"stream_profile_id",
"is_custom",
"channel_group",
"stream_hash",
]
def get_fields(self):
fields = super().get_fields()
# Unable to edit specific properties if this stream was created from an M3U account
if self.instance and getattr(self.instance, 'm3u_account', None) and not self.instance.is_custom:
fields['id'].read_only = True
fields['name'].read_only = True
fields['url'].read_only = True
fields['m3u_account'].read_only = True
fields['tvg_id'].read_only = True
fields['channel_group'].read_only = True
if (
self.instance
and getattr(self.instance, "m3u_account", None)
and not self.instance.is_custom
):
fields["id"].read_only = True
fields["name"].read_only = True
fields["url"].read_only = True
fields["m3u_account"].read_only = True
fields["tvg_id"].read_only = True
fields["channel_group"].read_only = True
return fields
@ -74,35 +91,38 @@ class StreamSerializer(serializers.ModelSerializer):
class ChannelGroupSerializer(serializers.ModelSerializer):
class Meta:
model = ChannelGroup
fields = ['id', 'name']
fields = ["id", "name"]
class ChannelProfileSerializer(serializers.ModelSerializer):
channels = serializers.SerializerMethodField()
class Meta:
model = ChannelProfile
fields = ['id', 'name', 'channels']
fields = ["id", "name", "channels"]
def get_channels(self, obj):
memberships = ChannelProfileMembership.objects.filter(channel_profile=obj, enabled=True)
return [
membership.channel.id
for membership in memberships
]
memberships = ChannelProfileMembership.objects.filter(
channel_profile=obj, enabled=True
)
return [membership.channel.id for membership in memberships]
class ChannelProfileMembershipSerializer(serializers.ModelSerializer):
class Meta:
model = ChannelProfileMembership
fields = ['channel', 'enabled']
fields = ["channel", "enabled"]
class ChanneProfilelMembershipUpdateSerializer(serializers.Serializer):
channel_id = serializers.IntegerField() # Ensure channel_id is an integer
enabled = serializers.BooleanField()
class BulkChannelProfileMembershipSerializer(serializers.Serializer):
channels = serializers.ListField(
child=ChanneProfilelMembershipUpdateSerializer(), # Use the nested serializer
allow_empty=False
allow_empty=False,
)
def validate_channels(self, value):
@ -110,6 +130,7 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer):
raise serializers.ValidationError("At least one channel must be provided.")
return value
#
# Channel
#
@ -119,14 +140,10 @@ class ChannelSerializer(serializers.ModelSerializer):
channel_number = serializers.FloatField(
allow_null=True,
required=False,
error_messages={
'invalid': 'Channel number must be a valid decimal number.'
}
error_messages={"invalid": "Channel number must be a valid decimal number."},
)
channel_group_id = serializers.PrimaryKeyRelatedField(
queryset=ChannelGroup.objects.all(),
source="channel_group",
required=False
queryset=ChannelGroup.objects.all(), source="channel_group", required=False
)
epg_data_id = serializers.PrimaryKeyRelatedField(
queryset=EPGData.objects.all(),
@ -137,16 +154,18 @@ class ChannelSerializer(serializers.ModelSerializer):
stream_profile_id = serializers.PrimaryKeyRelatedField(
queryset=StreamProfile.objects.all(),
source='stream_profile',
source="stream_profile",
allow_null=True,
required=False
required=False,
)
streams = serializers.PrimaryKeyRelatedField(queryset=Stream.objects.all(), many=True, required=False)
streams = serializers.PrimaryKeyRelatedField(
queryset=Stream.objects.all(), many=True, required=False
)
logo_id = serializers.PrimaryKeyRelatedField(
queryset=Logo.objects.all(),
source='logo',
source="logo",
allow_null=True,
required=False,
)
@ -154,24 +173,25 @@ class ChannelSerializer(serializers.ModelSerializer):
class Meta:
model = Channel
fields = [
'id',
'channel_number',
'name',
'channel_group_id',
'tvg_id',
'tvc_guide_stationid',
'epg_data_id',
'streams',
'stream_profile_id',
'uuid',
'logo_id',
"id",
"channel_number",
"name",
"channel_group_id",
"tvg_id",
"tvc_guide_stationid",
"epg_data_id",
"streams",
"stream_profile_id",
"uuid",
"logo_id",
"user_level",
]
def to_representation(self, instance):
include_streams = self.context.get('include_streams', False)
include_streams = self.context.get("include_streams", False)
if include_streams:
self.fields['streams'] = serializers.SerializerMethodField()
self.fields["streams"] = serializers.SerializerMethodField()
return super().to_representation(instance)
@ -180,22 +200,28 @@ class ChannelSerializer(serializers.ModelSerializer):
def get_streams(self, obj):
"""Retrieve ordered stream IDs for GET requests."""
return StreamSerializer(obj.streams.all().order_by('channelstream__order'), many=True).data
return StreamSerializer(
obj.streams.all().order_by("channelstream__order"), many=True
).data
def create(self, validated_data):
streams = validated_data.pop('streams', [])
channel_number = validated_data.pop('channel_number', Channel.get_next_available_channel_number())
streams = validated_data.pop("streams", [])
channel_number = validated_data.pop(
"channel_number", Channel.get_next_available_channel_number()
)
validated_data["channel_number"] = channel_number
channel = Channel.objects.create(**validated_data)
# Add streams in the specified order
for index, stream in enumerate(streams):
ChannelStream.objects.create(channel=channel, stream_id=stream.id, order=index)
ChannelStream.objects.create(
channel=channel, stream_id=stream.id, order=index
)
return channel
def update(self, instance, validated_data):
streams = validated_data.pop('streams', None)
streams = validated_data.pop("streams", None)
# Update standard fields
for attr, value in validated_data.items():
@ -206,8 +232,7 @@ class ChannelSerializer(serializers.ModelSerializer):
if streams is not None:
# Normalize stream IDs
normalized_ids = [
stream.id if hasattr(stream, "id") else stream
for stream in streams
stream.id if hasattr(stream, "id") else stream for stream in streams
]
print(normalized_ids)
@ -234,9 +259,7 @@ class ChannelSerializer(serializers.ModelSerializer):
cs.save(update_fields=["order"])
else:
ChannelStream.objects.create(
channel=instance,
stream_id=stream_id,
order=order
channel=instance, stream_id=stream_id, order=order
)
return instance
@ -250,20 +273,23 @@ class ChannelSerializer(serializers.ModelSerializer):
# Ensure it's processed as a float
return float(value)
except (ValueError, TypeError):
raise serializers.ValidationError("Channel number must be a valid decimal number.")
raise serializers.ValidationError(
"Channel number must be a valid decimal number."
)
def validate_stream_profile(self, value):
"""Handle special case where empty/0 values mean 'use default' (null)"""
if value == '0' or value == 0 or value == '' or value is None:
if value == "0" or value == 0 or value == "" or value is None:
return None
return value # PrimaryKeyRelatedField will handle the conversion to object
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
enabled = serializers.BooleanField()
class Meta:
model = ChannelGroupM3UAccount
fields = ['id', 'channel_group', 'enabled']
fields = ["id", "channel_group", "enabled"]
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
@ -272,12 +298,12 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = Recording
fields = '__all__'
read_only_fields = ['task_id']
fields = "__all__"
read_only_fields = ["task_id"]
def validate(self, data):
start_time = data.get('start_time')
end_time = data.get('end_time')
start_time = data.get("start_time")
end_time = data.get("end_time")
now = timezone.now() # timezone-aware current time
@ -286,8 +312,8 @@ class RecordingSerializer(serializers.ModelSerializer):
if start_time < now:
# Optional: Adjust start_time if it's in the past but end_time is in the future
data['start_time'] = now # or: timezone.now() + timedelta(seconds=1)
if end_time <= data['start_time']:
data["start_time"] = now # or: timezone.now() + timedelta(seconds=1)
if end_time <= data["start_time"]:
raise serializers.ValidationError("End time must be after start time.")
return data

View file

@ -9,11 +9,20 @@ from drf_yasg import openapi
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
from .serializers import ProgramDataSerializer, EPGSourceSerializer, EPGDataSerializer # Updated serializer
from .serializers import (
ProgramDataSerializer,
EPGSourceSerializer,
EPGDataSerializer,
) # Updated serializer
from .tasks import refresh_epg_data
from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
logger = logging.getLogger(__name__)
# ─────────────────────────────
# 1) EPG Source API (CRUD)
# ─────────────────────────────
@ -21,30 +30,38 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows EPG sources to be viewed or edited.
"""
queryset = EPGSource.objects.all()
serializer_class = EPGSourceSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG sources.")
return super().list(request, *args, **kwargs)
@action(detail=False, methods=['post'])
@action(detail=False, methods=["post"])
def upload(self, request):
if 'file' not in request.FILES:
return Response({'error': 'No file uploaded'}, status=status.HTTP_400_BAD_REQUEST)
if "file" not in request.FILES:
return Response(
{"error": "No file uploaded"}, status=status.HTTP_400_BAD_REQUEST
)
file = request.FILES['file']
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join('/data/uploads/epgs', file_name)
file_path = os.path.join("/data/uploads/epgs", file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
with open(file_path, "wb+") as destination:
for chunk in file.chunks():
destination.write(chunk)
new_obj_data = request.data.copy()
new_obj_data['file_path'] = file_path
new_obj_data["file_path"] = file_path
serializer = self.get_serializer(data=new_obj_data)
serializer.is_valid(raise_exception=True)
@ -57,55 +74,78 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
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']:
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'] = 'idle'
if request.data["is_active"]:
request.data["status"] = "idle"
else:
request.data['status'] = 'disabled'
request.data["status"] = "disabled"
# Continue with regular partial update
return super().partial_update(request, *args, **kwargs)
# ─────────────────────────────
# 2) Program API (CRUD)
# ─────────────────────────────
class ProgramViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs"""
queryset = ProgramData.objects.all()
serializer_class = ProgramDataSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs)
# ─────────────────────────────
# 3) EPG Grid View
# ─────────────────────────────
class EPGGridAPIView(APIView):
"""Returns all programs airing in the next 24 hours including currently running ones and recent ones"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
@swagger_auto_schema(
operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
responses={200: ProgramDataSerializer(many=True)}
responses={200: ProgramDataSerializer(many=True)},
)
def get(self, request, format=None):
# Use current time instead of midnight
now = timezone.now()
one_hour_ago = now - timedelta(hours=1)
twenty_four_hours_later = now + timedelta(hours=24)
logger.debug(f"EPGGridAPIView: Querying programs between {one_hour_ago} and {twenty_four_hours_later}.")
logger.debug(
f"EPGGridAPIView: Querying programs between {one_hour_ago} and {twenty_four_hours_later}."
)
# Use select_related to prefetch EPGData and include programs from the last hour
programs = ProgramData.objects.select_related('epg').filter(
programs = ProgramData.objects.select_related("epg").filter(
# Programs that end after one hour ago (includes recently ended programs)
end_time__gt=one_hour_ago,
# AND start before the end time window
start_time__lt=twenty_four_hours_later
start_time__lt=twenty_four_hours_later,
)
count = programs.count()
logger.debug(f"EPGGridAPIView: Found {count} program(s), including recently ended, currently running, and upcoming shows.")
logger.debug(
f"EPGGridAPIView: Found {count} program(s), including recently ended, currently running, and upcoming shows."
)
# Generate dummy programs for channels that have no EPG data
from apps.channels.models import Channel
@ -118,9 +158,13 @@ class EPGGridAPIView(APIView):
# Log more detailed information about channels missing EPG data
if channels_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_without_epg]
logger.warning(f"EPGGridAPIView: Missing EPG data for these channels: {', '.join(channel_names)}")
logger.warning(
f"EPGGridAPIView: Missing EPG data for these channels: {', '.join(channel_names)}"
)
logger.debug(f"EPGGridAPIView: Found {channels_count} channels with no EPG data.")
logger.debug(
f"EPGGridAPIView: Found {channels_count} channels with no EPG data."
)
# Serialize the regular programs
serialized_programs = ProgramDataSerializer(programs, many=True).data
@ -130,33 +174,33 @@ class EPGGridAPIView(APIView):
(0, 4): [
"Late Night with {channel} - Where insomniacs unite!",
"The 'Why Am I Still Awake?' Show on {channel}",
"Counting Sheep - A {channel} production for the sleepless"
"Counting Sheep - A {channel} production for the sleepless",
],
(4, 8): [
"Dawn Patrol - Rise and shine with {channel}!",
"Early Bird Special - Coffee not included",
"Morning Zombies - Before coffee viewing on {channel}"
"Morning Zombies - Before coffee viewing on {channel}",
],
(8, 12): [
"Mid-Morning Meetings - Pretend you're paying attention while watching {channel}",
"The 'I Should Be Working' Hour on {channel}",
"Productivity Killer - {channel}'s daytime programming"
"Productivity Killer - {channel}'s daytime programming",
],
(12, 16): [
"Lunchtime Laziness with {channel}",
"The Afternoon Slump - Brought to you by {channel}",
"Post-Lunch Food Coma Theater on {channel}"
"Post-Lunch Food Coma Theater on {channel}",
],
(16, 20): [
"Rush Hour - {channel}'s alternative to traffic",
"The 'What's For Dinner?' Debate on {channel}",
"Evening Escapism - {channel}'s remedy for reality"
"Evening Escapism - {channel}'s remedy for reality",
],
(20, 24): [
"Prime Time Placeholder - {channel}'s finest not-programming",
"The 'Netflix Was Too Complicated' Show on {channel}",
"Family Argument Avoider - Courtesy of {channel}"
]
"Family Argument Avoider - Courtesy of {channel}",
],
}
# Generate and append dummy programs
@ -184,7 +228,9 @@ class EPGGridAPIView(APIView):
if start_range <= hour < end_range:
# Pick a description using the sum of the hour and day as seed
# This makes it somewhat random but consistent for the same timeslot
description = descriptions[(hour + day) % len(descriptions)].format(channel=channel.name)
description = descriptions[
(hour + day) % len(descriptions)
].format(channel=channel.name)
break
else:
# Fallback description if somehow no range matches
@ -192,29 +238,31 @@ class EPGGridAPIView(APIView):
# Create a dummy program in the same format as regular programs
dummy_program = {
'id': f"dummy-{channel.id}-{hour_offset}", # Create a unique ID
'epg': {
'tvg_id': dummy_tvg_id,
'name': channel.name
},
'start_time': start_time.isoformat(),
'end_time': end_time.isoformat(),
'title': f"{channel.name}",
'description': description,
'tvg_id': dummy_tvg_id,
'sub_title': None,
'custom_properties': None
"id": f"dummy-{channel.id}-{hour_offset}", # Create a unique ID
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"title": f"{channel.name}",
"description": description,
"tvg_id": dummy_tvg_id,
"sub_title": None,
"custom_properties": None,
}
dummy_programs.append(dummy_program)
except Exception as e:
logger.error(f"Error creating dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}")
logger.error(
f"Error creating dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Combine regular and dummy programs
all_programs = list(serialized_programs) + dummy_programs
logger.debug(f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs).")
logger.debug(
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)."
)
return Response({"data": all_programs}, status=status.HTTP_200_OK)
return Response({'data': all_programs}, status=status.HTTP_200_OK)
# ─────────────────────────────
# 4) EPG Import View
@ -222,15 +270,26 @@ class EPGGridAPIView(APIView):
class EPGImportAPIView(APIView):
"""Triggers an EPG data refresh"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [IsAuthenticated()]
@swagger_auto_schema(
operation_description="Triggers an EPG data import",
responses={202: "EPG data import initiated"}
responses={202: "EPG data import initiated"},
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
refresh_epg_data.delay(request.data.get('id', None)) # Trigger Celery task
refresh_epg_data.delay(request.data.get("id", None)) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response({'success': True, 'message': 'EPG data import initiated.'}, status=status.HTTP_202_ACCEPTED)
return Response(
{"success": True, "message": "EPG data import initiated."},
status=status.HTTP_202_ACCEPTED,
)
# ─────────────────────────────
@ -240,6 +299,12 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint that allows EPGData objects to be viewed.
"""
queryset = EPGData.objects.all()
serializer_class = EPGDataSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]

View file

@ -2,6 +2,8 @@ from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import permission_classes_by_action
from apps.accounts.permissions import permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
from drf_yasg.utils import swagger_auto_schema
@ -18,21 +20,30 @@ from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from apps.m3u.models import M3UAccountProfile
# Configure logger
logger = logging.getLogger(__name__)
@login_required
def hdhr_dashboard_view(request):
"""Render the HDHR management page."""
hdhr_devices = HDHRDevice.objects.all()
return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices})
# 🔹 1) HDHomeRun Device API
class HDHRDeviceViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for HDHomeRun devices"""
queryset = HDHRDevice.objects.all()
serializer_class = HDHRDeviceSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
# 🔹 2) Discover API
@ -41,20 +52,20 @@ class DiscoverAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")}
responses={200: openapi.Response("HDHR Discovery JSON")},
)
def get(self, request, profile=None):
uri_parts = ["hdhr"]
if profile is not None:
uri_parts.append(profile)
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip('/')
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
device = HDHRDevice.objects.first()
# 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
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)
@ -63,9 +74,12 @@ class DiscoverAPIView(APIView):
# 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
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()
@ -82,7 +96,9 @@ class DiscoverAPIView(APIView):
# 5. Ensure minimum of 2 tuners
tuner_count = max(2, tuner_count)
logger.debug(f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})")
logger.debug(
f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})"
)
if not device:
data = {
@ -117,17 +133,17 @@ class LineupAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")}
responses={200: openapi.Response("Channel Lineup JSON")},
)
def get(self, request, profile=None):
if profile is not None:
channel_profile = ChannelProfile.objects.get(name=profile)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True
).order_by('channel_number')
channelprofilemembership__enabled=True,
).order_by("channel_number")
else:
channels = Channel.objects.all().order_by('channel_number')
channels = Channel.objects.all().order_by("channel_number")
lineup = []
for ch in channels:
@ -140,13 +156,15 @@ class LineupAPIView(APIView):
else:
formatted_channel_number = ""
lineup.append({
"GuideNumber": formatted_channel_number,
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
"Guide_ID": formatted_channel_number,
"Station": formatted_channel_number,
})
lineup.append(
{
"GuideNumber": formatted_channel_number,
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
"Guide_ID": formatted_channel_number,
"Station": formatted_channel_number,
}
)
return JsonResponse(lineup, safe=False)
@ -156,14 +174,14 @@ class LineupStatusAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")}
responses={200: openapi.Response("Lineup Status JSON")},
)
def get(self, request, profile=None):
data = {
"ScanInProgress": 0,
"ScanPossible": 0,
"Source": "Cable",
"SourceList": ["Cable"]
"SourceList": ["Cable"],
}
return JsonResponse(data)
@ -174,10 +192,10 @@ class HDHRDeviceXMLAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")}
responses={200: openapi.Response("HDHR Device XML")},
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -2,6 +2,7 @@ from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
@ -16,18 +17,26 @@ from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
@login_required
def hdhr_dashboard_view(request):
"""Render the HDHR management page."""
hdhr_devices = HDHRDevice.objects.all()
return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices})
# 🔹 1) HDHomeRun Device API
class HDHRDeviceViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for HDHomeRun devices"""
queryset = HDHRDevice.objects.all()
serializer_class = HDHRDeviceSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
# 🔹 2) Discover API
@ -36,10 +45,10 @@ class DiscoverAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")}
responses={200: openapi.Response("HDHR Discovery JSON")},
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
device = HDHRDevice.objects.first()
if not device:
@ -75,15 +84,15 @@ class LineupAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")}
responses={200: openapi.Response("Channel Lineup JSON")},
)
def get(self, request):
channels = Channel.objects.all().order_by('channel_number')
channels = Channel.objects.all().order_by("channel_number")
lineup = [
{
"GuideNumber": str(ch.channel_number),
"GuideName": ch.name,
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
}
for ch in channels
]
@ -96,14 +105,14 @@ class LineupStatusAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")}
responses={200: openapi.Response("Lineup Status JSON")},
)
def get(self, request):
data = {
"ScanInProgress": 0,
"ScanPossible": 0,
"Source": "Cable",
"SourceList": ["Cable"]
"SourceList": ["Cable"],
}
return JsonResponse(data)
@ -114,10 +123,10 @@ class HDHRDeviceXMLAPIView(APIView):
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")}
responses={200: openapi.Response("HDHR Device XML")},
)
def get(self, request):
base_url = request.build_absolute_uri('/hdhr/').rstrip('/')
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
<root>

View file

@ -2,6 +2,10 @@ from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
@ -17,6 +21,7 @@ from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer
# Import all serializers, including the UserAgentSerializer.
from .serializers import (
M3UAccountSerializer,
@ -29,37 +34,46 @@ from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
class M3UAccountViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U accounts"""
queryset = M3UAccount.objects.prefetch_related('channel_group')
queryset = M3UAccount.objects.prefetch_related("channel_group")
serializer_class = M3UAccountSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
def create(self, request, *args, **kwargs):
# Handle file upload first, if any
file_path = None
if 'file' in request.FILES:
file = request.FILES['file']
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join('/data/uploads/m3us', file_name)
file_path = os.path.join("/data/uploads/m3us", file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
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
request.data.pop('server_url')
request.data["file_path"] = (
file_path # Include the file path if a file was uploaded
)
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)
print(response.data.get('account_type'))
if response.data.get('account_type') == M3UAccount.Types.XC:
refresh_m3u_groups(response.data.get('id'))
print(response.data.get("account_type"))
if response.data.get("account_type") == M3UAccount.Types.XC:
refresh_m3u_groups(response.data.get("id"))
# After the instance is created, return the response
return response
@ -69,20 +83,22 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Handle file upload first, if any
file_path = None
if 'file' in request.FILES:
file = request.FILES['file']
if "file" in request.FILES:
file = request.FILES["file"]
file_name = file.name
file_path = os.path.join('/data/uploads/m3us', file_name)
file_path = os.path.join("/data/uploads/m3us", file_name)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'wb+') as destination:
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
request.data.pop('server_url')
request.data["file_path"] = (
file_path # Include the file path if a file was uploaded
)
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):
@ -99,75 +115,131 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
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']:
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
if request.data["is_active"]:
request.data["status"] = M3UAccount.Status.IDLE
else:
request.data['status'] = M3UAccount.Status.DISABLED
request.data["status"] = M3UAccount.Status.DISABLED
# Continue with regular partial update
return super().partial_update(request, *args, **kwargs)
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""
queryset = ServerGroup.objects.all()
serializer_class = ServerGroupSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
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 [IsAuthenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of all active M3U accounts",
responses={202: "M3U refresh initiated"}
responses={202: "M3U refresh initiated"},
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
return Response({'success': True, 'message': 'M3U refresh initiated.'}, status=status.HTTP_202_ACCEPTED)
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 [IsAuthenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of a single M3U account",
responses={202: "M3U account refresh initiated"}
responses={202: "M3U account refresh initiated"},
)
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)
return Response(
{
"success": True,
"message": f"M3U account {account_id} refresh initiated.",
},
status=status.HTTP_202_ACCEPTED,
)
class UserAgentViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for User Agents"""
queryset = UserAgent.objects.all()
serializer_class = UserAgentSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
class M3UAccountProfileViewSet(viewsets.ModelViewSet):
queryset = M3UAccountProfile.objects.all()
serializer_class = M3UAccountProfileSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
try:
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [IsAuthenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs['account_id']
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']
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
serializer.context["m3u_account"] = m3u_account
# Perform the actual save
serializer.save(m3u_account_id=m3u_account)

View file

@ -7,7 +7,8 @@ from apps.channels.models import StreamProfile
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings, UserAgent
CUSTOM_M3U_ACCOUNT_NAME="custom"
CUSTOM_M3U_ACCOUNT_NAME = "custom"
class M3UAccount(models.Model):
class Types(models.TextChoices):
@ -25,72 +26,61 @@ class M3UAccount(models.Model):
"""Represents an M3U Account for IPTV streams."""
name = models.CharField(
max_length=255,
unique=True,
help_text="Unique name for this M3U account"
max_length=255, unique=True, help_text="Unique name for this M3U account"
)
server_url = models.URLField(
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
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',
"ServerGroup",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='m3u_accounts',
help_text="The server group this M3U account belongs to"
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)"
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"
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"
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"
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
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"
help_text="Last status message, including success results or error information",
)
user_agent = models.ForeignKey(
'core.UserAgent',
"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."
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"
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'
related_name="m3u_accounts",
)
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
username = models.CharField(max_length=255, null=True, blank=True)
@ -102,7 +92,7 @@ class M3UAccount(models.Model):
)
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."
help_text="Number of days after which a stream will be removed if not seen in the M3U source.",
)
def __str__(self):
@ -134,17 +124,19 @@ class M3UAccount(models.Model):
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())
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']:
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')
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):
@ -158,35 +150,36 @@ class M3UAccount(models.Model):
# """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 Title'),
('name', 'Stream Name'),
("group", "Group Title"),
("name", "Stream Name"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
related_name='filters',
help_text="The M3U account this filter is applied to."
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."
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."
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."
help_text="If True, matching items are excluded; if False, only matches are included.",
)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == 'group' else stream_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):
@ -196,7 +189,9 @@ class M3UFilter(models.Model):
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')
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})"
@ -222,40 +217,38 @@ class M3UFilter(models.Model):
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."
max_length=100, unique=True, help_text="Unique name for this server group."
)
def __str__(self):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""
m3u_account = models.ForeignKey(
'M3UAccount',
"M3UAccount",
on_delete=models.CASCADE,
related_name='profiles',
help_text="The M3U account this profile belongs to."
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"
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"
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)"
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"
default=True, help_text="Set to false to deactivate this profile"
)
search_pattern = models.CharField(
max_length=255,
@ -267,19 +260,22 @@ class M3UAccountProfile(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(fields=['m3u_account', 'name'], name='unique_account_name')
models.UniqueConstraint(
fields=["m3u_account", "name"], name="unique_account_name"
)
]
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
@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',
name=f"{instance.name} Default",
max_streams=instance.max_streams,
is_default=True,
is_active=True,
@ -292,6 +288,5 @@ def create_profile_for_m3u_account(sender, instance, created, **kwargs):
is_default=True,
)
profile.max_streams = instance.max_streams
profile.save()

View file

@ -1,5 +1,5 @@
from django.urls import path, re_path, include
from .views import generate_m3u, generate_epg
from .views import generate_m3u, generate_epg, xc_get
from core.views import stream_view
app_name = 'output'

View file

@ -1,25 +1,33 @@
from django.http import HttpResponse
from django.http import HttpResponse, JsonResponse, Http404
from rest_framework.response import Response
from django.urls import reverse
from apps.channels.models import Channel, ChannelProfile
from apps.channels.models import Channel, ChannelProfile, ChannelGroup
from apps.epg.models import ProgramData
from django.utils import timezone
from datetime import datetime, timedelta
import re
import html # Add this import for XML escaping
from django.contrib.auth import authenticate
from tzlocal import get_localzone
import time
import json
from urllib.parse import urlparse
def generate_m3u(request, profile_name=None):
"""
Dynamically generate an M3U file from channels.
The stream URL now points to the new stream_view that uses StreamProfile.
"""
if profile_name is not None:
channel_profile = ChannelProfile.objects.get(name=profile_name)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True
).order_by('channel_number')
def generate_m3u(request, user):
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
channels = Channel.objects.order_by('channel_number')
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
m3u_content = "#EXTM3U\n"
for channel in channels:
@ -35,34 +43,45 @@ def generate_m3u(request, profile_name=None):
formatted_channel_number = ""
# Use formatted channel number for tvg_id to ensure proper matching with EPG
tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id)
tvg_id = (
str(formatted_channel_number)
if formatted_channel_number != ""
else str(channel.id)
)
tvg_name = channel.name
tvg_logo = ""
if channel.logo:
tvg_logo = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
tvg_logo = request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[channel.logo.id])
)
# create possible gracenote id insertion
tvc_guide_stationid = ""
if channel.tvc_guide_stationid:
tvc_guide_stationid = f'tvc-guide-stationid="{channel.tvc_guide_stationid}" '
tvc_guide_stationid = (
f'tvc-guide-stationid="{channel.tvc_guide_stationid}" '
)
extinf_line = (
f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" '
f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n'
)
base_url = request.build_absolute_uri('/')[:-1]
base_url = request.build_absolute_uri("/")[:-1]
stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}"
#stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id]))
# stream_url = request.build_absolute_uri(reverse('output:stream', args=[channel.id]))
m3u_content += extinf_line + stream_url + "\n"
response = HttpResponse(m3u_content, content_type="audio/x-mpegurl")
response['Content-Disposition'] = 'attachment; filename="channels.m3u"'
response["Content-Disposition"] = 'attachment; filename="channels.m3u"'
return response
def generate_dummy_epg(channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4):
def generate_dummy_epg(
channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4
):
"""
Generate dummy EPG programs for channels without EPG data.
Creates program blocks for a specified number of days.
@ -89,33 +108,33 @@ def generate_dummy_epg(channel_id, channel_name, xml_lines=None, num_days=1, pro
(0, 4): [
f"Late Night with {channel_name} - Where insomniacs unite!",
f"The 'Why Am I Still Awake?' Show on {channel_name}",
f"Counting Sheep - A {channel_name} production for the sleepless"
f"Counting Sheep - A {channel_name} production for the sleepless",
],
(4, 8): [
f"Dawn Patrol - Rise and shine with {channel_name}!",
f"Early Bird Special - Coffee not included",
f"Morning Zombies - Before coffee viewing on {channel_name}"
f"Morning Zombies - Before coffee viewing on {channel_name}",
],
(8, 12): [
f"Mid-Morning Meetings - Pretend you're paying attention while watching {channel_name}",
f"The 'I Should Be Working' Hour on {channel_name}",
f"Productivity Killer - {channel_name}'s daytime programming"
f"Productivity Killer - {channel_name}'s daytime programming",
],
(12, 16): [
f"Lunchtime Laziness with {channel_name}",
f"The Afternoon Slump - Brought to you by {channel_name}",
f"Post-Lunch Food Coma Theater on {channel_name}"
f"Post-Lunch Food Coma Theater on {channel_name}",
],
(16, 20): [
f"Rush Hour - {channel_name}'s alternative to traffic",
f"The 'What's For Dinner?' Debate on {channel_name}",
f"Evening Escapism - {channel_name}'s remedy for reality"
f"Evening Escapism - {channel_name}'s remedy for reality",
],
(20, 24): [
f"Prime Time Placeholder - {channel_name}'s finest not-programming",
f"The 'Netflix Was Too Complicated' Show on {channel_name}",
f"Family Argument Avoider - Courtesy of {channel_name}"
]
f"Family Argument Avoider - Courtesy of {channel_name}",
],
}
# Create programs for each day
@ -148,14 +167,17 @@ def generate_dummy_epg(channel_id, channel_name, xml_lines=None, num_days=1, pro
stop_str = end_time.strftime("%Y%m%d%H%M%S %z")
# Create program entry with escaped channel name
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">')
xml_lines.append(f' <title>{html.escape(channel_name)}</title>')
xml_lines.append(f' <desc>{html.escape(description)}</desc>')
xml_lines.append(f' </programme>')
xml_lines.append(
f' <programme start="{start_str}" stop="{stop_str}" channel="{channel_id}">'
)
xml_lines.append(f" <title>{html.escape(channel_name)}</title>")
xml_lines.append(f" <desc>{html.escape(description)}</desc>")
xml_lines.append(f" </programme>")
return xml_lines
def generate_epg(request, profile_name=None):
def generate_epg(request, user):
"""
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
Since the EPG data is stored independently of Channels, we group programmes
@ -164,16 +186,23 @@ def generate_epg(request, profile_name=None):
"""
xml_lines = []
xml_lines.append('<?xml version="1.0" encoding="UTF-8"?>')
xml_lines.append('<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">')
xml_lines.append(
'<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">'
)
if profile_name is not None:
channel_profile = ChannelProfile.objects.get(name=profile_name)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True
)
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
channels = Channel.objects.all()
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
# Retrieve all active channels
for channel in channels:
@ -188,14 +217,18 @@ def generate_epg(request, profile_name=None):
display_name = channel.epg_data.name if channel.epg_data else channel.name
xml_lines.append(f' <channel id="{formatted_channel_number}">')
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
xml_lines.append(
f" <display-name>{html.escape(display_name)}</display-name>"
)
# Add channel logo if available
if channel.logo:
logo_url = request.build_absolute_uri(reverse('api:channels:logo-cache', args=[channel.logo.id]))
logo_url = request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[channel.logo.id])
)
xml_lines.append(f' <icon src="{html.escape(logo_url)}" />')
xml_lines.append(' </channel>')
xml_lines.append(" </channel>")
for channel in channels:
# Use the same formatting for channel ID in program entries
@ -218,98 +251,313 @@ def generate_epg(request, profile_name=None):
display_name,
xml_lines,
num_days=num_days,
program_length_hours=program_length_hours
program_length_hours=program_length_hours,
)
else:
programs = channel.epg_data.programs.all()
for prog in programs:
start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z")
stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z")
xml_lines.append(f' <programme start="{start_str}" stop="{stop_str}" channel="{formatted_channel_number}">')
xml_lines.append(f' <title>{html.escape(prog.title)}</title>')
xml_lines.append(
f' <programme start="{start_str}" stop="{stop_str}" channel="{formatted_channel_number}">'
)
xml_lines.append(f" <title>{html.escape(prog.title)}</title>")
# Add subtitle if available
if prog.sub_title:
xml_lines.append(f' <sub-title>{html.escape(prog.sub_title)}</sub-title>')
xml_lines.append(
f" <sub-title>{html.escape(prog.sub_title)}</sub-title>"
)
# Add description if available
if prog.description:
xml_lines.append(f' <desc>{html.escape(prog.description)}</desc>')
xml_lines.append(
f" <desc>{html.escape(prog.description)}</desc>"
)
# Process custom properties if available
if prog.custom_properties:
try:
import json
custom_data = json.loads(prog.custom_properties)
# Add categories if available
if 'categories' in custom_data and custom_data['categories']:
for category in custom_data['categories']:
xml_lines.append(f' <category>{html.escape(category)}</category>')
if "categories" in custom_data and custom_data["categories"]:
for category in custom_data["categories"]:
xml_lines.append(
f" <category>{html.escape(category)}</category>"
)
# Handle episode numbering - multiple formats supported
# Standard episode number if available
if 'episode' in custom_data:
xml_lines.append(f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>')
if "episode" in custom_data:
xml_lines.append(
f' <episode-num system="onscreen">E{custom_data["episode"]}</episode-num>'
)
# Handle onscreen episode format (like S06E128)
if 'onscreen_episode' in custom_data:
xml_lines.append(f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>')
if "onscreen_episode" in custom_data:
xml_lines.append(
f' <episode-num system="onscreen">{html.escape(custom_data["onscreen_episode"])}</episode-num>'
)
# Add season and episode numbers in xmltv_ns format if available
if 'season' in custom_data and 'episode' in custom_data:
season = int(custom_data['season']) - 1 if str(custom_data['season']).isdigit() else 0
episode = int(custom_data['episode']) - 1 if str(custom_data['episode']).isdigit() else 0
xml_lines.append(f' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>')
if "season" in custom_data and "episode" in custom_data:
season = (
int(custom_data["season"]) - 1
if str(custom_data["season"]).isdigit()
else 0
)
episode = (
int(custom_data["episode"]) - 1
if str(custom_data["episode"]).isdigit()
else 0
)
xml_lines.append(
f' <episode-num system="xmltv_ns">{season}.{episode}.</episode-num>'
)
# Add rating if available
if 'rating' in custom_data:
rating_system = custom_data.get('rating_system', 'TV Parental Guidelines')
xml_lines.append(f' <rating system="{html.escape(rating_system)}">')
xml_lines.append(f' <value>{html.escape(custom_data["rating"])}</value>')
xml_lines.append(f' </rating>')
if "rating" in custom_data:
rating_system = custom_data.get(
"rating_system", "TV Parental Guidelines"
)
xml_lines.append(
f' <rating system="{html.escape(rating_system)}">'
)
xml_lines.append(
f' <value>{html.escape(custom_data["rating"])}</value>'
)
xml_lines.append(f" </rating>")
# Add actors/directors/writers if available
if 'credits' in custom_data:
xml_lines.append(f' <credits>')
for role, people in custom_data['credits'].items():
if "credits" in custom_data:
xml_lines.append(f" <credits>")
for role, people in custom_data["credits"].items():
if isinstance(people, list):
for person in people:
xml_lines.append(f' <{role}>{html.escape(person)}</{role}>')
xml_lines.append(
f" <{role}>{html.escape(person)}</{role}>"
)
else:
xml_lines.append(f' <{role}>{html.escape(people)}</{role}>')
xml_lines.append(f' </credits>')
xml_lines.append(
f" <{role}>{html.escape(people)}</{role}>"
)
xml_lines.append(f" </credits>")
# Add program date/year if available
if 'year' in custom_data:
xml_lines.append(f' <date>{html.escape(custom_data["year"])}</date>')
if "year" in custom_data:
xml_lines.append(
f' <date>{html.escape(custom_data["year"])}</date>'
)
# Add country if available
if 'country' in custom_data:
xml_lines.append(f' <country>{html.escape(custom_data["country"])}</country>')
if "country" in custom_data:
xml_lines.append(
f' <country>{html.escape(custom_data["country"])}</country>'
)
# Add icon if available
if 'icon' in custom_data:
xml_lines.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
if "icon" in custom_data:
xml_lines.append(
f' <icon src="{html.escape(custom_data["icon"])}" />'
)
# Add special flags as proper tags
if custom_data.get('previously_shown', False):
xml_lines.append(f' <previously-shown />')
if custom_data.get("previously_shown", False):
xml_lines.append(f" <previously-shown />")
if custom_data.get('premiere', False):
xml_lines.append(f' <premiere />')
if custom_data.get("premiere", False):
xml_lines.append(f" <premiere />")
if custom_data.get('new', False):
xml_lines.append(f' <new />')
if custom_data.get("new", False):
xml_lines.append(f" <new />")
except Exception as e:
xml_lines.append(f' <!-- Error parsing custom properties: {html.escape(str(e))} -->')
xml_lines.append(
f" <!-- Error parsing custom properties: {html.escape(str(e))} -->"
)
xml_lines.append(' </programme>')
xml_lines.append(" </programme>")
xml_lines.append('</tv>')
xml_lines.append("</tv>")
xml_content = "\n".join(xml_lines)
response = HttpResponse(xml_content, content_type="application/xml")
response['Content-Disposition'] = 'attachment; filename="epg.xml"'
response["Content-Disposition"] = 'attachment; filename="epg.xml"'
return response
def xc_player_api(request):
action = request.GET.get("action")
username = request.GET.get("username")
password = request.GET.get("password")
if not username or not password:
raise Http404()
user = authenticate(
username=request.GET.get("username"), password=request.GET.get("password")
)
if user is None:
raise Http404()
raw_host = request.get_host()
if ":" in raw_host:
hostname, port = raw_host.split(":", 1)
else:
hostname = raw_host
port = "443" if request.is_secure() else "80"
if not action:
return JsonResponse(
{
"user_info": {
"username": username,
"password": password,
"message": "",
"auth": 1,
"status": "Active",
"exp_date": "1715062090",
"max_connections": "99",
"allowed_output_formats": [
"ts",
],
},
"server_info": {
"url": hostname,
"server_protocol": request.scheme,
"port": port,
"timezone": get_localzone().key,
"timestamp_now": int(time.time()),
"time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"process": True,
},
}
)
if action == "get_live_categories":
return xc_get_live_categories(user)
if action == "get_live_streams":
return xc_get_live_streams(request, user, request.GET.get("category_id"))
def xc_get(request):
action = request.GET.get("action")
username = request.GET.get("username")
password = request.GET.get("password")
if not username or not password:
raise Http404()
user = authenticate(
username=request.GET.get("username"), password=request.GET.get("password")
)
if user is None:
raise Http404()
if not action:
return generate_m3u(request, user)
def xc_xmltv(request):
username = request.GET.get("username")
password = request.GET.get("password")
if not username or not password:
raise Http404()
user = authenticate(
username=request.GET.get("username"), password=request.GET.get("password")
)
if user is None:
raise Http404()
return generate_epg(request, user)
def xc_get_live_categories(user):
response = []
if user.user_level == 0:
# Only get data from active profile
channel_profiles = user.channel_profiles.all()
print(channel_profiles)
channel_groups = ChannelGroup.objects.filter(
channels__channelprofilemembership__channel_profile__in=channel_profiles,
channels__channelprofilemembership__enabled=True,
channels__user_level=0,
).distinct()
else:
channel_groups = ChannelGroup.objects.filter(
channels__isnull=False, channels__user_level__lte=user.user_level
).distinct()
for group in channel_groups:
response.append(
{
"category_id": group.id,
"category_name": group.name,
"parent_id": 0,
}
)
return JsonResponse(response, safe=False)
def xc_get_live_streams(request, user, category_id=None):
streams = []
if user.user_level == 0:
# Only get data from active profile
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
if category_id is not None:
filters["channel_group__id"] = category_id
channels = Channel.objects.filter(**filters)
else:
if not category_id:
channels = Channel.objects.filter(user_level__lte=user.user_level)
else:
channels = Channel.objects.filter(
channel_group__id=category_id, user_level__lte=user.user_level
)
for channel in channels:
streams.append(
{
"num": channel.channel_number,
"name": channel.name,
"stream_type": "live",
"stream_id": channel.id,
"stream_icon": (
None
if not channel.logo
else request.build_absolute_uri(
reverse("api:channels:logo-cache", args=[channel.logo.id])
)
),
"epg_channel_id": channel.epg_data.tvg_id if channel.epg_data else "",
"added": int(time.time()), # @TODO: make this the actual created date
"is_adult": 0,
"category_id": channel.channel_group.id,
"category_ids": [channel.channel_group.id],
"custom_sid": None,
"tv_archive": 0,
"direct_source": "",
"tv_archive_duration": 0,
}
)
return JsonResponse(streams, safe=False)

View file

@ -17,7 +17,6 @@ logger = get_logger()
def get_stream_object(id: str):
try:
uuid_obj = UUID(id, version=4)
logger.info(f"Fetching channel ID {id}")
return get_object_or_404(Channel, uuid=id)
except:

View file

@ -6,6 +6,7 @@ import re
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import get_object_or_404
from django.contrib.auth import authenticate
from apps.proxy.config import TSConfig as Config
from .server import ProxyServer
from .channel_status import ChannelStatus
@ -17,11 +18,22 @@ from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.accounts.permissions import (
IsAdmin,
permission_classes_by_method,
permission_classes_by_action,
)
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
from .url_utils import generate_stream_url, transform_url, get_stream_info_for_switch, get_stream_object, get_alternate_streams
from .url_utils import (
generate_stream_url,
transform_url,
get_stream_info_for_switch,
get_stream_object,
get_alternate_streams,
)
from .utils import get_logger
from uuid import UUID
import gevent
@ -29,7 +41,7 @@ import gevent
logger = get_logger()
@api_view(['GET'])
@api_view(["GET"])
def stream_ts(request, channel_id):
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
channel = get_stream_object(channel_id)
@ -44,10 +56,12 @@ def stream_ts(request, channel_id):
logger.info(f"[{client_id}] Requested stream for channel {channel_id}")
# Extract client user agent early
for header in ['HTTP_USER_AGENT', 'User-Agent', 'user-agent']:
if (header in request.META):
for header in ["HTTP_USER_AGENT", "User-Agent", "user-agent"]:
if header in request.META:
client_user_agent = request.META[header]
logger.debug(f"[{client_id}] Client connected with user agent: {client_user_agent}")
logger.debug(
f"[{client_id}] Client connected with user agent: {client_user_agent}"
)
break
# Check if we need to reinitialize the channel
@ -59,29 +73,40 @@ def stream_ts(request, channel_id):
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
metadata = proxy_server.redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE.encode('utf-8')
state_field = ChannelMetadataField.STATE.encode("utf-8")
if state_field in metadata:
channel_state = metadata[state_field].decode('utf-8')
channel_state = metadata[state_field].decode("utf-8")
# Only skip initialization if channel is in a healthy state
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS]
valid_states = [
ChannelState.ACTIVE,
ChannelState.WAITING_FOR_CLIENTS,
]
if channel_state in valid_states:
# Verify the owner is still active
owner_field = ChannelMetadataField.OWNER.encode('utf-8')
owner_field = ChannelMetadataField.OWNER.encode("utf-8")
if owner_field in metadata:
owner = metadata[owner_field].decode('utf-8')
owner = metadata[owner_field].decode("utf-8")
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is active and channel is in good state
needs_initialization = False
logger.info(f"[{client_id}] Channel {channel_id} in state {channel_state} with active owner {owner}")
logger.info(
f"[{client_id}] Channel {channel_id} in state {channel_state} with active owner {owner}"
)
# Start initialization if needed
channel_initializing = False
if needs_initialization or not proxy_server.check_if_channel_exists(channel_id):
# Force cleanup of any previous instance
if channel_state in [ChannelState.ERROR, ChannelState.STOPPING, ChannelState.STOPPED]:
logger.warning(f"[{client_id}] Channel {channel_id} in state {channel_state}, forcing cleanup")
if channel_state in [
ChannelState.ERROR,
ChannelState.STOPPING,
ChannelState.STOPPED,
]:
logger.warning(
f"[{client_id}] Channel {channel_id} in state {channel_state}, forcing cleanup"
)
proxy_server.stop_channel(channel_id)
# Initialize the channel (but don't wait for completion)
@ -100,67 +125,90 @@ def stream_ts(request, channel_id):
# Try to get a stream with configured retries
for attempt in range(max_retries):
stream_url, stream_user_agent, transcode, profile_value = generate_stream_url(channel_id)
stream_url, stream_user_agent, transcode, profile_value = (
generate_stream_url(channel_id)
)
if stream_url is not None:
logger.info(f"[{client_id}] Successfully obtained stream for channel {channel_id}")
logger.info(
f"[{client_id}] Successfully obtained stream for channel {channel_id}"
)
break
# If we failed because there are no streams assigned, don't retry
_, _, error_reason = channel.get_stream()
if error_reason and 'maximum connection limits' not in error_reason:
logger.warning(f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}")
if error_reason and "maximum connection limits" not in error_reason:
logger.warning(
f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}"
)
break
# Don't exceed the overall connection timeout
if time.time() - wait_start_time > retry_timeout:
logger.warning(f"[{client_id}] Connection wait timeout exceeded ({retry_timeout}s)")
logger.warning(
f"[{client_id}] Connection wait timeout exceeded ({retry_timeout}s)"
)
break
# Wait before retrying (using exponential backoff with a cap)
wait_time = min(0.5 * (2 ** attempt), 2.0) # Caps at 2 seconds
logger.info(f"[{client_id}] Waiting {wait_time:.1f}s for a connection to become available (attempt {attempt+1}/{max_retries})")
gevent.sleep(wait_time) # FIXED: Using gevent.sleep instead of time.sleep
wait_time = min(0.5 * (2**attempt), 2.0) # Caps at 2 seconds
logger.info(
f"[{client_id}] Waiting {wait_time:.1f}s for a connection to become available (attempt {attempt+1}/{max_retries})"
)
gevent.sleep(
wait_time
) # FIXED: Using gevent.sleep instead of time.sleep
if stream_url is None:
# Make sure to release any stream locks that might have been acquired
if hasattr(channel, 'streams') and channel.streams.exists():
if hasattr(channel, "streams") and channel.streams.exists():
for stream in channel.streams.all():
try:
stream.release_stream()
logger.info(f"[{client_id}] Released stream {stream.id} for channel {channel_id}")
logger.info(
f"[{client_id}] Released stream {stream.id} for channel {channel_id}"
)
except Exception as e:
logger.error(f"[{client_id}] Error releasing stream: {e}")
# Get the specific error message if available
wait_duration = f"{int(time.time() - wait_start_time)}s"
error_msg = error_reason if error_reason else 'No available streams for this channel'
return JsonResponse({
'error': error_msg,
'waited': wait_duration
}, status=503) # 503 Service Unavailable is appropriate here
error_msg = (
error_reason
if error_reason
else "No available streams for this channel"
)
return JsonResponse(
{"error": error_msg, "waited": wait_duration}, status=503
) # 503 Service Unavailable is appropriate here
# Get the stream ID from the channel
stream_id, m3u_profile_id, _ = channel.get_stream()
logger.info(f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}")
logger.info(
f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}"
)
# Generate transcode command if needed
stream_profile = channel.get_stream_profile()
if stream_profile.is_redirect():
# Validate the stream URL before redirecting
from .url_utils import validate_stream_url, get_alternate_streams, get_stream_info_for_switch
from .url_utils import (
validate_stream_url,
get_alternate_streams,
get_stream_info_for_switch,
)
# Try initial URL
logger.info(f"[{client_id}] Validating redirect URL: {stream_url}")
is_valid, final_url, status_code, message = validate_stream_url(
stream_url,
user_agent=stream_user_agent,
timeout=(5, 5)
stream_url, user_agent=stream_user_agent, timeout=(5, 5)
)
# If first URL doesn't validate, try alternates
if not is_valid:
logger.warning(f"[{client_id}] Primary stream URL failed validation: {message}")
logger.warning(
f"[{client_id}] Primary stream URL failed validation: {message}"
)
# Track tried streams to avoid loops
tried_streams = {stream_id}
@ -170,49 +218,71 @@ def stream_ts(request, channel_id):
# Try each alternate until one works
for alt in alternates:
if alt['stream_id'] in tried_streams:
if alt["stream_id"] in tried_streams:
continue
tried_streams.add(alt['stream_id'])
tried_streams.add(alt["stream_id"])
# Get stream info
alt_info = get_stream_info_for_switch(channel_id, alt['stream_id'])
if 'error' in alt_info:
logger.warning(f"[{client_id}] Error getting alternate stream info: {alt_info['error']}")
alt_info = get_stream_info_for_switch(
channel_id, alt["stream_id"]
)
if "error" in alt_info:
logger.warning(
f"[{client_id}] Error getting alternate stream info: {alt_info['error']}"
)
continue
# Validate the alternate URL
logger.info(f"[{client_id}] Trying alternate stream #{alt['stream_id']}: {alt_info['url']}")
logger.info(
f"[{client_id}] Trying alternate stream #{alt['stream_id']}: {alt_info['url']}"
)
is_valid, final_url, status_code, message = validate_stream_url(
alt_info['url'],
user_agent=alt_info['user_agent'],
timeout=(5, 5)
alt_info["url"],
user_agent=alt_info["user_agent"],
timeout=(5, 5),
)
if is_valid:
logger.info(f"[{client_id}] Alternate stream #{alt['stream_id']} validated successfully")
logger.info(
f"[{client_id}] Alternate stream #{alt['stream_id']} validated successfully"
)
break
else:
logger.warning(f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}")
logger.warning(
f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}"
)
# Release stream lock before redirecting
channel.release_stream()
# Final decision based on validation results
if is_valid:
logger.info(f"[{client_id}] Redirecting to validated URL: {final_url} ({message})")
logger.info(
f"[{client_id}] Redirecting to validated URL: {final_url} ({message})"
)
return HttpResponseRedirect(final_url)
else:
logger.error(f"[{client_id}] All available redirect URLs failed validation")
return JsonResponse({
'error': 'All available streams failed validation'
}, status=502) # 502 Bad Gateway
logger.error(
f"[{client_id}] All available redirect URLs failed validation"
)
return JsonResponse(
{"error": "All available streams failed validation"}, status=502
) # 502 Bad Gateway
# Initialize channel with the stream's user agent (not the client's)
success = ChannelService.initialize_channel(
channel_id, stream_url, stream_user_agent, transcode, profile_value, stream_id, m3u_profile_id
channel_id,
stream_url,
stream_user_agent,
transcode,
profile_value,
stream_id,
m3u_profile_id,
)
if not success:
return JsonResponse({'error': 'Failed to initialize channel'}, status=500)
return JsonResponse(
{"error": "Failed to initialize channel"}, status=500
)
# If we're the owner, wait for connection to establish
if proxy_server.am_i_owner(channel_id):
@ -223,7 +293,9 @@ def stream_ts(request, channel_id):
while not manager.connected:
if time.time() - wait_start > timeout:
proxy_server.stop_channel(channel_id)
return JsonResponse({'error': 'Connection timeout'}, status=504)
return JsonResponse(
{"error": "Connection timeout"}, status=504
)
# Check if this manager should keep retrying or stop
if not manager.should_retry():
@ -233,41 +305,68 @@ def stream_ts(request, channel_id):
if proxy_server.redis_client:
try:
state_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STATE)
state_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
if state_bytes:
current_state = state_bytes.decode('utf-8')
logger.debug(f"[{client_id}] Current state of channel {channel_id}: {current_state}")
current_state = state_bytes.decode("utf-8")
logger.debug(
f"[{client_id}] Current state of channel {channel_id}: {current_state}"
)
except Exception as e:
logger.warning(f"[{client_id}] Error getting channel state: {e}")
logger.warning(
f"[{client_id}] Error getting channel state: {e}"
)
# Allow normal transitional states to continue
if current_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING]:
logger.info(f"[{client_id}] Channel {channel_id} is in {current_state} state, continuing to wait")
if current_state in [
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
]:
logger.info(
f"[{client_id}] Channel {channel_id} is in {current_state} state, continuing to wait"
)
# Reset wait timer to allow the transition to complete
wait_start = time.time()
continue
# Check if we're switching URLs
if hasattr(manager, 'url_switching') and manager.url_switching:
logger.info(f"[{client_id}] Stream manager is currently switching URLs for channel {channel_id}")
if (
hasattr(manager, "url_switching")
and manager.url_switching
):
logger.info(
f"[{client_id}] Stream manager is currently switching URLs for channel {channel_id}"
)
# Reset wait timer to give the switch a chance
wait_start = time.time()
continue
# If we reach here, we've exhausted retries and the channel isn't in a valid transitional state
logger.warning(f"[{client_id}] Channel {channel_id} failed to connect and is not in transitional state")
logger.warning(
f"[{client_id}] Channel {channel_id} failed to connect and is not in transitional state"
)
proxy_server.stop_channel(channel_id)
return JsonResponse({'error': 'Failed to connect'}, status=502)
return JsonResponse(
{"error": "Failed to connect"}, status=502
)
gevent.sleep(0.1) # FIXED: Using gevent.sleep instead of time.sleep
gevent.sleep(
0.1
) # FIXED: Using gevent.sleep instead of time.sleep
logger.info(f"[{client_id}] Successfully initialized channel {channel_id}")
channel_initializing = True
# Register client - can do this regardless of initialization state
# Create local resources if needed
if channel_id not in proxy_server.stream_buffers or channel_id not in proxy_server.client_managers:
logger.debug(f"[{client_id}] Channel {channel_id} exists in Redis but not initialized in this worker - initializing now")
if (
channel_id not in proxy_server.stream_buffers
or channel_id not in proxy_server.client_managers
):
logger.debug(
f"[{client_id}] Channel {channel_id} exists in Redis but not initialized in this worker - initializing now"
)
# Get URL from Redis metadata
url = None
@ -275,32 +374,54 @@ def stream_ts(request, channel_id):
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
url_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.URL)
ua_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.USER_AGENT)
profile_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_PROFILE)
url_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.URL
)
ua_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.USER_AGENT
)
profile_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_PROFILE
)
if url_bytes:
url = url_bytes.decode('utf-8')
url = url_bytes.decode("utf-8")
if ua_bytes:
stream_user_agent = ua_bytes.decode('utf-8')
stream_user_agent = ua_bytes.decode("utf-8")
# Extract transcode setting from Redis
if profile_bytes:
profile_str = profile_bytes.decode('utf-8')
use_transcode = (profile_str == PROXY_PROFILE_NAME or profile_str == 'None')
logger.debug(f"Using profile '{profile_str}' for channel {channel_id}, transcode={use_transcode}")
profile_str = profile_bytes.decode("utf-8")
use_transcode = (
profile_str == PROXY_PROFILE_NAME or profile_str == "None"
)
logger.debug(
f"Using profile '{profile_str}' for channel {channel_id}, transcode={use_transcode}"
)
else:
# Default settings when profile not found in Redis
profile_str = 'None' # Default profile name
use_transcode = False # Default to direct streaming without transcoding
logger.debug(f"No profile found in Redis for channel {channel_id}, defaulting to transcode={use_transcode}")
profile_str = "None" # Default profile name
use_transcode = (
False # Default to direct streaming without transcoding
)
logger.debug(
f"No profile found in Redis for channel {channel_id}, defaulting to transcode={use_transcode}"
)
# Use client_user_agent as fallback if stream_user_agent is None
success = proxy_server.initialize_channel(url, channel_id, stream_user_agent or client_user_agent, use_transcode)
success = proxy_server.initialize_channel(
url, channel_id, stream_user_agent or client_user_agent, use_transcode
)
if not success:
logger.error(f"[{client_id}] Failed to initialize channel {channel_id} locally")
return JsonResponse({'error': 'Failed to initialize channel locally'}, status=500)
logger.error(
f"[{client_id}] Failed to initialize channel {channel_id} locally"
)
return JsonResponse(
{"error": "Failed to initialize channel locally"}, status=500
)
logger.info(f"[{client_id}] Successfully initialized channel {channel_id} locally")
logger.info(
f"[{client_id}] Successfully initialized channel {channel_id} locally"
)
# Register client
buffer = proxy_server.stream_buffers[channel_id]
@ -315,53 +436,72 @@ def stream_ts(request, channel_id):
# Return the StreamingHttpResponse from the main function
response = StreamingHttpResponse(
streaming_content=generate(),
content_type='video/mp2t'
streaming_content=generate(), content_type="video/mp2t"
)
response['Cache-Control'] = 'no-cache'
response["Cache-Control"] = "no-cache"
return response
except Exception as e:
logger.error(f"Error in stream_ts: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)
@api_view(["GET"])
def stream_xc(request, username, password, channel_id):
user = authenticate(username=username, password=password)
if user is None:
return Response({"error": "Invalid credentials"}, status=401)
channel = get_object_or_404(Channel, id=channel_id)
print(channel.uuid)
return stream_ts(request._request, channel.uuid)
@csrf_exempt
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@api_view(["POST"])
@permission_classes([IsAdmin])
def change_stream(request, channel_id):
"""Change stream URL for existing channel with enhanced diagnostics"""
proxy_server = ProxyServer.get_instance()
try:
data = json.loads(request.body)
new_url = data.get('url')
user_agent = data.get('user_agent')
stream_id = data.get('stream_id')
new_url = data.get("url")
user_agent = data.get("user_agent")
stream_id = data.get("stream_id")
# If stream_id is provided, get the URL and user_agent from it
if stream_id:
logger.info(f"Stream ID {stream_id} provided, looking up stream info for channel {channel_id}")
logger.info(
f"Stream ID {stream_id} provided, looking up stream info for channel {channel_id}"
)
stream_info = get_stream_info_for_switch(channel_id, stream_id)
if 'error' in stream_info:
return JsonResponse({
'error': stream_info['error'],
'stream_id': stream_id
}, status=404)
if "error" in stream_info:
return JsonResponse(
{"error": stream_info["error"], "stream_id": stream_id}, status=404
)
# Use the info from the stream
new_url = stream_info['url']
user_agent = stream_info['user_agent']
m3u_profile_id = stream_info.get('m3u_profile_id')
new_url = stream_info["url"]
user_agent = stream_info["user_agent"]
m3u_profile_id = stream_info.get("m3u_profile_id")
# Stream ID will be passed to change_stream_url later
elif not new_url:
return JsonResponse({'error': 'Either url or stream_id must be provided'}, status=400)
return JsonResponse(
{"error": "Either url or stream_id must be provided"}, status=400
)
logger.info(f"Attempting to change stream for channel {channel_id} to {new_url}")
logger.info(
f"Attempting to change stream for channel {channel_id} to {new_url}"
)
# Use the service layer instead of direct implementation
# Pass stream_id to ensure proper connection tracking
result = ChannelService.change_stream_url(channel_id, new_url, user_agent, stream_id, m3u_profile_id)
result = ChannelService.change_stream_url(
channel_id, new_url, user_agent, stream_id, m3u_profile_id
)
# Get the stream manager before updating URL
stream_manager = proxy_server.stream_managers.get(channel_id)
@ -370,37 +510,43 @@ def change_stream(request, channel_id):
if stream_manager:
# Reset tried streams when manually switching URL via API
stream_manager.tried_stream_ids = set()
logger.debug(f"Reset tried stream IDs for channel {channel_id} during manual stream change")
logger.debug(
f"Reset tried stream IDs for channel {channel_id} during manual stream change"
)
if result.get('status') == 'error':
return JsonResponse({
'error': result.get('message', 'Unknown error'),
'diagnostics': result.get('diagnostics', {})
}, status=404)
if result.get("status") == "error":
return JsonResponse(
{
"error": result.get("message", "Unknown error"),
"diagnostics": result.get("diagnostics", {}),
},
status=404,
)
# Format response based on whether it was a direct update or event-based
response_data = {
'message': 'Stream changed successfully',
'channel': channel_id,
'url': new_url,
'owner': result.get('direct_update', False),
'worker_id': proxy_server.worker_id
"message": "Stream changed successfully",
"channel": channel_id,
"url": new_url,
"owner": result.get("direct_update", False),
"worker_id": proxy_server.worker_id,
}
# Include stream_id in response if it was used
if stream_id:
response_data['stream_id'] = stream_id
response_data["stream_id"] = stream_id
return JsonResponse(response_data)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
return JsonResponse({"error": "Invalid JSON"}, status=400)
except Exception as e:
logger.error(f"Failed to change stream: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@api_view(["GET"])
@permission_classes([IsAdmin])
def channel_status(request, channel_id=None):
"""
Returns status information about channels with detail level based on request:
@ -412,7 +558,7 @@ def channel_status(request, channel_id=None):
try:
# Check if Redis is available
if not proxy_server.redis_client:
return JsonResponse({'error': 'Redis connection not available'}, status=500)
return JsonResponse({"error": "Redis connection not available"}, status=500)
# Handle single channel or all channels
if channel_id:
@ -421,7 +567,9 @@ def channel_status(request, channel_id=None):
if channel_info:
return JsonResponse(channel_info)
else:
return JsonResponse({'error': f'Channel {channel_id} not found'}, status=404)
return JsonResponse(
{"error": f"Channel {channel_id} not found"}, status=404
)
else:
# Basic info for all channels
channel_pattern = "ts_proxy:channel:*:metadata"
@ -430,9 +578,13 @@ def channel_status(request, channel_id=None):
# Extract channel IDs from keys
cursor = 0
while True:
cursor, keys = proxy_server.redis_client.scan(cursor, match=channel_pattern)
cursor, keys = proxy_server.redis_client.scan(
cursor, match=channel_pattern
)
for key in keys:
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key.decode('utf-8'))
channel_id_match = re.search(
r"ts_proxy:channel:(.*):metadata", key.decode("utf-8")
)
if channel_id_match:
ch_id = channel_id_match.group(1)
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
@ -442,15 +594,16 @@ def channel_status(request, channel_id=None):
if cursor == 0:
break
return JsonResponse({'channels': all_channels, 'count': len(all_channels)})
return JsonResponse({"channels": all_channels, "count": len(all_channels)})
except Exception as e:
logger.error(f"Error in channel_status: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(['POST', 'DELETE'])
@permission_classes([IsAuthenticated])
@api_view(["POST", "DELETE"])
@permission_classes([IsAdmin])
def stop_channel(request, channel_id):
"""Stop a channel and release all associated resources using PubSub events"""
try:
@ -459,60 +612,70 @@ def stop_channel(request, channel_id):
# Use the service layer instead of direct implementation
result = ChannelService.stop_channel(channel_id)
if result.get('status') == 'error':
return JsonResponse({'error': result.get('message', 'Unknown error')}, status=404)
if result.get("status") == "error":
return JsonResponse(
{"error": result.get("message", "Unknown error")}, status=404
)
return JsonResponse({
'message': 'Channel stop request sent',
'channel_id': channel_id,
'previous_state': result.get('previous_state')
})
return JsonResponse(
{
"message": "Channel stop request sent",
"channel_id": channel_id,
"previous_state": result.get("previous_state"),
}
)
except Exception as e:
logger.error(f"Failed to stop channel: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@api_view(["POST"])
@permission_classes([IsAdmin])
def stop_client(request, channel_id):
"""Stop a specific client connection using existing client management"""
try:
# Parse request body to get client ID
data = json.loads(request.body)
client_id = data.get('client_id')
client_id = data.get("client_id")
if not client_id:
return JsonResponse({'error': 'No client_id provided'}, status=400)
return JsonResponse({"error": "No client_id provided"}, status=400)
# Use the service layer instead of direct implementation
result = ChannelService.stop_client(channel_id, client_id)
if result.get('status') == 'error':
return JsonResponse({'error': result.get('message')}, status=404)
if result.get("status") == "error":
return JsonResponse({"error": result.get("message")}, status=404)
return JsonResponse({
'message': 'Client stop request processed',
'channel_id': channel_id,
'client_id': client_id,
'locally_processed': result.get('locally_processed', False)
})
return JsonResponse(
{
"message": "Client stop request processed",
"channel_id": channel_id,
"client_id": client_id,
"locally_processed": result.get("locally_processed", False),
}
)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
return JsonResponse({"error": "Invalid JSON"}, status=400)
except Exception as e:
logger.error(f"Failed to stop client: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)
@csrf_exempt
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@api_view(["POST"])
@permission_classes([IsAdmin])
def next_stream(request, channel_id):
"""Switch to the next available stream for a channel"""
proxy_server = ProxyServer.get_instance()
try:
logger.info(f"Request to switch to next stream for channel {channel_id} received")
logger.info(
f"Request to switch to next stream for channel {channel_id} received"
)
# Check if the channel exists
channel = get_stream_object(channel_id)
@ -525,29 +688,42 @@ def next_stream(request, channel_id):
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
# Get current stream ID from Redis
stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
stream_id_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
current_stream_id = int(stream_id_bytes.decode('utf-8'))
logger.info(f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}")
current_stream_id = int(stream_id_bytes.decode("utf-8"))
logger.info(
f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}"
)
# Get M3U profile from Redis if available
profile_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.M3U_PROFILE)
profile_id_bytes = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if profile_id_bytes:
profile_id = int(profile_id_bytes.decode('utf-8'))
logger.info(f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}")
profile_id = int(profile_id_bytes.decode("utf-8"))
logger.info(
f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}"
)
if not current_stream_id:
# Channel is not running
return JsonResponse({'error': 'No current stream found for channel'}, status=404)
return JsonResponse(
{"error": "No current stream found for channel"}, status=404
)
# Get all streams for this channel in their defined order
streams = list(channel.streams.all().order_by('channelstream__order'))
streams = list(channel.streams.all().order_by("channelstream__order"))
if len(streams) <= 1:
return JsonResponse({
'error': 'No alternate streams available for this channel',
'current_stream_id': current_stream_id
}, status=404)
return JsonResponse(
{
"error": "No alternate streams available for this channel",
"current_stream_id": current_stream_id,
},
status=404,
)
# Find the current stream's position in the list
current_index = None
@ -557,61 +733,74 @@ def next_stream(request, channel_id):
break
if current_index is None:
logger.warning(f"Current stream ID {current_stream_id} not found in channel's streams list")
logger.warning(
f"Current stream ID {current_stream_id} not found in channel's streams list"
)
# Fall back to the first stream that's not the current one
next_stream = next((s for s in streams if s.id != current_stream_id), None)
if not next_stream:
return JsonResponse({
'error': 'Could not find current stream in channel list',
'current_stream_id': current_stream_id
}, status=404)
return JsonResponse(
{
"error": "Could not find current stream in channel list",
"current_stream_id": current_stream_id,
},
status=404,
)
else:
# Get the next stream in the rotation (with wrap-around)
next_index = (current_index + 1) % len(streams)
next_stream = streams[next_index]
next_stream_id = next_stream.id
logger.info(f"Rotating to next stream ID {next_stream_id} for channel {channel_id}")
logger.info(
f"Rotating to next stream ID {next_stream_id} for channel {channel_id}"
)
# Get full stream info including URL for the next stream
stream_info = get_stream_info_for_switch(channel_id, next_stream_id)
if 'error' in stream_info:
return JsonResponse({
'error': stream_info['error'],
'current_stream_id': current_stream_id,
'next_stream_id': next_stream_id
}, status=404)
if "error" in stream_info:
return JsonResponse(
{
"error": stream_info["error"],
"current_stream_id": current_stream_id,
"next_stream_id": next_stream_id,
},
status=404,
)
# Now use the ChannelService to change the stream URL
result = ChannelService.change_stream_url(
channel_id,
stream_info['url'],
stream_info['user_agent'],
next_stream_id # Pass the stream_id to be stored in Redis
stream_info["url"],
stream_info["user_agent"],
next_stream_id, # Pass the stream_id to be stored in Redis
)
if result.get('status') == 'error':
return JsonResponse({
'error': result.get('message', 'Unknown error'),
'diagnostics': result.get('diagnostics', {}),
'current_stream_id': current_stream_id,
'next_stream_id': next_stream_id
}, status=404)
if result.get("status") == "error":
return JsonResponse(
{
"error": result.get("message", "Unknown error"),
"diagnostics": result.get("diagnostics", {}),
"current_stream_id": current_stream_id,
"next_stream_id": next_stream_id,
},
status=404,
)
# Format success response
response_data = {
'message': 'Stream switched to next available',
'channel': channel_id,
'previous_stream_id': current_stream_id,
'new_stream_id': next_stream_id,
'new_url': stream_info['url'],
'owner': result.get('direct_update', False),
'worker_id': proxy_server.worker_id
"message": "Stream switched to next available",
"channel": channel_id,
"previous_stream_id": current_stream_id,
"new_stream_id": next_stream_id,
"new_url": stream_info["url"],
"owner": result.get("direct_update", False),
"worker_id": proxy_server.worker_id,
}
return JsonResponse(response_data)
except Exception as e:
logger.error(f"Failed to switch to next stream: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
return JsonResponse({"error": str(e)}, status=500)

View file

@ -4,7 +4,11 @@ from rest_framework import viewsets, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from .models import UserAgent, StreamProfile, CoreSettings, STREAM_HASH_KEY
from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer
from .serializers import (
UserAgentSerializer,
StreamProfileSerializer,
CoreSettingsSerializer,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes
from drf_yasg.utils import swagger_auto_schema
@ -13,25 +17,31 @@ import requests
import os
from core.tasks import rehash_streams
class UserAgentViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows user agents to be viewed, created, edited, or deleted.
"""
queryset = UserAgent.objects.all()
serializer_class = UserAgentSerializer
class StreamProfileViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows stream profiles to be viewed, created, edited, or deleted.
"""
queryset = StreamProfile.objects.all()
serializer_class = StreamProfileSerializer
class CoreSettingsViewSet(viewsets.ModelViewSet):
"""
API endpoint for editing core settings.
This is treated as a singleton: only one instance should exist.
"""
queryset = CoreSettings.objects.all()
serializer_class = CoreSettingsSerializer
@ -39,21 +49,20 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
instance = self.get_object()
response = super().update(request, *args, **kwargs)
if instance.key == STREAM_HASH_KEY:
if instance.value != request.data['value']:
rehash_streams.delay(request.data['value'].split(','))
if instance.value != request.data["value"]:
rehash_streams.delay(request.data["value"].split(","))
return response
@swagger_auto_schema(
method='get',
method="get",
operation_description="Endpoint for environment details",
responses={200: "Environment variables"}
responses={200: "Environment variables"},
)
@api_view(['GET'])
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def environment(request):
public_ip = None
local_ip = None
country_code = None
@ -88,25 +97,31 @@ def environment(request):
country_code = None
country_name = None
return Response({
'authenticated': True,
'public_ip': public_ip,
'local_ip': local_ip,
'country_code': country_code,
'country_name': country_name,
'env_mode': "dev" if os.getenv('DISPATCHARR_ENV') == "dev" else "prod",
})
return Response(
{
"authenticated": True,
"public_ip": public_ip,
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"env_mode": "dev" if os.getenv("DISPATCHARR_ENV") == "dev" else "prod",
}
)
@swagger_auto_schema(
method='get',
method="get",
operation_description="Get application version information",
responses={200: "Version information"}
responses={200: "Version information"},
)
@api_view(['GET'])
@api_view(["GET"])
def version(request):
# Import version information
from version import __version__, __timestamp__
return Response({
'version': __version__,
'timestamp': __timestamp__,
})
return Response(
{
"version": __version__,
"timestamp": __timestamp__,
}
)

View file

@ -4,69 +4,67 @@ from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'REPLACE_ME_WITH_A_REAL_SECRET'
SECRET_KEY = "REPLACE_ME_WITH_A_REAL_SECRET"
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
REDIS_DB = os.environ.get("REDIS_DB", "0")
# Set DEBUG to True for development, False for production
if os.environ.get('DISPATCHARR_DEBUG', 'False').lower() == 'true':
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
DEBUG = True
else:
DEBUG = False
ALLOWED_HOSTS = ["*"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS = [
'apps.api',
'apps.accounts',
'apps.channels.apps.ChannelsConfig',
'apps.dashboard',
'apps.epg',
'apps.hdhr',
'apps.m3u',
'apps.output',
'apps.proxy.apps.ProxyConfig',
'apps.proxy.ts_proxy',
'core',
'daphne',
'drf_yasg',
'channels',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'django_filters',
'django_celery_beat',
"apps.api",
"apps.accounts",
"apps.channels.apps.ChannelsConfig",
"apps.dashboard",
"apps.epg",
"apps.hdhr",
"apps.m3u",
"apps.output",
"apps.proxy.apps.ProxyConfig",
"apps.proxy.ts_proxy",
"core",
"daphne",
"drf_yasg",
"channels",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"django_filters",
"django_celery_beat",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
]
ROOT_URLCONF = 'dispatcharr.urls'
ROOT_URLCONF = "dispatcharr.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'frontend/dist'),
BASE_DIR / "templates"
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "frontend/dist"), BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
@ -76,8 +74,8 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = 'dispatcharr.wsgi.application'
ASGI_APPLICATION = 'dispatcharr.asgi.application'
WSGI_APPLICATION = "dispatcharr.wsgi.application"
ASGI_APPLICATION = "dispatcharr.asgi.application"
CHANNEL_LAYERS = {
"default": {
@ -88,76 +86,72 @@ CHANNEL_LAYERS = {
},
}
if os.getenv('DB_ENGINE', None) == 'sqlite':
if os.getenv("DB_ENGINE", None) == "sqlite":
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': '/data/dispatcharr.db',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/data/dispatcharr.db",
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('POSTGRES_DB', 'dispatcharr'),
'USER': os.environ.get('POSTGRES_USER', 'dispatch'),
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'secret'),
'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
'PORT': int(os.environ.get('POSTGRES_PORT', 5432)),
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
}
}
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
]
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
SWAGGER_SETTINGS = {
'SECURITY_DEFINITIONS': {
'Bearer': {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header'
}
}
"SECURITY_DEFINITIONS": {
"Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}
}
}
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static' # Directory where static files will be collected
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "static" # Directory where static files will be collected
# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files.
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'frontend/dist'), # React build static files
os.path.join(BASE_DIR, "frontend/dist"), # React build static files
]
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'accounts.User'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
# Configure Redis key prefix
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
'global_keyprefix': 'celery-tasks:', # Set the Redis key prefix for Celery
"global_keyprefix": "celery-tasks:", # Set the Redis key prefix for Celery
}
# Set TTL (Time-to-Live) for task results (in seconds)
@ -165,47 +159,44 @@ CELERY_RESULT_EXPIRES = 3600 # 1 hour TTL for task results
# Optionally, set visibility timeout for task retries (if using Redis)
CELERY_BROKER_TRANSPORT_OPTIONS = {
'visibility_timeout': 3600, # Time in seconds that a task remains invisible during retries
"visibility_timeout": 3600, # Time in seconds that a task remains invisible during retries
}
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
CELERY_BEAT_SCHEDULE = {
'fetch-channel-statuses': {
'task': 'apps.proxy.tasks.fetch_channel_stats', # Direct task call
'schedule': 2.0, # Every 2 seconds
"fetch-channel-statuses": {
"task": "apps.proxy.tasks.fetch_channel_stats", # Direct task call
"schedule": 2.0, # Every 2 seconds
},
'scan-files': {
'task': 'core.tasks.scan_and_process_files', # Direct task call
'schedule': 20.0, # Every 20 seconds
"scan-files": {
"task": "core.tasks.scan_and_process_files", # Direct task call
"schedule": 20.0, # Every 20 seconds
},
}
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
SERVER_IP = "127.0.0.1"
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = [
'http://*',
'https://*'
]
CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
APPEND_SLASH = True
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False, # Optional: Whether to rotate refresh tokens
'BLACKLIST_AFTER_ROTATION': True, # Optional: Whether to blacklist refresh tokens
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": False, # Optional: Whether to rotate refresh tokens
"BLACKLIST_AFTER_ROTATION": True, # Optional: Whether to blacklist refresh tokens
}
# Redis connection settings
REDIS_URL = 'redis://localhost:6379/0'
REDIS_URL = "redis://localhost:6379/0"
REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds
REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds
REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds
@ -216,45 +207,45 @@ REDIS_RETRY_INTERVAL = 1 # Initial retry interval in seconds
# Proxy Settings
PROXY_SETTINGS = {
'HLS': {
'DEFAULT_URL': '', # Default HLS stream URL if needed
'BUFFER_SIZE': 1000,
'USER_AGENT': 'VLC/3.0.20 LibVLC/3.0.20',
'CHUNK_SIZE': 8192,
'CLIENT_POLL_INTERVAL': 0.1,
'MAX_RETRIES': 3,
'MIN_SEGMENTS': 12,
'MAX_SEGMENTS': 16,
'WINDOW_SIZE': 12,
'INITIAL_SEGMENTS': 3,
"HLS": {
"DEFAULT_URL": "", # Default HLS stream URL if needed
"BUFFER_SIZE": 1000,
"USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20",
"CHUNK_SIZE": 8192,
"CLIENT_POLL_INTERVAL": 0.1,
"MAX_RETRIES": 3,
"MIN_SEGMENTS": 12,
"MAX_SEGMENTS": 16,
"WINDOW_SIZE": 12,
"INITIAL_SEGMENTS": 3,
},
"TS": {
"DEFAULT_URL": "", # Default TS stream URL if needed
"BUFFER_SIZE": 1000,
"RECONNECT_DELAY": 5,
"USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20",
"REDIS_CHUNK_TTL": 60, # How long to keep chunks in Redis (seconds)
},
'TS': {
'DEFAULT_URL': '', # Default TS stream URL if needed
'BUFFER_SIZE': 1000,
'RECONNECT_DELAY': 5,
'USER_AGENT': 'VLC/3.0.20 LibVLC/3.0.20',
'REDIS_CHUNK_TTL': 60, # How long to keep chunks in Redis (seconds)
}
}
# Map log level names to their numeric values
LOG_LEVEL_MAP = {
'TRACE': 5,
'DEBUG': 10,
'INFO': 20,
'WARNING': 30,
'ERROR': 40,
'CRITICAL': 50
"TRACE": 5,
"DEBUG": 10,
"INFO": 20,
"WARNING": 30,
"ERROR": 40,
"CRITICAL": 50,
}
# Get log level from environment variable, default to INFO if not set
# Add debugging output to see exactly what's being detected
env_log_level = os.environ.get('DISPATCHARR_LOG_LEVEL', '')
env_log_level = os.environ.get("DISPATCHARR_LOG_LEVEL", "")
print(f"Environment DISPATCHARR_LOG_LEVEL detected as: '{env_log_level}'")
if not env_log_level:
print("No DISPATCHARR_LOG_LEVEL found in environment, using default INFO")
LOG_LEVEL_NAME = 'INFO'
LOG_LEVEL_NAME = "INFO"
else:
LOG_LEVEL_NAME = env_log_level.upper()
print(f"Setting log level to: {LOG_LEVEL_NAME}")
@ -263,63 +254,63 @@ LOG_LEVEL = LOG_LEVEL_MAP.get(LOG_LEVEL_NAME, 20) # Default to INFO (20) if inv
# Add this to your existing LOGGING configuration or create one if it doesn't exist
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '{asctime} {levelname} {name} {message}',
'style': '{',
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{asctime} {levelname} {name} {message}",
"style": "{",
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
'level': 5, # Always allow TRACE level messages through the handler
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"level": 5, # Always allow TRACE level messages through the handler
},
},
'loggers': {
'core.tasks': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use environment-configured level
'propagate': False, # Don't propagate to root logger to avoid duplicate logs
"loggers": {
"core.tasks": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use environment-configured level
"propagate": False, # Don't propagate to root logger to avoid duplicate logs
},
'apps.proxy': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use environment-configured level
'propagate': False, # Don't propagate to root logger
"apps.proxy": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use environment-configured level
"propagate": False, # Don't propagate to root logger
},
# Add parent logger for all app modules
'apps': {
'handlers': ['console'],
'level': LOG_LEVEL,
'propagate': False,
"apps": {
"handlers": ["console"],
"level": LOG_LEVEL,
"propagate": False,
},
# Celery loggers to capture task execution messages
'celery': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use configured log level for Celery logs
'propagate': False,
"celery": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use configured log level for Celery logs
"propagate": False,
},
'celery.task': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use configured log level for task-specific logs
'propagate': False,
"celery.task": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use configured log level for task-specific logs
"propagate": False,
},
'celery.worker': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use configured log level for worker logs
'propagate': False,
"celery.worker": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use configured log level for worker logs
"propagate": False,
},
'celery.beat': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use configured log level for scheduler logs
'propagate': False,
"celery.beat": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use configured log level for scheduler logs
"propagate": False,
},
# Add any other loggers you need to capture TRACE logs from
},
'root': {
'handlers': ['console'],
'level': LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
"root": {
"handlers": ["console"],
"level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
},
}

View file

@ -7,13 +7,14 @@ from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
# Define schema_view for Swagger
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
default_version="v1",
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="contact@dispatcharr.local"),
@ -25,38 +26,42 @@ schema_view = get_schema_view(
urlpatterns = [
# API Routes
path('api/', include(('apps.api.urls', 'api'), namespace='api')),
path('api', RedirectView.as_view(url='/api/', permanent=True)),
path("api/", include(("apps.api.urls", "api"), namespace="api")),
path("api", RedirectView.as_view(url="/api/", permanent=True)),
# Admin
path('admin', RedirectView.as_view(url='/admin/', permanent=True)),
path('admin/', admin.site.urls),
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
path("admin/", admin.site.urls),
# Outputs
path('output', RedirectView.as_view(url='/output/', permanent=True)),
path('output/', include(('apps.output.urls', 'output'), namespace='output')),
path("output", RedirectView.as_view(url="/output/", permanent=True)),
path("output/", include(("apps.output.urls", "output"), namespace="output")),
# HDHR
path('hdhr', RedirectView.as_view(url='/hdhr/', permanent=True)),
path('hdhr/', include(('apps.hdhr.urls', 'hdhr'), namespace='hdhr')),
path("hdhr", RedirectView.as_view(url="/hdhr/", permanent=True)),
path("hdhr/", include(("apps.hdhr.urls", "hdhr"), namespace="hdhr")),
# Add proxy apps - Move these before the catch-all
path('proxy/', include(('apps.proxy.urls', 'proxy'), namespace='proxy')),
path('proxy', RedirectView.as_view(url='/proxy/', permanent=True)),
path("proxy/", include(("apps.proxy.urls", "proxy"), namespace="proxy")),
path("proxy", RedirectView.as_view(url="/proxy/", permanent=True)),
path(
"<slug:username>/<slug:password>/<int:channel_id>",
stream_xc,
name="xc_stream_endpoint",
),
# xc
re_path("player_api.php", xc_player_api, name="xc_get"),
re_path("get.php", xc_get, name="xc_get"),
re_path("xmltv.php", xc_xmltv, name="xc_xmltv"),
# Swagger UI
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path(
"swagger/",
schema_view.with_ui("swagger", cache_timeout=0),
name="schema-swagger-ui",
),
# ReDoc UI
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
# Optionally, serve the raw Swagger JSON
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
# Catch-all routes should always be last
path('', TemplateView.as_view(template_name='index.html')), # React entry point
path('<path:unused_path>', TemplateView.as_view(template_name='index.html')),
path("", TemplateView.as_view(template_name="index.html")), # React entry point
path("<path:unused_path>", TemplateView.as_view(template_name="index.html")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += websocket_urlpatterns

View file

@ -14,6 +14,7 @@ import Guide from './pages/Guide';
import Stats from './pages/Stats';
import DVR from './pages/DVR';
import Settings from './pages/Settings';
import Users from './pages/Users';
import useAuthStore from './store/auth';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
@ -75,18 +76,17 @@ const App = () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
} else {
await logout();
}
} catch (error) {
console.error("Auth check failed:", error);
console.error('Auth check failed:', error);
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
}, [initializeAuth, initData, logout]);
return (
<MantineProvider
@ -132,6 +132,7 @@ const App = () => {
<Route path="/guide" element={<Guide />} />
<Route path="/dvr" element={<DVR />} />
<Route path="/stats" element={<Stats />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
</>
) : (

View file

@ -9,6 +9,7 @@ import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
import { notifications } from '@mantine/notifications';
import useChannelsTableStore from './store/channelsTable';
import useUsersStore from './store/users';
// If needed, you can set a base host or keep it empty if relative requests
const host = import.meta.env.DEV
@ -1392,4 +1393,59 @@ export default class API {
return null;
}
}
static async me() {
return await request(`${host}/api/accounts/users/me/`);
}
static async getUsers() {
try {
const response = await request(`${host}/api/accounts/users/`);
return response;
} catch (e) {
errorNotification('Failed to fetch users', e);
}
}
static async createUser(body) {
try {
const response = await request(`${host}/api/accounts/users/`, {
method: 'POST',
body,
});
useUsersStore.getState().addUser(response);
return response;
} catch (e) {
errorNotification('Failed to fetch users', e);
}
}
static async updateUser(id, body) {
try {
const response = await request(`${host}/api/accounts/users/${id}/`, {
method: 'PATCH',
body,
});
useUsersStore.getState().updateUser(response);
return response;
} catch (e) {
errorNotification('Failed to fetch users', e);
}
}
static async deleteUser(id) {
try {
await request(`${host}/api/accounts/users/${id}/`, {
method: 'DELETE',
});
useUsersStore.getState().removeUser(id);
} catch (e) {
errorNotification('Failed to delete user', e);
}
}
}

View file

@ -10,6 +10,8 @@ import {
Copy,
ChartLine,
Video,
Ellipsis,
LogOut,
} from 'lucide-react';
import {
Avatar,
@ -21,6 +23,7 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
Menu,
} from '@mantine/core';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
@ -28,6 +31,7 @@ import './sidebar.css';
import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth'; // Add this import
import API from '../api';
import { USER_LEVELS } from '../constants';
const NavLink = ({ item, isActive, collapsed }) => {
return (
@ -63,11 +67,63 @@ const NavLink = ({ item, isActive, collapsed }) => {
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
const channels = useChannelsStore((s) => s.channels);
const environment = useSettingsStore((s) => s.environment);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const publicIPRef = useRef(null);
const [appVersion, setAppVersion] = useState({ version: '', timestamp: null });
const [appVersion, setAppVersion] = useState({
version: '',
timestamp: null,
});
// Navigation Items
const navItems =
authUser && authUser.user_level == USER_LEVELS.ADMIN
? [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{
label: 'M3U & EPG Manager',
icon: <Play size={20} />,
path: '/sources',
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Users',
icon: <LucideSettings size={20} />,
path: '/users',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
]
: [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
// Fetch environment settings including version on component mount
useEffect(() => {
@ -99,24 +155,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
fetchVersion();
}, []);
// Navigation Items
const navItems = [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'M3U & EPG Manager', icon: <Play size={20} />, path: '/sources' },
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
const copyPublicIP = async () => {
try {
@ -135,6 +173,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}
};
const onLogout = () => {
logout();
window.location.reload();
};
return (
<AppShell.Navbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
@ -243,7 +286,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
<Avatar src="https://via.placeholder.com/40" radius="xl" />
{!collapsed && (
{!collapsed && authUser && (
<Group
style={{
flex: 1,
@ -252,11 +295,28 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}}
>
<Text size="sm" color="white">
John Doe
</Text>
<Text size="sm" color="white">
{authUser.username}
</Text>
<Menu>
<Menu.Target>
<ActionIcon variant="transparent" size={18} color="white">
<Ellipsis size="18" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<LogOut size="14" />}>
<UnstyledButton
variant="unstyled"
size="xs"
onClick={onLogout}
>
<Text size="xs">Log Out</Text>
</UnstyledButton>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
)}
</Group>

View file

@ -36,6 +36,7 @@ import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
import { FixedSizeList as List } from 'react-window';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const theme = useMantineTheme();
@ -94,13 +95,17 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: '',
channel_number: '', // Change from 0 to empty string for consistency
channel_group_id: Object.keys(channelGroups).length > 0 ? Object.keys(channelGroups)[0] : '',
channel_number: '', // Change from 0 to empty string for consistency
channel_group_id:
Object.keys(channelGroups).length > 0
? Object.keys(channelGroups)[0]
: '',
stream_profile_id: '0',
tvg_id: '',
tvc_guide_stationid: '',
epg_data_id: '',
logo_id: '',
user_level: '0',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
@ -124,7 +129,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
formattedValues.tvg_id = formattedValues.tvg_id || null;
// Ensure tvc_guide_stationid is properly included (no empty strings)
formattedValues.tvc_guide_stationid = formattedValues.tvc_guide_stationid || null;
formattedValues.tvc_guide_stationid =
formattedValues.tvc_guide_stationid || null;
if (channel) {
// If there's an EPG to set, use our enhanced endpoint
@ -183,7 +189,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
formik.setValues({
name: channel.name || '',
channel_number: channel.channel_number !== null ? channel.channel_number : '',
channel_number:
channel.channel_number !== null ? channel.channel_number : '',
channel_group_id: channel.channel_group_id
? `${channel.channel_group_id}`
: '',
@ -194,6 +201,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
tvc_guide_stationid: channel.tvc_guide_stationid || '',
epg_data_id: channel.epg_data_id ?? '',
logo_id: channel.logo_id ? `${channel.logo_id}` : '',
user_level: `${channel.user_level}`,
});
setChannelStreams(channel.streams || []);
@ -353,7 +361,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
// Preserve all current form values while updating just the channel_group_id
formik.setValues({
...formik.values,
channel_group_id: `${newGroup.id}`
channel_group_id: `${newGroup.id}`,
});
}
};
@ -542,6 +550,23 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
)}
size="xs"
/>
<Select
label="User Level Access"
data={Object.entries(USER_LEVELS).map(([label, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
value={formik.values.user_level}
onChange={(value) => {
formik.setFieldValue('user_level', value);
}}
error={
formik.errors.user_level ? formik.touched.user_level : ''
}
/>
</Stack>
<Divider size="sm" orientation="vertical" />
@ -667,9 +692,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
: ''
}
size="xs"
step={0.1} // Add step prop to allow decimal inputs
precision={1} // Specify decimal precision
removeTrailingZeros // Optional: remove trailing zeros for cleaner display
step={0.1} // Add step prop to allow decimal inputs
precision={1} // Specify decimal precision
removeTrailingZeros // Optional: remove trailing zeros for cleaner display
/>
<TextInput
@ -688,7 +713,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
label="Gracenote StationId"
value={formik.values.tvc_guide_stationid}
onChange={formik.handleChange}
error={formik.errors.tvc_guide_stationid ? formik.touched.tvc_guide_stationid : ''}
error={
formik.errors.tvc_guide_stationid
? formik.touched.tvc_guide_stationid
: ''
}
size="xs"
/>

View file

@ -0,0 +1,831 @@
import React, { useState, useEffect, useRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import useStreamsStore from '../../store/streams';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
import {
Box,
Button,
Modal,
TextInput,
NativeSelect,
Text,
Group,
ActionIcon,
Center,
Grid,
Flex,
Select,
Divider,
Stack,
useMantineTheme,
Popover,
ScrollArea,
Tooltip,
NumberInput,
Image,
UnstyledButton,
} from '@mantine/core';
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
import { FixedSizeList as List } from 'react-window';
const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
const theme = useMantineTheme();
const listRef = useRef(null);
const logoListRef = useRef(null);
const groupListRef = useRef(null);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const logos = useChannelsStore((s) => s.logos);
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
const streams = useStreamsStore((state) => state.streams);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const playlists = usePlaylistsStore((s) => s.playlists);
const epgs = useEPGsStore((s) => s.epgs);
const tvgs = useEPGsStore((s) => s.tvgs);
const tvgsById = useEPGsStore((s) => s.tvgsById);
const [logoPreview, setLogoPreview] = useState(null);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
const [selectedEPG, setSelectedEPG] = useState('');
const [tvgFilter, setTvgFilter] = useState('');
const [logoFilter, setLogoFilter] = useState('');
const [logoOptions, setLogoOptions] = useState([]);
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
const groupOptions = Object.values(channelGroups);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = async (files) => {
if (files.length === 1) {
const retval = await API.uploadLogo(files[0]);
await fetchLogos();
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
} else {
setLogoPreview(null);
}
};
const formik = useFormik({
initialValues: {
name: '',
channel_number: '', // Change from 0 to empty string for consistency
channel_group_id:
Object.keys(channelGroups).length > 0
? Object.keys(channelGroups)[0]
: '',
stream_profile_id: '0',
tvg_id: '',
tvc_guide_stationid: '',
epg_data_id: '',
logo_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting }) => {
let response;
try {
const formattedValues = { ...values };
// Convert empty or "0" stream_profile_id to null for the API
if (
!formattedValues.stream_profile_id ||
formattedValues.stream_profile_id === '0'
) {
formattedValues.stream_profile_id = null;
}
// Ensure tvg_id is properly included (no empty strings)
formattedValues.tvg_id = formattedValues.tvg_id || null;
// Ensure tvc_guide_stationid is properly included (no empty strings)
formattedValues.tvc_guide_stationid =
formattedValues.tvc_guide_stationid || null;
if (channel) {
// If there's an EPG to set, use our enhanced endpoint
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
// Use the special endpoint to set EPG and trigger refresh
const epgResponse = await API.setChannelEPG(
channel.id,
values.epg_data_id
);
// Remove epg_data_id from values since we've handled it separately
const { epg_data_id, ...otherValues } = formattedValues;
// Update other channel fields if needed
if (Object.keys(otherValues).length > 0) {
response = await API.updateChannel({
id: channel.id,
...otherValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} else {
// No EPG change, regular update
response = await API.updateChannel({
id: channel.id,
...formattedValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} else {
// New channel creation - use the standard method
response = await API.addChannel({
...formattedValues,
streams: channelStreams.map((stream) => stream.id),
});
}
} catch (error) {
console.error('Error saving channel:', error);
}
formik.resetForm();
API.requeryChannels();
setSubmitting(false);
setTvgFilter('');
setLogoFilter('');
onClose();
},
});
useEffect(() => {
if (channel) {
if (channel.epg_data_id) {
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
}
formik.setValues({
name: channel.name || '',
channel_number:
channel.channel_number !== null ? channel.channel_number : '',
channel_group_id: channel.channel_group_id
? `${channel.channel_group_id}`
: '',
stream_profile_id: channel.stream_profile_id
? `${channel.stream_profile_id}`
: '0',
tvg_id: channel.tvg_id || '',
tvc_guide_stationid: channel.tvc_guide_stationid || '',
epg_data_id: channel.epg_data_id ?? '',
logo_id: channel.logo_id ? `${channel.logo_id}` : '',
});
setChannelStreams(channel.streams || []);
} else {
formik.resetForm();
setTvgFilter('');
setLogoFilter('');
}
}, [channel, tvgsById, channelGroups]);
useEffect(() => {
setLogoOptions([{ id: '0', name: 'Default' }].concat(Object.values(logos)));
}, [logos]);
const renderLogoOption = ({ option, checked }) => {
return (
<Center style={{ width: '100%' }}>
<img src={logos[option.value].cache_url} width="30" />
</Center>
);
};
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// {
// header: 'M3U',
// accessorKey: 'group_name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// ],
// []
// ),
// enableSorting: false,
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// enableRowOrdering: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// positionActionsColumn: 'last',
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="error" // Red color for delete actions
// onClick={() => removeStream(row.original)}
// >
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// mantineRowDragHandleProps: ({ table }) => ({
// onDragEnd: () => {
// const { draggingRow, hoveredRow } = table.getState();
// if (hoveredRow && draggingRow) {
// channelStreams.splice(
// hoveredRow.index,
// 0,
// channelStreams.splice(draggingRow.index, 1)[0]
// );
// setChannelStreams([...channelStreams]);
// }
// },
// }),
// });
// const availableStreamsTable = useMantineReactTable({
// data: streams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// },
// {
// header: 'M3U',
// accessorFn: (row) =>
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
// },
// ],
// []
// ),
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="success" // Red color for delete actions
// onClick={() => addStream(row.original)}
// >
// <AddIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// positionActionsColumn: 'last',
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// });
// Update the handler for when channel group modal is closed
const handleChannelGroupModalClose = (newGroup) => {
setChannelGroupModalOpen(false);
// If a new group was created and returned, update the form with it
if (newGroup && newGroup.id) {
// Preserve all current form values while updating just the channel_group_id
formik.setValues({
...formik.values,
channel_group_id: `${newGroup.id}`,
});
}
};
if (!isOpen) {
return <></>;
}
const filteredTvgs = tvgs
.filter((tvg) => tvg.epg_source == selectedEPG)
.filter(
(tvg) =>
tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) ||
tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase())
);
const filteredLogos = logoOptions.filter((logo) =>
logo.name.toLowerCase().includes(logoFilter.toLowerCase())
);
const filteredGroups = groupOptions.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
);
return (
<Modal
opened={isOpen}
onClose={onClose}
size={1000}
title={
<Group gap="5">
<ListOrdered size="20" />
<Text>Channels</Text>
</Group>
}
styles={{ content: { '--mantine-color-body': '#27272A' } }}
>
<form onSubmit={formik.handleSubmit}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
id="name"
name="name"
label="Channel Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
size="xs"
/>
<Flex gap="sm">
<Popover
opened={groupPopoverOpened}
onChange={setGroupPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<TextInput
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
readOnly
value={
channelGroups[formik.values.channel_group_id]
? channelGroups[formik.values.channel_group_id].name
: ''
}
onClick={() => setGroupPopoverOpened(true)}
size="xs"
/>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<TextInput
placeholder="Filter"
value={groupFilter}
onChange={(event) =>
setGroupFilter(event.currentTarget.value)
}
mb="xs"
size="xs"
/>
</Group>
<ScrollArea style={{ height: 200 }}>
<List
height={200} // Set max height for visible items
itemCount={filteredGroups.length}
itemSize={20} // Adjust row height for each item
width={200}
ref={groupListRef}
>
{({ index, style }) => (
<Box
style={{ ...style, height: 20, overflow: 'hidden' }}
>
<Tooltip
openDelay={500}
label={filteredGroups[index].name}
size="xs"
>
<UnstyledButton
onClick={() => {
formik.setFieldValue(
'channel_group_id',
filteredGroups[index].id
);
setGroupPopoverOpened(false);
}}
>
<Text
size="xs"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{filteredGroups[index].name}
</Text>
</UnstyledButton>
</Tooltip>
</Box>
)}
</List>
</ScrollArea>
</Popover.Dropdown>
</Popover>
{/* <Select
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
searchable
onChange={(value) => {
formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value
}}
error={
formik.errors.channel_group_id
? formik.touched.channel_group_id
: ''
}
data={Object.values(channelGroups).map((option, index) => ({
value: `${option.id}`,
label: option.name,
}))}
size="xs"
style={{ flex: 1 }}
/> */}
<Flex align="flex-end">
<ActionIcon
color={theme.tailwind.green[5]}
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="transparent"
style={{ marginBottom: 5 }}
>
<SquarePlus size="20" />
</ActionIcon>
</Flex>
</Flex>
<Select
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={(value) => {
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
}}
error={
formik.errors.stream_profile_id
? formik.touched.stream_profile_id
: ''
}
data={[{ value: '0', label: '(use default)' }].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))
)}
size="xs"
/>
</Stack>
<Divider size="sm" orientation="vertical" />
<Stack justify="flex-start" style={{ flex: 1 }}>
<Group justify="space-between">
<Popover
opened={logoPopoverOpened}
onChange={setLogoPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<TextInput
id="logo_id"
name="logo_id"
label="Logo"
readOnly
value={logos[formik.values.logo_id]?.name || 'Default'}
onClick={() => setLogoPopoverOpened(true)}
size="xs"
/>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<TextInput
placeholder="Filter"
value={logoFilter}
onChange={(event) =>
setLogoFilter(event.currentTarget.value)
}
mb="xs"
size="xs"
/>
</Group>
<ScrollArea style={{ height: 200 }}>
<List
height={200} // Set max height for visible items
itemCount={filteredLogos.length}
itemSize={20} // Adjust row height for each item
width="100%"
ref={logoListRef}
>
{({ index, style }) => (
<div style={style}>
<Center>
<img
src={filteredLogos[index].cache_url || logo}
height="20"
style={{ maxWidth: 80 }}
onClick={() => {
formik.setFieldValue(
'logo_id',
filteredLogos[index].id
);
}}
/>
</Center>
</div>
)}
</List>
</ScrollArea>
</Popover.Dropdown>
</Popover>
<img
src={
logos[formik.values.logo_id]
? logos[formik.values.logo_id].cache_url
: logo
}
height="40"
/>
</Group>
<Group>
<Divider size="xs" style={{ flex: 1 }} />
<Text size="xs" c="dimmed">
OR
</Text>
<Divider size="xs" style={{ flex: 1 }} />
</Group>
<Stack>
<Text size="sm">Upload Logo</Text>
<Dropzone
onDrop={handleLogoChange}
onReject={(files) => console.log('rejected files', files)}
maxSize={5 * 1024 ** 2}
>
<Group
justify="center"
gap="xl"
mih={40}
style={{ pointerEvents: 'none' }}
>
<Text size="sm" inline>
Drag images here or click to select files
</Text>
</Group>
</Dropzone>
<Center></Center>
</Stack>
</Stack>
<Divider size="sm" orientation="vertical" />
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
<NumberInput
id="channel_number"
name="channel_number"
label="Channel # (blank to auto-assign)"
value={formik.values.channel_number}
onChange={(value) =>
formik.setFieldValue('channel_number', value)
}
error={
formik.errors.channel_number
? formik.touched.channel_number
: ''
}
size="xs"
/>
<TextInput
id="tvg_id"
name="tvg_id"
label="TVG-ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
size="xs"
/>
<TextInput
id="tvc_guide_stationid"
name="tvc_guide_stationid"
label="Gracenote StationId"
value={formik.values.tvc_guide_stationid}
onChange={formik.handleChange}
error={
formik.errors.tvc_guide_stationid
? formik.touched.tvc_guide_stationid
: ''
}
size="xs"
/>
<Popover
opened={epgPopoverOpened}
onChange={setEpgPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<TextInput
id="epg_data_id"
name="epg_data_id"
label={
<Group style={{ width: '100%' }}>
<Box>EPG</Box>
<Button
size="xs"
variant="transparent"
onClick={() =>
formik.setFieldValue('epg_data_id', null)
}
>
Use Dummy
</Button>
</Group>
}
readOnly
value={
formik.values.epg_data_id
? tvgsById[formik.values.epg_data_id].name
: 'Dummy'
}
onClick={() => setEpgPopoverOpened(true)}
size="xs"
rightSection={
<Tooltip label="Use dummy EPG">
<ActionIcon
// color={theme.tailwind.green[5]}
color="white"
onClick={(e) => {
e.stopPropagation();
formik.setFieldValue('epg_data_id', null);
}}
title="Create new group"
size="small"
variant="transparent"
>
<X size="20" />
</ActionIcon>
</Tooltip>
}
/>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
<Group>
<Select
label="Source"
value={selectedEPG}
onChange={setSelectedEPG}
data={Object.values(epgs).map((epg) => ({
value: `${epg.id}`,
label: epg.name,
}))}
size="xs"
mb="xs"
/>
{/* Filter Input */}
<TextInput
label="Filter"
value={tvgFilter}
onChange={(event) =>
setTvgFilter(event.currentTarget.value)
}
mb="xs"
size="xs"
/>
</Group>
<ScrollArea style={{ height: 200 }}>
<List
height={200} // Set max height for visible items
itemCount={filteredTvgs.length}
itemSize={40} // Adjust row height for each item
width="100%"
ref={listRef}
>
{({ index, style }) => (
<div style={style}>
<Button
key={filteredTvgs[index].id}
variant="subtle"
color="gray"
fullWidth
justify="left"
size="xs"
onClick={() => {
if (filteredTvgs[index].id == '0') {
formik.setFieldValue('epg_data_id', null);
} else {
formik.setFieldValue(
'epg_data_id',
filteredTvgs[index].id
);
}
setEpgPopoverOpened(false);
}}
>
{filteredTvgs[index].tvg_id}
</Button>
</div>
)}
</List>
</ScrollArea>
</Popover.Dropdown>
</Popover>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="default"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default ChannelsForm;

View file

@ -5,17 +5,18 @@ import { Paper, Title, TextInput, Button, Center, Stack } from '@mantine/core';
const LoginForm = () => {
const login = useAuthStore((s) => s.login);
const logout = useAuthStore((s) => s.logout);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const initData = useAuthStore((s) => s.initData);
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
// useEffect(() => {
// if (isAuthenticated) {
// navigate('/channels');
// }
// }, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
@ -27,8 +28,13 @@ const LoginForm = () => {
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
try {
await initData();
navigate('/channels');
} catch (e) {
console.log(`Failed to login: ${e}`);
}
};
return (
@ -60,7 +66,7 @@ const LoginForm = () => {
name="password"
value={formData.password}
onChange={handleInputChange}
required
// required
/>
<Button type="submit" mt="sm">

View file

@ -0,0 +1,168 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
NumberInput,
Space,
Select,
PasswordInput,
Box,
Group,
Stack,
MultiSelect,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import useUsersStore from '../../store/users';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
console.log(user);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
username: '',
email: '',
user_level: '0',
current_password: '',
password: '',
password_repeat: '',
channel_profiles: [],
},
validate: (values) => ({
username: !values.username
? 'Username is required'
: values.user_level == USER_LEVELS.STREAMER &&
!values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
!user && !values.password
? 'Password is requried'
: values.user_level == USER_LEVELS.STREAMER &&
!user &&
!values.password.match(/^[a-z0-9]+$/i)
? 'Streamer password must be alphanumeric'
: null,
}),
});
const onSubmit = async () => {
const values = form.getValues();
if (!user) {
await API.createUser(values);
} else {
if (!values.password) {
delete values.password;
}
await API.updateUser(user.id, values);
}
form.reset();
onClose();
};
useEffect(() => {
if (user) {
form.setValues({
username: user.username,
email: user.email,
user_level: `${user.user_level}`,
channel_profiles: user.channel_profiles.map((id) => `${id}`),
});
} else {
form.reset();
}
}, [user]);
if (!isOpen) {
return <></>;
}
console.log(user);
return (
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="xs" style={{ flex: 1 }}>
<TextInput
id="username"
name="username"
label="Username"
{...form.getInputProps('username')}
key={form.key('username')}
/>
<TextInput
id="email"
name="email"
label="E-Mail"
{...form.getInputProps('email')}
key={form.key('email')}
/>
<PasswordInput
label="Password"
{...form.getInputProps('password')}
key={form.key('password')}
/>
</Stack>
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([label, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
<MultiSelect
label="Channel Profiles"
{...form.getInputProps('channel_profiles')}
key={form.key('channel_profiles')}
data={Object.values(profiles)
.filter((profile) => profile.id != 0)
.map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
/>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={form.submitting}
size="small"
>
Save
</Button>
</Flex>
</form>
</Modal>
);
};
export default User;

View file

@ -36,6 +36,8 @@ import {
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shallow } from 'zustand/shallow';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
@ -120,6 +122,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
shallow
);
const playlists = usePlaylistsStore((s) => s.playlists);
const authUser = useAuthStore((s) => s.user);
const [data, setData] = useState(channelStreams || []);
@ -168,6 +171,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
<SquareMinus
color={theme.tailwind.red[6]}
onClick={() => removeStream(row.original)}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
/>
</ActionIcon>
</Center>
@ -192,7 +196,11 @@ const ChannelStreams = ({ channel, isExpanded }) => {
getCoreRowModel: getCoreRowModel(),
});
function handleDragEnd(event) {
const handleDragEnd = (event) => {
if (authUser.user_level != USER_LEVELS.ADMIN) {
return;
}
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((data) => {
@ -211,7 +219,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
return retval; //this is just a splice util
});
}
}
};
const sensors = useSensors(
useSensor(MouseSensor, {}),

View file

@ -51,6 +51,8 @@ import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
@ -108,6 +110,8 @@ const ChannelRowActions = React.memo(
const channelUuid = row.original.uuid;
const [tableSize, _] = useLocalStorage('table-size', 'default');
const authUser = useAuthStore((s) => s.user);
const onEdit = useCallback(() => {
// Use the ID directly to avoid issues with filtered tables
console.log(`Editing channel ID: ${channelId}`);
@ -141,6 +145,7 @@ const ChannelRowActions = React.memo(
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquarePen size="18" />
</ActionIcon>
@ -150,6 +155,7 @@ const ChannelRowActions = React.memo(
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquareMinus size="18" />
</ActionIcon>
@ -181,6 +187,7 @@ const ChannelRowActions = React.memo(
</Menu.Item>
<Menu.Item
onClick={onRecord}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
leftSection={
<div
style={{
@ -203,7 +210,7 @@ const ChannelRowActions = React.memo(
}
);
const ChannelsTable = ({ }) => {
const ChannelsTable = ({}) => {
const theme = useMantineTheme();
/**
@ -596,8 +603,12 @@ const ChannelsTable = ({ }) => {
cell: ({ getValue }) => {
const value = getValue();
// Format as integer if no decimal component
const formattedValue = value !== null && value !== undefined ?
(value === Math.floor(value) ? Math.floor(value) : value) : '';
const formattedValue =
value !== null && value !== undefined
? value === Math.floor(value)
? Math.floor(value)
: value
: '';
return (
<Flex justify="flex-end" style={{ width: '100%' }}>
@ -797,8 +808,8 @@ const ChannelsTable = ({ }) => {
return hasStreams
? {} // Default style for channels with streams
: {
className: 'no-streams-row', // Add a class instead of background color
};
className: 'no-streams-row', // Add a class instead of background color
};
},
});

View file

@ -24,12 +24,16 @@ import {
import API from '../../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useAuthStore from '../../../store/auth';
import { USER_LEVELS } from '../../../constants';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
const [name, setName] = useState('');
const theme = useMantineTheme();
const authUser = useAuthStore((s) => s.user);
const setOpen = () => {
setName('');
setOpened(!opened);
@ -54,6 +58,7 @@ const CreateProfilePopover = React.memo(() => {
variant="transparent"
color={theme.tailwind.green[5]}
onClick={setOpen}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquarePlus />
</ActionIcon>
@ -95,6 +100,7 @@ const ChannelTableHeader = ({
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
const authUser = useAuthStore((s) => s.user);
const deleteProfile = async (id) => {
await API.deleteChannelProfile(id);
@ -152,6 +158,7 @@ const ChannelTableHeader = ({
e.stopPropagation();
deleteProfile(option.value);
}}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<SquareMinus />
</ActionIcon>
@ -193,7 +200,10 @@ const ChannelTableHeader = ({
variant="default"
size="xs"
onClick={deleteChannels}
disabled={selectedTableIds.length == 0}
disabled={
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
>
Remove
</Button>
@ -206,7 +216,10 @@ const ChannelTableHeader = ({
variant="default"
size="xs"
p={5}
disabled={selectedTableIds.length == 0}
disabled={
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
>
Assign
</Button>
@ -240,6 +253,7 @@ const ChannelTableHeader = ({
size="xs"
onClick={matchEpg}
p={5}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
Auto-Match
</Button>
@ -250,12 +264,15 @@ const ChannelTableHeader = ({
variant="light"
size="xs"
onClick={() => editChannel()}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
...(authUser.user_level == USER_LEVELS.ADMIN && {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}),
}}
>
Add

11
frontend/src/constants.js Normal file
View file

@ -0,0 +1,11 @@
export const USER_LEVELS = {
STREAMER: 0,
READ_ONLY: 1,
ADMIN: 10,
};
export const USER_LEVEL_LABELS = {
[USER_LEVELS.STREAMER]: 'Streamer',
[USER_LEVELS.READ_ONLY]: 'Read Only',
[USER_LEVELS.ADMIN]: 'Admin',
};

View file

@ -1,15 +0,0 @@
import React from 'react';
import { Allotment } from 'allotment';
import { Box, Container } from '@mantine/core';
import 'allotment/dist/style.css';
const ChannelsPage = () => {
return (
<Allotment>
<div>Pane 1</div>
<div>Pane 1</div>
</Allotment>
);
};
export default ChannelsPage;

View file

@ -3,8 +3,20 @@ import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Box } from '@mantine/core';
import { Allotment } from 'allotment';
import { USER_LEVELS } from '../constants';
import useAuthStore from '../store/auth';
const ChannelsPage = () => {
const authUser = useAuthStore((s) => s.user);
if (authUser.user_level <= USER_LEVELS.READ_ONLY) {
return (
<Box style={{ padding: 10 }}>
<ChannelsTable />
</Box>
);
}
return (
<div style={{ height: '100vh', width: '100%', display: 'flex' }}>
<Allotment

View file

@ -19,11 +19,14 @@ import { isNotEmpty, useForm } from '@mantine/form';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
import useLocalStorage from '../hooks/useLocalStorage';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
const SettingsPage = () => {
const settings = useSettingsStore((s) => s.settings);
const userAgents = useUserAgentsStore((s) => s.userAgents);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
// UI / local storage settings
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
@ -366,137 +369,178 @@ const SettingsPage = () => {
>
<Box style={{ width: '100%', maxWidth: 800 }}>
<Accordion variant="separated" defaultValue="ui-settings">
<Accordion.Item value="ui-settings">
<Accordion.Control>UI Settings</Accordion.Control>
<Accordion.Panel>
<Select
label="Table Size"
value={tableSize}
onChange={(val) => onUISettingsChange('table-size', val)}
data={[
{
value: 'default',
label: 'Default',
},
{
value: 'compact',
label: 'Compact',
},
{
value: 'large',
label: 'Large',
},
]}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="stream-settings">
<Accordion.Control>Stream Settings</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
{[
<Accordion.Item value="ui-settings">
<Accordion.Control>UI Settings</Accordion.Control>
<Accordion.Panel>
<Select
searchable
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={settings['default-user-agent']?.id || 'default-user-agent'}
name={settings['default-user-agent']?.key || 'default-user-agent'}
label={settings['default-user-agent']?.name || 'Default User Agent'}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={settings['default-stream-profile']?.id || 'default-stream-profile'}
name={settings['default-stream-profile']?.key || 'default-stream-profile'}
label={settings['default-stream-profile']?.name || 'Default Stream Profile'}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={settings['preferred-region']?.id || 'preferred-region'}
name={settings['preferred-region']?.key || 'preferred-region'}
label={settings['preferred-region']?.name || 'Preferred Region'}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Group justify="space-between" style={{ paddingTop: 5 }}>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
type: 'checkbox',
})}
key={form.key('auto-import-mapped-files')}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
/>
</Group>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
label="M3U Hash Key"
label="Table Size"
value={tableSize}
onChange={(val) => onUISettingsChange('table-size', val)}
data={[
{
value: 'name',
label: 'Name',
value: 'default',
label: 'Default',
},
{
value: 'url',
label: 'URL',
value: 'compact',
label: 'Compact',
},
{
value: 'tvg_id',
label: 'TVG-ID',
value: 'large',
label: 'Large',
},
]}
{...form.getInputProps('m3u-hash-key')}
key={form.key('m3u-hash-key')}
/>
</Accordion.Panel>
</Accordion.Item>,
].concat(
authUser.user_level == USER_LEVELS.ADMIN
? [
<Accordion.Item value="stream-settings">
<Accordion.Control>Stream Settings</Accordion.Control>
<Accordion.Panel>
<form onSubmit={form.onSubmit(onSubmit)}>
<Select
searchable
{...form.getInputProps('default-user-agent')}
key={form.key('default-user-agent')}
id={
settings['default-user-agent']?.id ||
'default-user-agent'
}
name={
settings['default-user-agent']?.key ||
'default-user-agent'
}
label={
settings['default-user-agent']?.name ||
'Default User Agent'
}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</form>
</Accordion.Panel>
</Accordion.Item>
<Select
searchable
{...form.getInputProps('default-stream-profile')}
key={form.key('default-stream-profile')}
id={
settings['default-stream-profile']?.id ||
'default-stream-profile'
}
name={
settings['default-stream-profile']?.key ||
'default-stream-profile'
}
label={
settings['default-stream-profile']?.name ||
'Default Stream Profile'
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
<Select
searchable
{...form.getInputProps('preferred-region')}
key={form.key('preferred-region')}
id={
settings['preferred-region']?.id ||
'preferred-region'
}
name={
settings['preferred-region']?.key ||
'preferred-region'
}
label={
settings['preferred-region']?.name ||
'Preferred Region'
}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/>
<Accordion.Item value="user-agents">
<Accordion.Control>User-Agents</Accordion.Control>
<Accordion.Panel>
<UserAgentsTable />
</Accordion.Panel>
</Accordion.Item>
<Group
justify="space-between"
style={{ paddingTop: 5 }}
>
<Text size="sm" fw={500}>
Auto-Import Mapped Files
</Text>
<Switch
{...form.getInputProps('auto-import-mapped-files', {
type: 'checkbox',
})}
key={form.key('auto-import-mapped-files')}
id={
settings['auto-import-mapped-files']?.id ||
'auto-import-mapped-files'
}
/>
</Group>
<Accordion.Item value="stream-profiles">
<Accordion.Control>Stream Profiles</Accordion.Control>
<Accordion.Panel>
<StreamProfilesTable />
</Accordion.Panel>
</Accordion.Item>
<MultiSelect
id="m3u-hash-key"
name="m3u-hash-key"
label="M3U Hash Key"
data={[
{
value: 'name',
label: 'Name',
},
{
value: 'url',
label: 'URL',
},
{
value: 'tvg_id',
label: 'TVG-ID',
},
]}
{...form.getInputProps('m3u-hash-key')}
key={form.key('m3u-hash-key')}
/>
<Flex
mih={50}
gap="xs"
justify="flex-end"
align="flex-end"
>
<Button
type="submit"
disabled={form.submitting}
variant="default"
>
Save
</Button>
</Flex>
</form>
</Accordion.Panel>
</Accordion.Item>,
<Accordion.Item value="user-agents">
<Accordion.Control>User-Agents</Accordion.Control>
<Accordion.Panel>
<UserAgentsTable />
</Accordion.Panel>
</Accordion.Item>,
<Accordion.Item value="stream-profiles">
<Accordion.Control>Stream Profiles</Accordion.Control>
<Accordion.Panel>
<StreamProfilesTable />
</Accordion.Panel>
</Accordion.Item>,
]
: []
)}
</Accordion>
</Box>
</Center>

View file

@ -0,0 +1,118 @@
import React, { useState } from 'react';
import useUsersStore from '../store/users';
import {
ActionIcon,
Box,
Button,
Center,
Group,
Paper,
Select,
useMantineTheme,
} from '@mantine/core';
import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react';
import UserForm from '../components/forms/User';
import useAuthStore from '../store/auth';
import API from '../api';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../constants';
const UsersPage = () => {
const theme = useMantineTheme();
const authUser = useAuthStore((s) => s.user);
const users = useUsersStore((s) => s.users);
const [selectedUser, setSelectedUser] = useState(null);
const [userModalOpen, setUserModalOpen] = useState(false);
console.log(authUser);
const closeUserModal = () => {
setSelectedUser(null);
setUserModalOpen(false);
};
const editUser = (user) => {
setSelectedUser(user);
setUserModalOpen(true);
};
const deleteUser = (id) => {
API.deleteUser(id);
};
return (
<>
<Center>
<Paper
style={{
minWidth: 400,
padding: 10,
}}
>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUser()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add User
</Button>
{Object.values(users)
.sort((a, b) => a.id > b.id)
.map((user) => {
return (
<Group justify="space-between">
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.username}
</Box>
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.email}
</Box>
{authUser.user_level == USER_LEVELS.ADMIN && (
<Group>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editUser(user)}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={() => deleteUser(user.id)}
disabled={authUser.id === user.id}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
)}
</Group>
);
})}
</Paper>
</Center>
<UserForm
user={selectedUser}
isOpen={userModalOpen}
onClose={closeUserModal}
/>
</>
);
};
export default UsersPage;

View file

@ -6,6 +6,9 @@ import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
import useUserAgentsStore from './userAgents';
import useUsersStore from './users';
import API from '../api';
import { USER_LEVELS } from '../constants';
const decodeToken = (token) => {
if (!token) return null;
@ -26,11 +29,17 @@ const useAuthStore = create((set, get) => ({
user: {
username: '',
email: '',
user_level: '',
},
isLoading: false,
error: null,
initData: async () => {
const user = await API.me();
if (user.user_level <= USER_LEVELS.STREAMER) {
throw new Error('Unauthorized');
}
// Ensure settings are loaded first
await useSettingsStore.getState().fetchSettings();
@ -47,8 +56,14 @@ const useAuthStore = create((set, get) => ({
useStreamProfilesStore.getState().fetchProfiles(),
useUserAgentsStore.getState().fetchUserAgents(),
]);
if (user.user_level >= USER_LEVELS.ADMIN) {
await Promise.all([useUsersStore.getState().fetchUsers()]);
}
set({ user, isAuthenticated: true });
} catch (error) {
console.error("Error initializing data:", error);
console.error('Error initializing data:', error);
}
},
@ -83,7 +98,6 @@ const useAuthStore = create((set, get) => ({
accessToken: response.access,
refreshToken: response.refresh,
tokenExpiration: expiration, // 1 hour from now
isAuthenticated: true,
});
// Store in localStorage
localStorage.setItem('accessToken', response.access);
@ -128,6 +142,7 @@ const useAuthStore = create((set, get) => ({
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
user: null,
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');

View file

@ -0,0 +1,41 @@
import { create } from 'zustand';
import api from '../api';
const useUsersStore = create((set) => ({
users: {},
isLoading: false,
error: null,
fetchUsers: async () => {
set({ isLoading: true, error: null });
try {
const users = await api.getUsers();
set({
users,
isLoading: false,
});
} catch (error) {
console.error('Failed to fetch users:', error);
set({ error: 'Failed to load users.', isLoading: false });
}
},
addUser: (user) =>
set((state) => ({
users: state.users.concat([user]),
})),
updateUser: (updatedUser) =>
set((state) => ({
users: state.users.map((user) =>
user.id === updatedUser.id ? updatedUser : user
),
})),
removeUser: (userId) =>
set((state) => ({
users: state.users.filter((user) => (user.id === userId ? false : true)),
})),
}));
export default useUsersStore;

View file

@ -18,6 +18,8 @@ django-cors-headers
djangorestframework-simplejwt
m3u8
rapidfuzz==3.12.1
tzlocal
# PyTorch dependencies (CPU only)
--extra-index-url https://download.pytorch.org/whl/cpu/
torch==2.6.0+cpu