Initial commit

This commit is contained in:
Dispatcharr 2025-02-18 11:14:09 -06:00
commit 8edb743ebd
1078 changed files with 264296 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

8
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

34
.idea/DispatcharrSM.iml generated Normal file
View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="dispatcharr/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.9 (DispatcharrSM)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.9 (DispatcharrSM)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (DispatcharrSM)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/DispatcharrSM.iml" filepath="$PROJECT_DIR$/.idea/DispatcharrSM.iml" />
</modules>
</component>
</project>

77
FileTree.py Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
import os
# Specify the names of the script file and output file to exclude them
SCRIPT_NAME = "FileTree.py"
OUTPUT_FILE = "FileTree.txt"
EXCLUDED_FILES = {SCRIPT_NAME, OUTPUT_FILE, ".DS_Store", "__init__.py", "FileTree.old.txt"}
EXCLUDED_DIRS = {"__pycache__", "migrations", "static", "staticfiles", "media", ".venv", ".idea"} # Exclude directories like __pycache__
def generate_file_tree(output_file):
"""Generate a pretty file tree of the current directory and subdirectories."""
with open(output_file, 'w') as f:
for root, dirs, files in os.walk('.'): # Walk through the directory tree
# Remove excluded directories from the traversal
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
level = root.count(os.sep)
indent = '' * level
f.write(f"{indent}├── {os.path.basename(root)}/\n")
sub_indent = '' * (level + 1)
for i, file in enumerate(files):
if file not in EXCLUDED_FILES:
connector = '└── ' if i == len(files) - 1 else '├── '
f.write(f"{sub_indent}{connector}{file}\n")
def append_file_contents(output_file):
"""Append contents of each file in the current directory and subdirectories to the output file, excluding specified files."""
# Determine the maximum width for the boxes
max_width = 20 # Default minimum width
file_paths = []
for root, dirs, files in os.walk('.'): # Walk through the directory tree
# Remove excluded directories from the traversal
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
for file_name in files:
if file_name not in EXCLUDED_FILES:
file_path = os.path.join(root, file_name)
relative_path = os.path.relpath(file_path, start='.')
directory = os.path.dirname(relative_path)
base_name = os.path.basename(relative_path)
file_paths.append((directory, base_name))
max_width = max(max_width, len(directory) + 10, len(base_name) + 10)
max_width += 4 # Add padding for aesthetics
# Append file contents with uniform box size
with open(output_file, 'a') as f:
for directory, base_name in file_paths:
# Add the formatted header for the file
horizontal_line = f"{'' * max_width}"
directory_line = f"│ Directory: {directory:<{max_width - 12}}"
file_line = f"│ File: {base_name:<{max_width - 12}}"
bottom_line = f"{'' * max_width}"
f.write(f"\n{horizontal_line}\n")
f.write(f"{directory_line}\n")
f.write(f"{file_line}\n")
f.write(f"{bottom_line}\n\n")
# Append the contents of the file
file_path = os.path.join(directory, base_name)
try:
with open(file_path, 'r', errors='ignore') as file:
f.write(file.read())
except Exception as e:
f.write(f"Error reading {file_path}: {e}\n")
# Add a visually distinct footer to signify the end of the file
f.write(f"\n========= END OF FILE =========\n")
f.write(f"File: {base_name}\n")
f.write(f"===============================\n\n")
def main():
generate_file_tree(OUTPUT_FILE)
append_file_contents(OUTPUT_FILE)
if __name__ == "__main__":
main()

6348
FileTree.txt Normal file

File diff suppressed because it is too large Load diff

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# Dispatcharr

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

BIN
apps/accounts/.DS_Store vendored Normal file

Binary file not shown.

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

16
apps/accounts/admin.py Normal file
View file

@ -0,0 +1,16 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin, GroupAdmin
from django.contrib.auth.models import Group
from .models import User
@admin.register(User)
class CustomUserAdmin(UserAdmin):
fieldsets = (
(None, {'fields': ('username', 'password', 'avatar_config', 'groups')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
# Unregister default Group admin and re-register it.
admin.site.unregister(Group)
admin.site.register(Group, GroupAdmin)

35
apps/accounts/api_urls.py Normal file
View file

@ -0,0 +1,35 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
AuthViewSet, UserViewSet, GroupViewSet,
list_permissions
)
app_name = 'accounts'
# 🔹 Register ViewSets with a Router
router = DefaultRouter()
router.register(r'users', UserViewSet, basename='user')
router.register(r'groups', GroupViewSet, basename='group')
# 🔹 Custom Authentication Endpoints
auth_view = AuthViewSet.as_view({
'post': 'login'
})
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'),
# Permissions API
path('permissions/', list_permissions, name='list-permissions'),
]
# 🔹 Include ViewSet routes
urlpatterns += router.urls

132
apps/accounts/api_views.py Normal file
View file

@ -0,0 +1,132 @@
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.models import Group, Permission
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework import viewsets
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from .models import User
from .serializers import UserSerializer, GroupSerializer, PermissionSerializer
# 🔹 1) Authentication APIs
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['username', 'password'],
properties={
'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')
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({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"}
)
def logout(self, request):
"""Logs out the authenticated user"""
logout(request)
return Response({"message": "Logout successful"})
# 🔹 2) User Management APIs
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of users",
responses={200: UserSerializer(many=True)}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific user by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new user")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a user")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a user")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 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)}
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific group by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new group")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a group")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a group")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 4) Permissions List API
@swagger_auto_schema(
method='get',
operation_description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)}
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def list_permissions(request):
"""Returns a list of all available permissions"""
permissions = Permission.objects.all()
serializer = PermissionSerializer(permissions, many=True)
return Response(serializer.data)

6
apps/accounts/apps.py Normal file
View file

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

59
apps/accounts/forms.py Normal file
View file

@ -0,0 +1,59 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.auth.models import Group as AuthGroup
from apps.channels.models import ChannelGroup
from .models import User
from .models import User
class UserRegistrationForm(UserCreationForm):
groups = forms.ModelMultipleChoiceField(
queryset=AuthGroup.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple
)
class Meta:
model = User
fields = ['username', 'groups', 'password1', 'password2', ]
def save(self, commit=True):
user = super().save(commit=False)
if commit:
user.save()
self.save_m2m() # Save the many-to-many field data
return user
class GroupForm(forms.ModelForm):
permissions = forms.ModelMultipleChoiceField(
queryset=Permission.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False
)
class Meta:
model = AuthGroup
fields = ['name', 'permissions']
class UserEditForm(forms.ModelForm):
auth_groups = forms.ModelMultipleChoiceField(
queryset=AuthGroup.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Auth Groups"
)
channel_groups = forms.ModelMultipleChoiceField(
queryset=ChannelGroup.objects.all(),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Channel Groups"
)
class Meta:
model = User
fields = ['username', 'email', 'auth_groups', 'channel_groups']

View file

@ -0,0 +1,47 @@
# Generated by Django 4.2.2 on 2025-02-18 16:33
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('channels', '__first__'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('avatar_config', models.JSONField(blank=True, default=dict, null=True)),
('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='channels.channelgroup')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]

View file

30
apps/accounts/models.py Normal file
View file

@ -0,0 +1,30 @@
# apps/accounts/models.py
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.
"""
avatar_config = models.JSONField(default=dict, blank=True, null=True)
channel_groups = models.ManyToManyField(
'channels.ChannelGroup', # Updated reference to renamed model
blank=True,
related_name="users"
)
def __str__(self):
return self.username
def get_groups(self):
"""
Returns the groups (roles) the user belongs to.
"""
return self.groups.all()
def get_permissions(self):
"""
Returns the permissions assigned to the user and their groups.
"""
return self.user_permissions.all() | Permission.objects.filter(group__user=self)

View file

@ -0,0 +1,32 @@
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
from .models import User
# 🔹 Fix for Permission serialization
class PermissionSerializer(serializers.ModelSerializer):
class Meta:
model = Permission
fields = ['id', 'name', 'codename']
# 🔹 Fix for Group serialization
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.PrimaryKeyRelatedField(
many=True, queryset=Permission.objects.all()
) # ✅ Fixes ManyToManyField `_meta` error
class Meta:
model = Group
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
class Meta:
model = User
fields = ['id', 'username', 'email', 'groups']

14
apps/accounts/signals.py Normal file
View file

@ -0,0 +1,14 @@
# apps/accounts/signals.py
# Example: automatically create something on user creation
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:
# e.g. initialize default avatar config
if not instance.avatar_config:
instance.avatar_config = {"style": "circle"}
instance.save()

BIN
apps/api/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

40
apps/api/urls.py Normal file
View file

@ -0,0 +1,40 @@
from django.urls import path, include
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework.permissions import AllowAny
app_name = 'api'
# Configure Swagger Schema
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="support@dispatcharr.local"),
license=openapi.License(name="Unlicense"),
),
public=True,
permission_classes=(AllowAny,),
)
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
#path('backup/', include(('apps.backup.api_urls', 'backup'), namespace='backup')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
path('epg/', include(('apps.epg.api_urls', 'epg'), namespace='epg')),
path('hdhr/', include(('apps.hdhr.api_urls', 'hdhr'), namespace='hdhr')),
path('m3u/', include(('apps.m3u.api_urls', 'm3u'), namespace='m3u')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),
#path('streams/', include(('apps.streams.api_urls', 'streams'), namespace='streams')),
# Swagger Documentation api_urls
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
]

BIN
apps/channels/.DS_Store vendored Normal file

Binary file not shown.

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
apps/channels/admin.py Normal file
View file

@ -0,0 +1,27 @@
from django.contrib import admin
from .models import Stream, Channel, ChannelGroup
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = (
'id', 'name', 'group_name', 'custom_url',
'current_viewers', 'is_transcoded', 'updated_at',
)
list_filter = ('group_name', 'is_transcoded')
search_fields = ('name', 'custom_url', 'group_name')
ordering = ('-updated_at',)
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = (
'channel_number', 'channel_name', 'channel_group',
'is_active', 'is_looping', 'shuffle_mode', 'tvg_name'
)
list_filter = ('channel_group', 'is_active', 'is_looping', 'shuffle_mode')
search_fields = ('channel_name', 'channel_group__name', 'tvg_name')
ordering = ('channel_number',)
@admin.register(ChannelGroup)
class ChannelGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)

24
apps/channels/api_urls.py Normal file
View file

@ -0,0 +1,24 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
StreamViewSet,
ChannelViewSet,
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsViewSet
)
app_name = 'channels' # for DRF routing
router = DefaultRouter()
router.register(r'streams', StreamViewSet, basename='stream')
router.register(r'groups', ChannelGroupViewSet, basename='channel-group')
router.register(r'channels', ChannelViewSet, basename='channel')
router.register(r'bulk-delete-channels', BulkDeleteChannelsViewSet, basename='bulk-delete-channels')
urlpatterns = [
# Bulk delete for streams is a single APIView, not a ViewSet
path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'),
]
urlpatterns += router.urls

173
apps/channels/api_views.py Normal file
View file

@ -0,0 +1,173 @@
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 rest_framework.decorators import action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
# ─────────────────────────────────────────────────────────
class StreamViewSet(viewsets.ModelViewSet):
queryset = Stream.objects.all()
serializer_class = StreamSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = super().get_queryset()
assigned = self.request.query_params.get('assigned')
if assigned is not None:
# Streams that belong to a given channel?
qs = qs.filter(channels__id=assigned)
unassigned = self.request.query_params.get('unassigned')
if unassigned == '1':
# Streams that are not linked to any channel
qs = qs.filter(channels__isnull=True)
return qs
# ─────────────────────────────────────────────────────────
# 2) Channel Group Management (CRUD)
# ─────────────────────────────────────────────────────────
class ChannelGroupViewSet(viewsets.ModelViewSet):
queryset = ChannelGroup.objects.all()
serializer_class = ChannelGroupSerializer
permission_classes = [IsAuthenticated]
# ─────────────────────────────────────────────────────────
# 3) Channel Management (CRUD)
# ─────────────────────────────────────────────────────────
class ChannelViewSet(viewsets.ModelViewSet):
queryset = Channel.objects.all()
serializer_class = ChannelSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
method='post',
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_order"],
properties={
"channel_order": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of channel IDs in the new order"
)
}
),
responses={200: "Channels have been auto-assigned!"}
)
@action(detail=False, methods=['post'], url_path='assign')
def assign(self, request):
channel_order = request.data.get('channel_order', [])
for order, channel_id in enumerate(channel_order, start=1):
Channel.objects.filter(id=channel_id).update(channel_number=order)
return Response({"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK)
@swagger_auto_schema(
method='post',
operation_description=(
"Create a new channel from an existing stream.\n"
"Request body must contain: 'stream_id', 'channel_number', 'channel_name'."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id", "channel_number", "channel_name"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
),
"channel_number": openapi.Schema(
type=openapi.TYPE_INTEGER, description="Desired channel_number"
),
"channel_name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
)
}
),
responses={201: ChannelSerializer()}
)
@action(detail=False, methods=['post'], url_path='from-stream')
def from_stream(self, request):
stream_id = request.data.get('stream_id')
if not stream_id:
return Response({"error": "Missing stream_id"}, status=status.HTTP_400_BAD_REQUEST)
stream = get_object_or_404(Stream, pk=stream_id)
channel_data = {
'channel_number': request.data.get('channel_number', 0),
'channel_name': request.data.get('channel_name', f"Channel from {stream.name}"),
}
serializer = self.get_serializer(data=channel_data)
serializer.is_valid(raise_exception=True)
channel = serializer.save()
# Optionally attach the stream to that channel
channel.streams.add(stream)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# ─────────────────────────────────────────────────────────
# 4) Bulk Delete Streams
# ─────────────────────────────────────────────────────────
class BulkDeleteStreamsAPIView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Bulk delete streams by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Stream IDs to delete"
)
},
),
responses={204: "Streams deleted"}
)
def delete(self, request, *args, **kwargs):
stream_ids = request.data.get('stream_ids', [])
Stream.objects.filter(id__in=stream_ids).delete()
return Response({"message": "Streams deleted successfully!"}, status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────────────────────────────────────
# 5) Bulk Delete Channels
# ─────────────────────────────────────────────────────────
class BulkDeleteChannelsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Bulk delete channels by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to delete"
)
},
),
responses={204: "Channels deleted"}
)
def destroy(self, request):
channel_ids = request.data.get('channel_ids', [])
Channel.objects.filter(id__in=channel_ids).delete()
return Response({"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT)

6
apps/channels/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.channels'
verbose_name = "Channel & Stream Management"

52
apps/channels/forms.py Normal file
View file

@ -0,0 +1,52 @@
from django import forms
from .models import Stream, Channel, ChannelGroup
#
# ChannelGroup Form
#
class ChannelGroupForm(forms.ModelForm):
class Meta:
model = ChannelGroup
fields = ['name']
#
# Channel Form
#
class ChannelForm(forms.ModelForm):
channel_group = forms.ModelChoiceField(
queryset=ChannelGroup.objects.all(),
required=False,
label="Channel Group",
empty_label="--- No group ---"
)
class Meta:
model = Channel
fields = [
'channel_number',
'channel_name',
'channel_group',
'is_active',
'is_looping',
'shuffle_mode',
]
#
# Example: Stream Form (optional if you want a ModelForm for Streams)
#
class StreamForm(forms.ModelForm):
class Meta:
model = Stream
fields = [
'name',
'url',
'custom_url',
'logo_url',
'tvg_id',
'local_file',
'is_transcoded',
'ffmpeg_preset',
'group_name',
]

BIN
apps/channels/management/.DS_Store vendored Normal file

Binary file not shown.

View file

View file

@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand
from apps.channels.models import Stream, Channel, ChannelGroup
from apps.m3u.models import M3UAccount
class Command(BaseCommand):
help = "Delete all Channels, Streams, M3Us from the database (example)."
def handle(self, *args, **kwargs):
# Delete all Streams
stream_count = Stream.objects.count()
Stream.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {stream_count} Streams."))
# Or delete Channels:
channel_count = Channel.objects.count()
Channel.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {channel_count} Channels."))
# If you have M3UAccount:
m3u_count = M3UAccount.objects.count()
M3UAccount.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {m3u_count} M3U accounts."))
self.stdout.write(self.style.SUCCESS("Successfully deleted the requested objects."))

View file

@ -0,0 +1,63 @@
# Generated by Django 4.2.2 on 2025-02-18 16:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('m3u', '__first__'),
]
operations = [
migrations.CreateModel(
name='ChannelGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
],
),
migrations.CreateModel(
name='Stream',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default Stream', max_length=255)),
('url', models.URLField()),
('custom_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('local_file', models.FileField(blank=True, null=True, upload_to='uploads/')),
('current_viewers', models.PositiveIntegerField(default=0)),
('is_transcoded', models.BooleanField(default=False)),
('ffmpeg_preset', models.CharField(blank=True, max_length=50, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('group_name', models.CharField(blank=True, max_length=255, null=True)),
('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='m3u.m3uaccount')),
],
options={
'verbose_name': 'Stream',
'verbose_name_plural': 'Streams',
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='Channel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_number', models.IntegerField()),
('channel_name', models.CharField(max_length=255)),
('logo_url', models.URLField(blank=True, max_length=2000, null=True)),
('logo_file', models.ImageField(blank=True, null=True, upload_to='logos/')),
('tvg_id', models.CharField(blank=True, max_length=255, null=True)),
('tvg_name', models.CharField(blank=True, max_length=255, null=True)),
('is_active', models.BooleanField(default=True)),
('is_looping', models.BooleanField(default=False, help_text='If True, loops local file(s).')),
('shuffle_mode', models.BooleanField(default=False, help_text='If True, randomize streams for failover.')),
('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='channels.channelgroup')),
('streams', models.ManyToManyField(blank=True, related_name='channels', to='channels.stream')),
],
),
]

View file

101
apps/channels/models.py Normal file
View file

@ -0,0 +1,101 @@
from django.db import models
from django.core.exceptions import ValidationError
# If you have an M3UAccount model in apps.m3u, you can still import it:
from apps.m3u.models import M3UAccount
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()
custom_url = models.URLField(max_length=2000, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="streams"
)
logo_url = models.URLField(max_length=2000, 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)
current_viewers = models.PositiveIntegerField(default=0)
is_transcoded = models.BooleanField(default=False)
ffmpeg_preset = models.CharField(max_length=50, blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
group_name = models.CharField(max_length=255, blank=True, null=True)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','custom_url','m3u_account')
verbose_name = "Stream"
verbose_name_plural = "Streams"
ordering = ['-updated_at']
def __str__(self):
return self.name or self.custom_url or f"Stream ID {self.id}"
class ChannelManager(models.Manager):
def active(self):
return self.filter(is_active=True)
class Channel(models.Model):
channel_number = models.IntegerField()
channel_name = models.CharField(max_length=255)
logo_url = models.URLField(max_length=2000, blank=True, null=True)
logo_file = models.ImageField(
upload_to='logos/', # Will store in MEDIA_ROOT/logos
blank=True,
null=True
)
# M2M to Stream now in the same file
streams = models.ManyToManyField(
Stream,
blank=True,
related_name='channels'
)
channel_group = models.ForeignKey(
'ChannelGroup',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels',
help_text="Channel group this channel belongs to."
)
tvg_id = models.CharField(max_length=255, blank=True, null=True)
tvg_name = models.CharField(max_length=255, blank=True, null=True)
is_active = models.BooleanField(default=True)
is_looping = models.BooleanField(default=False, help_text="If True, loops local file(s).")
shuffle_mode = models.BooleanField(default=False, help_text="If True, randomize streams for failover.")
objects = ChannelManager()
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
).exclude(id=self.id)
if existing.exists():
raise ValidationError(
f"Channel number {self.channel_number} already exists in group {self.channel_group}."
)
def __str__(self):
return f"{self.channel_number} - {self.channel_name}"
class ChannelGroup(models.Model):
name = models.CharField(max_length=100, unique=True)
def related_channels(self):
# local import if needed to avoid cyc. Usually fine in a single file though
return Channel.objects.filter(channel_group=self)
def __str__(self):
return self.name

View file

@ -0,0 +1,68 @@
from rest_framework import serializers
from .models import Stream, Channel, ChannelGroup
#
# Stream
#
class StreamSerializer(serializers.ModelSerializer):
class Meta:
model = Stream
fields = [
'id',
'name',
'url',
'custom_url',
'm3u_account', # Uncomment if using M3U fields
'logo_url',
'tvg_id',
'local_file',
'current_viewers',
'is_transcoded',
'ffmpeg_preset',
'updated_at',
'group_name',
]
#
# Channel Group
#
class ChannelGroupSerializer(serializers.ModelSerializer):
class Meta:
model = ChannelGroup
fields = ['id', 'name']
#
# Channel
#
class ChannelSerializer(serializers.ModelSerializer):
# Show nested group data, or ID
channel_group = ChannelGroupSerializer(read_only=True)
channel_group_id = serializers.PrimaryKeyRelatedField(
queryset=ChannelGroup.objects.all(),
source="channel_group",
write_only=True,
required=False
)
# Possibly show streams inline, or just by ID
# streams = StreamSerializer(many=True, read_only=True)
class Meta:
model = Channel
fields = [
'id',
'channel_number',
'channel_name',
'logo_url',
'logo_file',
'channel_group',
'channel_group_id',
'tvg_id',
'tvg_name',
'is_active',
'is_looping',
'shuffle_mode',
'streams'
]

12
apps/channels/urls.py Normal file
View file

@ -0,0 +1,12 @@
from django.urls import path
from .views import StreamDashboardView, channels_dashboard_view
app_name = 'channels_dashboard'
urlpatterns = [
# Example “dashboard” routes for streams
path('streams/', StreamDashboardView.as_view(), name='stream_dashboard'),
# Example “dashboard” route for channels
path('channels/', channels_dashboard_view, name='channels_dashboard'),
]

25
apps/channels/utils.py Normal file
View file

@ -0,0 +1,25 @@
import threading
lock = threading.Lock()
# Dictionary to track usage: {account_id: current_usage}
active_streams_map = {}
def increment_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
current_usage += 1
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def decrement_stream_count(account):
with lock:
current_usage = active_streams_map.get(account.id, 0)
if current_usage > 0:
current_usage -= 1
if current_usage == 0:
del active_streams_map[account.id]
else:
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])

44
apps/channels/views.py Normal file
View file

@ -0,0 +1,44 @@
from django.views import View
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render
from .models import Stream
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(login_required, name='dispatch')
class StreamDashboardView(View):
"""
Example dashboard style view for Streams
"""
def get(self, request, *args, **kwargs):
streams = Stream.objects.values(
'id', 'name', 'url', 'custom_url',
'group_name', 'current_viewers'
)
return JsonResponse({'data': list(streams)}, safe=False)
def post(self, request, *args, **kwargs):
"""
Creates a new Stream from JSON data
"""
import json
try:
data = json.loads(request.body)
new_stream = Stream.objects.create(**data)
return JsonResponse({
'id': new_stream.id,
'message': 'Stream created successfully!'
}, status=201)
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@login_required
def channels_dashboard_view(request):
"""
Example dashboard style view for Channels
"""
return render(request, 'channels/channels.html')

BIN
apps/dashboard/.DS_Store vendored Normal file

Binary file not shown.

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2
apps/dashboard/admin.py Normal file
View file

@ -0,0 +1,2 @@
from django.contrib import admin

View file

@ -0,0 +1,8 @@
from django.urls import path
from .views import dashboard_view, settings_view, live_dashboard_data
app_name = 'dashboard'
urlpatterns = [
path('dashboard-data/', live_dashboard_data, name='dashboard_data'),
]

6
apps/dashboard/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.dashboard'

View file

@ -0,0 +1,46 @@
# Generated by Django 4.2.2 on 2025-02-18 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('server_name', models.CharField(default='Dispatcharr', max_length=255)),
('time_zone', models.CharField(default='UTC', max_length=50)),
('default_logo_url', models.URLField(blank=True, null=True)),
('max_concurrent_streams', models.PositiveIntegerField(default=10)),
('auto_backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
('enable_debug_logs', models.BooleanField(default=False)),
('schedules_direct_username', models.CharField(blank=True, max_length=255, null=True)),
('schedules_direct_password', models.CharField(blank=True, max_length=255, null=True)),
('schedules_direct_update_frequency', models.CharField(choices=[('12h', 'Every 12 Hours'), ('daily', 'Daily')], default='daily', max_length=50)),
('schedules_direct_api_key', models.CharField(blank=True, max_length=255, null=True)),
('transcoding_bitrate', models.PositiveIntegerField(default=2000)),
('transcoding_audio_codec', models.CharField(choices=[('aac', 'AAC'), ('mp3', 'MP3')], default='aac', max_length=50)),
('transcoding_resolution', models.CharField(choices=[('720p', '720p'), ('1080p', '1080p')], default='1080p', max_length=50)),
('failover_behavior', models.CharField(choices=[('sequential', 'Sequential'), ('random', 'Random')], default='sequential', max_length=50)),
('stream_health_check_frequency', models.PositiveIntegerField(default=5)),
('email_notifications', models.BooleanField(default=False)),
('webhook_url', models.URLField(blank=True, null=True)),
('cpu_alert_threshold', models.PositiveIntegerField(default=90)),
('memory_alert_threshold', models.PositiveIntegerField(default=90)),
('hdhr_integration', models.BooleanField(default=True)),
('custom_api_endpoints', models.JSONField(blank=True, null=True)),
('backup_path', models.CharField(default='backups/', max_length=255)),
('backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)),
('ffmpeg_path', models.CharField(default='/usr/bin/ffmpeg', max_length=255)),
('custom_transcoding_flags', models.TextField(blank=True, null=True)),
('celery_worker_concurrency', models.PositiveIntegerField(default=4)),
],
),
]

View file

66
apps/dashboard/models.py Normal file
View file

@ -0,0 +1,66 @@
from django.db import models
class Settings(models.Model):
# General Settings
server_name = models.CharField(max_length=255, default="Dispatcharr")
time_zone = models.CharField(max_length=50, default="UTC")
default_logo_url = models.URLField(blank=True, null=True)
max_concurrent_streams = models.PositiveIntegerField(default=10)
auto_backup_frequency = models.CharField(
max_length=50,
choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")],
default="weekly"
)
enable_debug_logs = models.BooleanField(default=False)
# Schedules Direct Settings
schedules_direct_username = models.CharField(max_length=255, blank=True, null=True)
schedules_direct_password = models.CharField(max_length=255, blank=True, null=True)
schedules_direct_update_frequency = models.CharField(
max_length=50,
choices=[("12h", "Every 12 Hours"), ("daily", "Daily")],
default="daily"
)
schedules_direct_api_key = models.CharField(max_length=255, blank=True, null=True)
# Stream and Channel Settings
transcoding_bitrate = models.PositiveIntegerField(default=2000) # in kbps
transcoding_audio_codec = models.CharField(
max_length=50,
choices=[("aac", "AAC"), ("mp3", "MP3")],
default="aac"
)
transcoding_resolution = models.CharField(
max_length=50,
choices=[("720p", "720p"), ("1080p", "1080p")],
default="1080p"
)
failover_behavior = models.CharField(
max_length=50,
choices=[("sequential", "Sequential"), ("random", "Random")],
default="sequential"
)
stream_health_check_frequency = models.PositiveIntegerField(default=5) # in minutes
# Notifications
email_notifications = models.BooleanField(default=False)
webhook_url = models.URLField(blank=True, null=True)
cpu_alert_threshold = models.PositiveIntegerField(default=90) # Percentage
memory_alert_threshold = models.PositiveIntegerField(default=90) # Percentage
# API Settings
hdhr_integration = models.BooleanField(default=True)
custom_api_endpoints = models.JSONField(blank=True, null=True)
# Backup and Restore
backup_path = models.CharField(max_length=255, default="backups/")
backup_frequency = models.CharField(
max_length=50,
choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")],
default="weekly"
)
# Advanced
ffmpeg_path = models.CharField(max_length=255, default="/usr/bin/ffmpeg")
custom_transcoding_flags = models.TextField(blank=True, null=True)
celery_worker_concurrency = models.PositiveIntegerField(default=4)

3
apps/dashboard/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
apps/dashboard/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from .views import dashboard_view, settings_view, live_dashboard_data
app_name = 'dashboard'
urlpatterns = [
path('', dashboard_view, name='dashboard'),
path('settings/', settings_view, name='settings'),
path('api/dashboard-data/', live_dashboard_data, name='dashboard_data'),
]

80
apps/dashboard/views.py Normal file
View file

@ -0,0 +1,80 @@
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from psutil import cpu_percent, virtual_memory, net_io_counters
from apps.channels.models import Stream
from django.http import JsonResponse # ADD THIS LINE
@login_required
def dashboard_view(request):
# Fetch system metrics
try:
cpu_usage = cpu_percent(interval=1)
ram = virtual_memory()
ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB"
network = net_io_counters()
network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB"
except Exception as e:
cpu_usage = "N/A"
ram_usage = "N/A"
network_traffic = "N/A"
print(f"Error fetching system metrics: {e}")
# Fetch active streams and related channels
active_streams = Stream.objects.filter(current_viewers__gt=0).prefetch_related('channels')
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
# Pass data to the template
context = {
"cpu_usage": f"{cpu_usage}%",
"ram_usage": ram_usage,
"current_streams": active_streams.count(),
"network_traffic": network_traffic,
"active_streams": active_streams_list,
}
return render(request, "dashboard/dashboard.html", context)
@login_required
def settings_view(request):
# Placeholder for settings functionality
return render(request, 'settings.html')
@login_required
def live_dashboard_data(request):
try:
cpu_usage = cpu_percent(interval=1)
ram = virtual_memory()
network = net_io_counters()
ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB"
network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB"
# Mocked example data for the charts
cpu_data = [45, 50, 60, 55, 70, 65]
ram_data = [6.5, 7.0, 7.5, 8.0, 8.5, 9.0]
network_data = [120, 125, 130, 128, 126, 124]
active_streams = Stream.objects.filter(current_viewers__gt=0)
active_streams_list = [
f"Stream {i + 1}: {stream.custom_url or 'Unknown'} ({stream.current_viewers} viewers)"
for i, stream in enumerate(active_streams)
]
data = {
"cpu_usage": f"{cpu_usage}%",
"ram_usage": ram_usage,
"current_streams": active_streams.count(),
"network_traffic": network_traffic,
"active_streams": active_streams_list,
"cpu_data": cpu_data,
"ram_data": ram_data,
"network_data": network_data,
}
except Exception as e:
data = {
"error": str(e)
}
return JsonResponse(data)

BIN
apps/epg/.DS_Store vendored Normal file

Binary file not shown.

0
apps/epg/__init__.py Normal file
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more