mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-08-03 15:22:36 +00:00
user management, user levels, user level channel access
This commit is contained in:
parent
eecf879119
commit
74d58515d0
42 changed files with 3788 additions and 1268 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
38
apps/accounts/permissions.py
Normal file
38
apps/accounts/permissions.py
Normal 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],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
18
apps/channels/migrations/0021_channel_user_level.py
Normal file
18
apps/channels/migrations/0021_channel_user_level.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
831
frontend/src/components/forms/Channels.jsx
Normal file
831
frontend/src/components/forms/Channels.jsx
Normal 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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
168
frontend/src/components/forms/User.jsx
Normal file
168
frontend/src/components/forms/User.jsx
Normal 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;
|
||||
|
|
@ -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, {}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
11
frontend/src/constants.js
Normal 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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
118
frontend/src/pages/Users.jsx
Normal file
118
frontend/src/pages/Users.jsx
Normal 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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
41
frontend/src/store/users.jsx
Normal file
41
frontend/src/store/users.jsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue