Merge branch 'main' into main

This commit is contained in:
Dispatcharr 2025-02-26 16:13:47 -06:00 committed by GitHub
commit d9b7df96a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 37548 additions and 28 deletions

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ __pycache__/
**/__pycache__/
**/.vscode/
*.pyc
node_modules/
.history/

View file

@ -1,9 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import (
AuthViewSet, UserViewSet, GroupViewSet,
AuthViewSet, UserViewSet, GroupViewSet,
list_permissions
)
from rest_framework_simplejwt import views as jwt_views
app_name = 'accounts'
@ -29,6 +30,9 @@ urlpatterns = [
# 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'),
]
# 🔹 Include ViewSet routes

View file

@ -5,9 +5,9 @@ from .models import Stream, Channel, ChannelGroup
class StreamAdmin(admin.ModelAdmin):
list_display = (
'id', 'name', 'group_name', 'custom_url',
'current_viewers', 'is_transcoded', 'updated_at',
'current_viewers', 'updated_at',
)
list_filter = ('group_name', 'is_transcoded')
list_filter = ('group_name',)
search_fields = ('name', 'custom_url', 'group_name')
ordering = ('-updated_at',)

View file

@ -10,7 +10,6 @@ from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
# ─────────────────────────────────────────────────────────
@ -21,20 +20,19 @@ class StreamViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
# Exclude streams from inactive M3U accounts
qs = qs.exclude(m3u_account__is_active=False)
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)
# ─────────────────────────────────────────────────────────
@ -43,7 +41,6 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
serializer_class = ChannelGroupSerializer
permission_classes = [IsAuthenticated]
# ─────────────────────────────────────────────────────────
# 3) Channel Management (CRUD)
# ─────────────────────────────────────────────────────────
@ -103,22 +100,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
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)
# Create a channel group from the stream group name if it doesn't already exist
channel_group, created = ChannelGroup.objects.get_or_create(
name=stream.group_name
)
# Include the stream's tvg_id in the channel data
channel_data = {
'channel_number': request.data.get('channel_number', 0),
'channel_name': request.data.get('channel_name', f"Channel from {stream.name}"),
'tvg_id': stream.tvg_id, # Inherit tvg-id from the stream
'channel_group_id': channel_group.id,
}
serializer = self.get_serializer(data=channel_data)
serializer.is_valid(raise_exception=True)
channel = serializer.save()
# Optionally attach the stream to that channel
# Optionally attach the stream to the channel
channel.streams.add(stream)
return Response(serializer.data, status=status.HTTP_201_CREATED)
# ─────────────────────────────────────────────────────────
# 4) Bulk Delete Streams
# ─────────────────────────────────────────────────────────
@ -145,7 +148,6 @@ class BulkDeleteStreamsAPIView(APIView):
Stream.objects.filter(id__in=stream_ids).delete()
return Response({"message": "Streams deleted successfully!"}, status=status.HTTP_204_NO_CONTENT)
# ─────────────────────────────────────────────────────────
# 5) Bulk Delete Channels
# ─────────────────────────────────────────────────────────

View file

@ -4,3 +4,7 @@ class ChannelsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.channels'
verbose_name = "Channel & Stream Management"
def ready(self):
# Import signals so they get registered.
import apps.channels.signals

View file

@ -43,6 +43,5 @@ class StreamForm(forms.ModelForm):
'logo_url',
'tvg_id',
'local_file',
'is_transcoded',
'group_name',
]

View file

@ -23,7 +23,6 @@ class Stream(models.Model):
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)
updated_at = models.DateTimeField(auto_now=True)
group_name = models.CharField(max_length=255, blank=True, null=True)
stream_profile = models.ForeignKey(

View file

@ -12,6 +12,7 @@ class StreamSerializer(serializers.ModelSerializer):
allow_null=True,
required=False
)
class Meta:
model = Stream
fields = [
@ -24,12 +25,24 @@ class StreamSerializer(serializers.ModelSerializer):
'tvg_id',
'local_file',
'current_viewers',
'is_transcoded',
'updated_at',
'group_name',
'stream_profile_id',
]
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):
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['group_name'].read_only = True
return fields
#
# Channel Group

16
apps/channels/signals.py Normal file
View file

@ -0,0 +1,16 @@
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Channel, Stream
@receiver(m2m_changed, sender=Channel.streams.through)
def update_channel_tvg_id(sender, instance, action, reverse, model, pk_set, **kwargs):
# When streams are added to a channel...
if action == "post_add":
# If the channel does not already have a tvg-id...
if not instance.tvg_id:
# Look for any of the newly added streams that have a nonempty tvg_id.
streams_with_tvg = model.objects.filter(pk__in=pk_set).exclude(tvg_id__exact='')
if streams_with_tvg.exists():
# Update the channel's tvg_id with the first found tvg_id.
instance.tvg_id = streams_with_tvg.first().tvg_id
instance.save(update_fields=['tvg_id'])

View file

@ -10,7 +10,7 @@ class EPGSourceAdmin(admin.ModelAdmin):
@admin.register(ProgramData)
class ProgramAdmin(admin.ModelAdmin):
list_display = ['title', 'get_channel_tvg_id', 'start_time', 'end_time']
list_filter = ['epg__channel'] # updated here
list_filter = ['epg__channel', 'tvg_id'] # updated here
search_fields = ['title', 'epg__channel__channel_name'] # updated here
def get_channel_tvg_id(self, obj):

View file

@ -35,6 +35,7 @@ class ProgramData(models.Model):
title = models.CharField(max_length=255)
sub_title = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
tvg_id = models.TextField(max_length=255, null=True)
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"

View file

@ -10,7 +10,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
model = ProgramData
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description']
fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id']
class EPGDataSerializer(serializers.ModelSerializer):
programs = ProgramDataSerializer(many=True, read_only=True)

View file

@ -47,8 +47,9 @@ def fetch_xmltv(source):
'end_time': stop_time,
'title': title,
'description': desc,
'tvg_id': channel_tvg_id,
})
# Process each channel group
for tvg_id, programmes in programmes_by_channel.items():
try:
@ -66,7 +67,7 @@ def fetch_xmltv(source):
if not created and epg_data.channel_name != channel.channel_name:
epg_data.channel_name = channel.channel_name
epg_data.save(update_fields=['channel_name'])
logger.info(f"Processing {len(programmes)} programme(s) for channel '{channel.channel_name}'.")
# For each programme, update or create a ProgramData record
with transaction.atomic():
@ -78,7 +79,8 @@ def fetch_xmltv(source):
defaults={
'end_time': prog['end_time'],
'description': prog['description'],
'sub_title': ''
'sub_title': '',
'tvg_id': tvg_id,
}
)
if created:

View file

@ -117,14 +117,23 @@ def refresh_single_m3u_account(account_id):
if line.startswith('#EXTINF'):
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
# Extract tvg-id
tvg_id_match = re.search(r'tvg-id="([^"]*)"', line)
tvg_id = tvg_id_match.group(1) if tvg_id_match else ""
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Default Stream"
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
group_title = _get_group_title(line)
logger.debug(f"Parsed EXTINF: name={name}, logo_url={logo_url}, group_title={group_title}")
current_info = {"name": name, "logo_url": logo_url, "group_title": group_title}
logger.debug(f"Parsed EXTINF: name={name}, logo_url={logo_url}, tvg_id={tvg_id}, group_title={group_title}")
current_info = {
"name": name,
"logo_url": logo_url,
"group_title": group_title,
"tvg_id": tvg_id, # save the tvg-id here
}
elif current_info and line.startswith('http'):
lower_line = line.lower()
@ -145,7 +154,11 @@ def refresh_single_m3u_account(account_id):
current_info = None
continue
defaults = {"logo_url": current_info["logo_url"]}
# Include tvg_id in the defaults so it gets saved
defaults = {
"logo_url": current_info["logo_url"],
"tvg_id": current_info["tvg_id"]
}
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],
@ -203,11 +216,13 @@ def parse_m3u_file(file_path, account):
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Stream"
tvg_id_match = re.search(r'tvg-id="([^"]*)"', line)
tvg_id = tvg_id_match.group(1) if tvg_id_match else ""
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
current_info = {"name": name, "logo_url": logo_url}
current_info = {"name": name, "logo_url": logo_url, "tvg_id": tvg_id}
elif current_info and line.startswith('http'):
lower_line = line.lower()
@ -216,7 +231,11 @@ def parse_m3u_file(file_path, account):
current_info = None
continue
defaults = {"logo_url": current_info["logo_url"]}
defaults = {
"logo_url": current_info["logo_url"],
"tvg_id": current_info.get("tvg_id", "")
}
try:
obj, created = Stream.objects.update_or_create(
name=current_info["name"],

View file

@ -1,5 +1,6 @@
import os
from pathlib import Path
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent
@ -26,6 +27,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
]
@ -38,6 +40,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
]
@ -109,3 +112,20 @@ MEDIA_URL = '/media/'
SERVER_IP = "127.0.0.1"
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True
if os.getenv('REACT_UI', False):
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
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
}

View file

@ -20,6 +20,16 @@ services:
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
- REACT_UI=true
ui:
image: alpine
container_name: dispatcharr_ui
volumes:
- ../frontend:/app
entrypoint: ["/bin/sh", "/app/entrypoint.sh"]
ports:
- 3031:3031
celery:
build:
@ -34,7 +44,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- POSTGRES_HOST=db
- POSTGRES_HOST=dispatcharr_db
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
@ -55,8 +65,8 @@ services:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
volumes:
- postgres_data:/var/lib/postgresql/data
# volumes:
# - postgres_data:/var/lib/postgresql/data
redis:
image: redis:latest

23
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
frontend/README.md Normal file
View file

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

6
frontend/entrypoint.sh Normal file
View file

@ -0,0 +1,6 @@
#!/bin/sh
apk add nodejs npm
cd /app/
npm i
PORT=3031 npm run start

View file

@ -0,0 +1,12 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
/** @type {import('eslint').Linter.Config[]} */
export default [
{files: ["**/*.{js,mjs,cjs,jsx}"]},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
pluginReact.configs.flat.recommended,
];

17132
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

50
frontend/package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"axios": "^1.7.9",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"material-react-table": "^3.2.0",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"format": "prettier --write ."
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View file

@ -0,0 +1,10 @@
// prettier.config.js or .prettierrc.js
module.exports = {
semi: true, // Add semicolons at the end of statements
singleQuote: true, // Use single quotes instead of double
tabWidth: 2, // Set the indentation width
trailingComma: "es5", // Add trailing commas where valid in ES5
printWidth: 80, // Wrap lines at 80 characters
bracketSpacing: true, // Add spaces inside object braces
arrowParens: "always", // Always include parentheses around arrow function parameters
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

38
frontend/src/App.css Normal file
View file

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

179
frontend/src/App.js Normal file
View file

@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import {
BrowserRouter as Router,
Route,
Routes,
Navigate,
} from 'react-router-dom';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import { ThemeProvider } from '@mui/material/styles'; // Import theme tools
import {
AppBar,
Toolbar,
Typography,
Box,
CssBaseline,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
Divider,
} from '@mui/material';
import theme from './theme';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import API from './api';
const drawerWidth = 240;
const miniDrawerWidth = 60;
const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const {
isAuthenticated,
setIsAuthenticated,
logout,
initData,
initializeAuth,
} = useAuthStore();
const toggleDrawer = () => {
setOpen(!open);
};
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
} else {
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
{/* <AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
width: `calc(100% - ${open ? drawerWidth : miniDrawerWidth}px)`,
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
}}
>
<Toolbar variant="dense"></Toolbar>
</AppBar> */}
<Drawer
variant="permanent"
open={open}
sx={{
width: open ? drawerWidth : miniDrawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? drawerWidth : miniDrawerWidth,
transition: 'width 0.3s',
overflowX: 'hidden',
},
}}
>
{/* Drawer Toggle Button */}
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton
onClick={toggleDrawer}
size="small"
sx={{
pt: 0,
pb: 0,
}}
>
<img src="/images/logo.png" width="33x" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
<Divider />
<Sidebar open />
</Drawer>
<Box
sx={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
// pt: '64px',
}}
>
{/* Fixed Header */}
{/* <Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
</Box> */}
{/* Main Content Area between Header and Footer */}
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Routes>
{isAuthenticated ? (
<>
<Route exact path="/channels" element={<Channels />} />
<Route exact path="/m3u" element={<M3U />} />
<Route exact path="/epg" element={<EPG />} />
<Route
exact
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route exact path="/guide" element={<Guide />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
{/* Redirect if no match */}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</Box>
</Router>
</ThemeProvider>
);
};
export default App;

8
frontend/src/App.test.js Normal file
View file

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

608
frontend/src/api.js Normal file
View file

@ -0,0 +1,608 @@
import Axios from 'axios';
import useAuthStore from './store/auth';
import useChannelsStore from './store/channels';
import useUserAgentsStore from './store/userAgents';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useStreamsStore from './store/streams';
import useStreamProfilesStore from './store/streamProfiles';
// const axios = Axios.create({
// withCredentials: true,
// });
const host = 'http://192.168.1.151:9191';
const getAuthToken = async () => {
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
return token;
};
export default class API {
static async login(username, password) {
const response = await fetch(`${host}/api/accounts/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
return await response.json();
}
static async refreshToken(refreshToken) {
const response = await fetch(`${host}/api/accounts/token/refresh/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: refreshToken }),
});
const retval = await response.json();
return retval;
}
static async logout() {
const response = await fetch(`${host}/api/accounts/auth/logout/`, {
method: 'POST',
});
return response.data.data;
}
static async getChannels() {
const response = await fetch(`${host}/api/channels/channels/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async getChannelGroups() {
const response = await fetch(`${host}/api/channels/groups/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async addChannelGroup(values) {
const response = await fetch(`${host}/api/channels/groups/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannelGroup(retval);
}
return retval;
}
static async updateChannelGroup(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/groups/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().updateChannelGroup(retval);
}
return retval;
}
static async addChannel(channel) {
let body = null;
if (channel.logo_file) {
body = new FormData();
for (const prop in channel) {
body.append(prop, channel[prop]);
}
} else {
body = { ...channel };
delete body.logo_file;
body = JSON.stringify(body);
}
const response = await fetch(`${host}/api/channels/channels/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
...(channel.logo_file
? {}
: {
'Content-Type': 'application/json',
}),
},
body: body,
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval);
}
return retval;
}
static async deleteChannel(id) {
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useChannelsStore.getState().removeChannels([id]);
}
// @TODO: the bulk delete endpoint is currently broken
// static async deleteChannels(channel_ids) {
// const response = await fetch(`${host}/api/channels/bulk-delete-channels/0/`, {
// method: 'DELETE',
// headers: {
// Authorization: `Bearer ${await getAuthToken()}`,
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({ channel_ids }),
// });
// useChannelsStore.getState().removeChannels(channel_ids)
// }
static async updateChannel(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().updateChannel(retval);
}
return retval;
}
static async assignChannelNumbers(ids) {
const response = await fetch(`${host}/api/channels/channels/assign/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel_order: ids }),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval);
}
return retval;
}
static async createChannelFromStream(values) {
const response = await fetch(`${host}/api/channels/channels/from-stream/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useChannelsStore.getState().addChannel(retval);
}
return retval;
}
static async getStreams() {
const response = await fetch(`${host}/api/channels/streams/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async addStream(values) {
const response = await fetch(`${host}/api/channels/streams/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useStreamsStore.getState().addStream(retval);
}
return retval;
}
static async updateStream(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useStreamsStore.getState().updateStream(retval);
}
return retval;
}
static async deleteStream(id) {
const response = await fetch(`${host}/api/channels/streams/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useStreamsStore.getState().removeStreams([id]);
}
static async deleteStreams(ids) {
const response = await fetch(`${host}/api/channels/streams/bulk-delete/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stream_ids: ids }),
});
useStreamsStore.getState().removeStreams(ids);
}
static async getUserAgents() {
const response = await fetch(`${host}/api/core/useragents/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async addUserAgent(values) {
const response = await fetch(`${host}/api/core/useragents/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useUserAgentsStore.getState().addUserAgent(retval);
}
return retval;
}
static async updateUserAgent(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useUserAgentsStore.getState().updateUserAgent(retval);
}
return retval;
}
static async deleteUserAgent(id) {
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useUserAgentsStore.getState().removeUserAgents([id]);
}
static async getPlaylists() {
const response = await fetch(`${host}/api/m3u/accounts/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async addPlaylist(values) {
const response = await fetch(`${host}/api/m3u/accounts/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
usePlaylistsStore.getState().addPlaylist(retval);
}
return retval;
}
static async refreshPlaylist(id) {
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async refreshAllPlaylist() {
const response = await fetch(`${host}/api/m3u/refresh/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async deletePlaylist(id) {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
usePlaylistsStore.getState().removePlaylists([id]);
}
static async updatePlaylist(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
usePlaylistsStore.getState().updatePlaylist(retval);
}
return retval;
}
static async getEPGs() {
const response = await fetch(`${host}/api/epg/sources/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async refreshPlaylist(id) {
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async addEPG(values) {
let body = null;
if (values.epg_file) {
body = new FormData();
for (const prop in values) {
body.append(prop, values[prop]);
}
} else {
body = { ...values };
delete body.epg_file;
body = JSON.stringify(body);
}
const response = await fetch(`${host}/api/epg/sources/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
...(values.epg_file
? {}
: {
'Content-Type': 'application/json',
}),
},
body,
});
const retval = await response.json();
if (retval.id) {
useEPGsStore.getState().addEPG(retval);
}
return retval;
}
static async deleteEPG(id) {
const response = await fetch(`${host}/api/epg/sources/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useEPGsStore.getState().removeEPGs([id]);
}
static async refreshEPG(id) {
const response = await fetch(`${host}/api/epg/import/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ id }),
});
const retval = await response.json();
return retval;
}
static async getStreamProfiles() {
const response = await fetch(`${host}/api/core/streamprofiles/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async addStreamProfile(values) {
const response = await fetch(`${host}/api/core/streamprofiles/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
const retval = await response.json();
if (retval.id) {
useStreamProfilesStore.getState().addStreamProfile(retval);
}
return retval;
}
static async updateStreamProfile(values) {
const { id, ...payload } = values;
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const retval = await response.json();
if (retval.id) {
useStreamProfilesStore.getState().updateStreamProfile(retval);
}
return retval;
}
static async deleteStreamProfile(id) {
const response = await fetch(`${host}/api/core/streamprofiles/${id}/`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
useStreamProfilesStore.getState().removeStreamProfiles([id]);
}
static async getGrid() {
const response = await fetch(`${host}/api/epg/grid/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
console.log(retval);
return retval;
}
}

View file

@ -0,0 +1,51 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import {
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
} from "@mui/material";
import {
Tv as TvIcon,
CalendarMonth as CalendarMonthIcon,
VideoFile as VideoFileIcon,
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
} from "@mui/icons-material";
const items = [
{ text: "Channels", icon: <TvIcon />, route: "/channels" },
{ text: "M3U", icon: <PlaylistPlayIcon />, route: "/m3u" },
{ text: "EPG", icon: <CalendarMonthIcon />, route: "/epg" },
{
text: "Stream Profiles",
icon: <VideoFileIcon />,
route: "/stream-profiles",
},
{ text: "TV Guide", icon: <LiveTvIcon />, route: "/guide" },
];
const Sidebar = ({ open }) => {
const location = useLocation();
return (
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>
);
};
export default Sidebar;

View file

@ -0,0 +1,474 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormHelperText,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logo, setLogo] = useState(null);
const [logoPreview, setLogoPreview] = useState('/images/logo.png');
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
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 = (e) => {
const file = e.target.files[0];
if (file) {
setLogo(file);
setLogoPreview(URL.createObjectURL(file));
}
};
const formik = useFormik({
initialValues: {
channel_name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '',
tvg_id: '',
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
if (channel?.id) {
await API.updateChannel({
id: channel.id,
...values,
logo_file: logo,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logo,
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogo(null);
setLogoPreview('/images/logo.png');
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id,
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
console.log('channel streams');
console.log(channel.streams);
const filteredStreams = streams
.filter((stream) => channel.streams.includes(stream.id))
.sort(
(a, b) =>
channel.streams.indexOf(a.id) - channel.streams.indexOf(b.id)
);
console.log('filtered streams');
console.log(filteredStreams);
setChannelStreams(filteredStreams);
} else {
formik.resetForm();
}
}, [channel]);
const activeStreamsTable = useMaterialReactTable({
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
],
[]
),
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>
</>
),
muiTableContainerProps: {
sx: {
height: '200px',
},
},
muiRowDragHandleProps: ({ 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 = useMaterialReactTable({
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',
muiTableContainerProps: {
sx: {
height: '200px',
},
},
});
if (!isOpen) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Channel
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<TextField
fullWidth
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_name &&
Boolean(formik.errors.channel_name)
}
helperText={
formik.touched.channel_name && formik.errors.channel_name
}
variant="standard"
/>
<Grid2
container
spacing={1}
sx={{
alignItems: 'center',
}}
>
<Grid2 size={11}>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">
Channel Group
</InputLabel>
<Select
labelId="channel-group-label"
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_group_id &&
Boolean(formik.errors.channel_group_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
variant="standard"
>
{channelGroups.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
<FormHelperText sx={{ color: 'error.main' }}>
{formik.touched.channel_group_id &&
formik.errors.channel_group_id}
</FormHelperText>
</FormControl>
</Grid2>
<Grid2 size={1}>
<IconButton
color="success"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="filled"
>
<AddIcon fontSize="small" />
</IconButton>
</Grid2>
</Grid2>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_number &&
Boolean(formik.errors.channel_number)
}
helperText={
formik.touched.channel_number &&
formik.errors.channel_number
}
variant="standard"
/>
</Grid2>
<Grid2 size={6}>
<TextField
fullWidth
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
}
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
variant="standard"
/>
<TextField
fullWidth
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<Box mt={2} mb={2}>
{/* File upload input */}
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
}}
>
<Typography>Logo</Typography>
{/* Display selected image */}
<Box mb={2}>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Stack>
</Box>
</Grid2>
</Grid2>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<Typography>Active Streams</Typography>
<MaterialReactTable table={activeStreamsTable} />
</Grid2>
<Grid2 size={6}>
<Typography>Available Streams</Typography>
<MaterialReactTable table={availableStreamsTable} />
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

@ -0,0 +1,94 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Channel Group
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default ChannelGroup;

View file

@ -0,0 +1,180 @@
// Modal.js
import React, { useState, useEffect } from "react";
import {
Box,
Modal,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useEPGsStore from "../../store/epgs";
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: "",
source_type: "",
url: "",
api_key: "",
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
source_type: Yup.string().required("Source type is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (epg) {
formik.setValues({
name: epg.name,
source_type: epg.source_type,
url: epg.url,
api_key: epg.api_key,
is_active: epg.is_active,
});
} else {
formik.resetForm();
}
}, [epg]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
EPG Source
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<TextField
fullWidth
id="api_key"
name="api_key"
label="API Key"
value={formik.values.api_key}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
helperText={formik.touched.api_key && formik.errors.api_key}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="source-type-label">Source Type</InputLabel>
<Select
labelId="source-type-label"
id="source_type"
name="source_type"
label="Source Type"
value={formik.values.source_type}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
helperText={
formik.touched.source_type && formik.errors.source_type
}
variant="standard"
>
<MenuItem key="0" value="xmltv">
XMLTV
</MenuItem>
<MenuItem key="1" value="schedules_direct">
Schedules Direct
</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default EPG;

View file

@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Box,
TextField,
Button,
Typography,
Grid2,
Paper,
} from '@mui/material';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
// // Handle form submission
// const handleSubmit = async (e) => {
// e.preventDefault();
// setLoading(true);
// setError(''); // Reset error on each new submission
// await login(username, password)
// navigate('/channels'); // Or any other route you'd like
// };
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f5f5f5',
}}
>
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" align="center" gutterBottom>
Login
</Typography>
<form onSubmit={handleSubmit}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 item xs={12}>
<TextField
label="Username"
variant="standard"
fullWidth
name="username"
value={formData.username}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<TextField
label="Password"
variant="standard"
type="password"
fullWidth
name="password"
value={formData.password}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 item xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Submit
</Button>
</Grid2>
</Grid2>
</form>
</Paper>
</Box>
);
};
export default LoginForm;

View file

@ -0,0 +1,227 @@
// Modal.js
import React, { useState, useEffect } from "react";
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: "",
server_url: "",
max_streams: 0,
user_agent: "",
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
user_agent: Yup.string().required("User-Agent is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (playlist) {
formik.setValues({
name: playlist.name,
server_url: playlist.server_url,
max_streams: playlist.max_streams,
user_agent: playlist.user_agent,
is_active: playlist.is_active,
});
} else {
formik.resetForm();
}
}, [playlist]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
M3U Account
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="server_url"
name="server_url"
label="URL"
value={formik.values.server_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
variant="standard"
/>
<Box mb={2}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "center",
pt: 2,
}}
>
<Typography>File</Typography>
<input
type="file"
id="uploaded_file"
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: "none" }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
Browse...
</Button>
</label>
</Stack>
</Box>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue("is_active", e.target.checked)
}
/>
}
label="Is Active"
/>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default M3U;

View file

@ -0,0 +1,151 @@
// Modal.js
import React, { useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const formik = useFormik({
initialValues: {
name: '',
url: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
stream_profile_id: Yup.string().required('Stream profile is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
stream_profile_id: stream.stream_profile_id,
});
} else {
formik.resetForm();
}
}, [stream]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Stream
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={12}>
<TextField
fullWidth
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default Stream;

View file

@ -0,0 +1,165 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: "",
command: "",
parameters: "",
is_active: true,
user_agent: "",
},
validationSchema: Yup.object({
profile_name: Yup.string().required("Name is required"),
command: Yup.string().required("Command is required"),
parameters: Yup.string().required("Parameters are is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
} else {
formik.resetForm();
}
}, [profile]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Stream Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="profile_name"
name="profile_name"
label="Name"
value={formik.values.profile_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.profile_name && Boolean(formik.errors.profile_name)
}
helperText={
formik.touched.profile_name && formik.errors.profile_name
}
variant="standard"
/>
<TextField
fullWidth
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.command && Boolean(formik.errors.command)}
helperText={formik.touched.command && formik.errors.command}
variant="standard"
/>
<TextField
fullWidth
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.parameters && Boolean(formik.errors.parameters)
}
helperText={formik.touched.parameters && formik.errors.parameters}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">User-Agent</InputLabel>
<Select
labelId="channel-group-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default StreamProfile;

View file

@ -0,0 +1,143 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: "",
user_agent: "",
description: "",
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required("Name is required"),
user_agent: Yup.string().required("User-Agent is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
} else {
formik.resetForm();
}
}, [userAgent]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
User-Agent
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="user_agent_name"
name="user_agent_name"
label="Name"
value={formik.values.user_agent_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
helperText={
formik.touched.user_agent_name && formik.errors.user_agent_name
}
variant="standard"
/>
<TextField
fullWidth
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
/>
<TextField
fullWidth
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.description && Boolean(formik.errors.description)
}
helperText={formik.touched.description && formik.errors.description}
variant="standard"
/>
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
</DialogContent>
<DialogActions>
<Button
size="small"
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default UserAgent;

View file

@ -0,0 +1,327 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
Tooltip,
IconButton,
Button,
ButtonGroup,
Snackbar,
Popover,
TextField,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import { ContentCopy } from '@mui/icons-material';
const Example = () => {
const [channel, setChannel] = useState(null);
const [channelModelOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { channels, isLoading: channelsLoading } = useChannelsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
},
{
header: 'Logo',
accessorKey: 'logo_url',
size: 50,
cell: (info) => (
<Grid2
container
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={info.getValue() || '/images/logo.png'} width="20" />
</Grid2>
),
meta: {
filterVariant: null,
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const editChannel = async (channel = null) => {
setChannel(channel);
setChannelModalOpen(true);
};
const deleteChannel = async (id) => {
await API.deleteChannel(id);
};
// @TODO: the bulk delete endpoint is currently broken
const deleteChannels = async () => {
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => {
return deleteChannel(chan.original.id);
})
);
setIsLoading(false);
};
const assignChannels = async () => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.assignChannelNumbers(selected.map((sel) => sel.id));
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const closePopover = () => {
setAnchorEl(null);
setSnackbarMessage('');
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy);
setSnackbarMessage('Copied!');
} catch (err) {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true);
};
const open = Boolean(anchorEl);
const copyM3UUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('m3u url');
};
const copyEPGUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('epg url');
};
const copyHDHRUrl = async (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy('hdhr url');
};
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: channels,
// enableGlobalFilterModes: true,
enablePagination: false,
// enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editChannel(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteChannel(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 90px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
},
},
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<ButtonGroup
sx={{
marginLeft: 1,
}}
>
<Button variant="contained" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" onClick={copyEPGUrl}>
EPG
</Button>
</ButtonGroup>
</Stack>
),
});
return (
<Box>
<MaterialReactTable table={table} />
<ChannelForm
channel={channel}
isOpen={channelModelOpen}
onClose={() => setChannelModalOpen(false)}
/>
<Popover
open={open}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<div style={{ padding: '16px', display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
variant="standard"
disabled
size="small"
sx={{ marginRight: 1 }}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />
</IconButton>
</div>
{/* {copySuccess && <Typography variant="caption" sx={{ paddingLeft: 2 }}>{copySuccess}</Typography>} */}
</Popover>
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default Example;

View file

@ -0,0 +1,201 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
Snackbar,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import useEPGsStore from "../../store/epgs";
import EPGForm from "../forms/EPG";
import { TableHelper } from "../../helpers";
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarOpen, setSnackbarOpen] = useState(false);
const epgs = useEPGsStore((state) => state.epgs);
const columns = useMemo(
//column definitions...
() => [
{
header: "Name",
size: 10,
accessorKey: "name",
},
{
header: "Source Type",
accessorKey: "source_type",
size: 50,
},
{
header: "URL / API Key",
accessorKey: "max_streams",
},
],
[],
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const editEPG = async (epg = null) => {
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
await API.deleteEPG(id);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
setSnackbarMessage("EPG refresh initiated");
setSnackbarOpen(true);
};
useEffect(() => {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => editEPG(row.original)}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: "calc(42vh - 0px)",
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>EPGs</Typography>
<Tooltip title="Add New EPG">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editEPG()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 2,
}}
>
<MaterialReactTable table={table} />
<EPGForm
epg={epg}
isOpen={epgModalOpen}
onClose={() => setEPGModalOpen(false)}
/>
<Snackbar
anchorOrigin={{ vertical: "top", horizontal: "right" }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default EPGsTable;

View file

@ -0,0 +1,234 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import usePlaylistsStore from "../../store/playlists";
import M3UForm from "../forms/M3U";
import { TableHelper } from "../../helpers";
const Example = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const playlists = usePlaylistsStore((state) => state.playlists);
const columns = useMemo(
//column definitions...
() => [
{
header: "Name",
accessorKey: "name",
},
{
header: "URL / File",
accessorKey: "server_url",
},
{
header: "Max Streams",
accessorKey: "max_streams",
size: 200,
},
{
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: "basic",
muiTableBodyCellProps: {
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (!activeFilterValue) return true; // Show all if no filter
return String(row.getValue("is_active")) === activeFilterValue;
},
},
],
[],
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const refreshPlaylist = async (id) => {
await API.refreshPlaylist(id);
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
};
const deletePlaylists = async (ids) => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
// await API.deleteStreams(selected.map(stream => stream.original.id))
};
useEffect(() => {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: playlists,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editPlaylist(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deletePlaylist(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
variant="contained"
onClick={() => refreshPlaylist(row.original.id)}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: "calc(42vh - 0px)",
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editPlaylist()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 2,
}}
>
<MaterialReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
/>
</Box>
);
};
export default Example;

View file

@ -0,0 +1,217 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from "@mui/icons-material";
import useEPGsStore from "../../store/epgs";
import StreamProfileForm from "../forms/StreamProfile";
import useStreamProfilesStore from "../../store/streamProfiles";
import { TableHelper } from "../../helpers";
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const columns = useMemo(
//column definitions...
() => [
{
header: "Name",
accessorKey: "profile_name",
},
{
header: "Command",
accessorKey: "command",
},
{
header: "Parameters",
accessorKey: "parameters",
},
{
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: "basic",
muiTableBodyCellProps: {
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, filterValue) => {
if (filterValue == "all") return true; // Show all if no filter
return String(row.getValue("is_active")) === filterValue;
},
},
],
[],
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editStreamProfile = async (profile = null) => {
setProfile(profile);
setProfileModalOpen(true);
};
const deleteStreamProfile = async (ids) => {
await API.deleteStreamProfile(ids);
};
useEffect(() => {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streamProfiles,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => editStreamProfile(row.original)}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteStreamProfile(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: "calc(100vh - 100px)", // Subtract padding to avoid cutoff
overflowY: "auto", // Internal scrolling for the table
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>Stream Profiles</Typography>
<Tooltip title="Add New Stream Profile">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editStreamProfile()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 2,
}}
>
<MaterialReactTable table={table} />
<StreamProfileForm
profile={profile}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
</Box>
);
};
export default StreamProfiles;

View file

@ -0,0 +1,234 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const Example = () => {
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const { streams, isLoading: streamsLoading } = useStreamsStore();
const { playlists } = usePlaylistsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Group',
accessorKey: 'group_name',
},
{
header: 'M3U',
size: 100,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[playlists]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: 0,
stream_id: stream.id,
});
};
// @TODO: bulk create is broken, returning a 404
const createChannelsFromStreams = async () => {
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((stream) => () => {
return createChannelFromStream(stream.original);
})
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id);
};
const deleteStreams = async () => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.deleteStreams(selected.map((stream) => stream.original.id));
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streams,
// enableGlobalFilterModes: true,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || streamsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<Tooltip
title={
row.original.m3u_account ? 'M3U streams locked' : 'Edit Stream'
}
>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteStream(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
onClick={() => createChannelFromStream(row.original)}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 90px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
variant="contained"
onClick={deleteStreams}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
// disabled={rowSelection.length === 0}
sx={{
marginLeft: 1,
}}
>
Create Channels
</Button>
</Stack>
),
});
return (
<Box
sx={
{
// paddingTop: 2,
// paddingLeft: 1,
// paddingRight: 2,
// paddingBottom: 2,
}
}
>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
</Box>
);
};
export default Example;

View file

@ -0,0 +1,219 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from "material-react-table";
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from "@mui/material";
import API from "../../api";
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import useUserAgentsStore from "../../store/userAgents";
import UserAgentForm from "../forms/UserAgent";
import { TableHelper } from "../../helpers";
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState("all");
const userAgents = useUserAgentsStore((state) => state.userAgents);
const columns = useMemo(
//column definitions...
() => [
{
header: "Name",
size: 10,
accessorKey: "user_agent_name",
},
{
header: "User-Agent",
accessorKey: "user_agent",
size: 50,
},
{
header: "Desecription",
accessorKey: "description",
},
{
header: "Active",
accessorKey: "is_active",
size: 100,
sortingFn: "basic",
muiTableBodyCellProps: {
align: "left",
},
Cell: ({ cell }) => (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == "all") return true; // Show all if no filter
return String(row.getValue("is_active")) === activeFilterValue;
},
},
],
[],
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
await API.deleteUserAgents(ids);
} else {
await API.deleteUserAgent(ids);
}
};
useEffect(() => {
if (typeof window !== "undefined") {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: "compact",
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: "calc(42vh - 10px)",
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: "center",
}}
>
<Typography>User-Agents</Typography>
<Tooltip title="Add New User Agent">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editUserAgent()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<>
<Box
sx={{
padding: 2,
}}
>
<MaterialReactTable table={table} />
</Box>
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={() => setUserAgentModalOpen(false)}
/>
</>
);
};
export default UserAgentsTable;

View file

@ -0,0 +1,3 @@
import table from "./table";
export const TableHelper = table;

View file

@ -0,0 +1,14 @@
export default {
defaultProperties: {
enableBottomToolbar: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: "none",
columnFilterDisplayMode: "popover",
enableRowNumbers: false,
positionActionsColumn: "last",
initialState: {
density: "compact",
},
},
};

13
frontend/src/index.css Normal file
View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

14
frontend/src/index.js Normal file
View file

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client'; // Import the "react-dom/client" for React 18
import './index.css'; // Optional styles
import App from './App'; // Import your App component
// Create a root element
const root = ReactDOM.createRoot(document.getElementById('root'));
// Render your app using the "root.render" method
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1
frontend/src/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,43 @@
import React from "react";
import ChannelsTable from "../components/tables/ChannelsTable";
import StreamsTable from "../components/tables/StreamsTable";
import { Grid2, Box } from "@mui/material";
const ChannelsPage = () => {
return (
<Grid2 container>
<Grid2 size={6}>
<Box
sx={{
height: "100vh", // Full viewport height
paddingTop: "20px", // Top padding
paddingBottom: "20px", // Bottom padding
paddingRight: "10px",
paddingLeft: "20px",
boxSizing: "border-box", // Include padding in height calculation
overflow: "hidden", // Prevent parent scrolling
}}
>
<ChannelsTable />
</Box>
</Grid2>
<Grid2 size={6}>
<Box
sx={{
height: "100vh", // Full viewport height
paddingTop: "20px", // Top padding
paddingBottom: "20px", // Bottom padding
paddingRight: "20px",
paddingLeft: "10px",
boxSizing: "border-box", // Include padding in height calculation
overflow: "hidden", // Prevent parent scrolling
}}
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
);
};
export default ChannelsPage;

View file

@ -0,0 +1,27 @@
// src/components/Dashboard.js
import React, { useState } from "react";
const Dashboard = () => {
const [newStream, setNewStream] = useState("");
return (
<div>
<h1>Dashboard Page</h1>
<input
type="text"
value={newStream}
onChange={(e) => setNewStream(e.target.value)}
placeholder="Enter Stream"
/>
<h3>Streams:</h3>
<ul>
{state.streams.map((stream, index) => (
<li key={index}>{stream}</li>
))}
</ul>
</div>
);
};
export default Dashboard;

27
frontend/src/pages/EPG.js Normal file
View file

@ -0,0 +1,27 @@
import React from "react";
import { Box } from "@mui/material";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import EPGsTable from "../components/tables/EPGsTable";
const EPGPage = () => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<EPGsTable />
</Box>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
</Box>
);
};
export default EPGPage;

View file

@ -0,0 +1,91 @@
import React from 'react';
import { useEpg, Epg, Layout } from 'planby';
import API from '../api';
function App() {
const [channels, setChannels] = React.useState([]);
const [epg, setEpg] = React.useState([]);
const fetchChannels = async () => {
const channels = await API.getChannels();
const retval = [];
for (const channel of channels) {
if (!channel.tvg_id) {
continue;
}
console.log(channel);
retval.push({
uuid: channel.tvg_id,
type: 'channel',
title: channel.channel_name,
country: 'USA',
provider: channel.channel_group?.name || 'Default',
logo: channel.logo_url || '/images/logo.png',
year: 2025,
});
}
setChannels(retval);
return retval;
};
const fetchEpg = async () => {
const programs = await API.getGrid();
const retval = [];
console.log(programs);
for (const program of programs.data) {
retval.push({
id: program.id,
channelUuid: 'Nickelodeon (East).us',
description: program.description,
title: program.title,
since: program.start_time,
till: program.end_time,
});
}
setEpg(retval);
return retval;
};
const fetchData = async () => {
const channels = await fetchChannels();
const epg = await fetchEpg();
setChannels(channels);
setEpg(epg);
};
if (channels.length === 0) {
fetchData();
}
const formatDate = (date) => date.toISOString().split('T')[0] + 'T00:00:00';
const today = new Date();
const tomorrow = new Date(today);
const {
getEpgProps,
getLayoutProps,
onScrollToNow,
onScrollLeft,
onScrollRight,
} = useEpg({
epg,
channels,
startDate: '2025-02-25T11:00:00', // or 2022-02-02T00:00:00
width: '100%',
height: 600,
});
return (
<div>
<Epg {...getEpgProps()}>
<Layout {...getLayoutProps()} />
</Epg>
</div>
);
}
export default App;

View file

@ -0,0 +1,14 @@
// src/components/Home.js
import React, { useState } from "react";
const Home = () => {
const [newChannel, setNewChannel] = useState("");
return (
<div>
<h1>Home Page</h1>
</div>
);
};
export default Home;

View file

@ -0,0 +1,8 @@
import React from "react";
import LoginForm from "../components/forms/LoginForm";
const Login = () => {
return <LoginForm />;
};
export default Login;

72
frontend/src/pages/M3U.js Normal file
View file

@ -0,0 +1,72 @@
import React, { useState } from "react";
import useUserAgentsStore from "../store/userAgents";
import { Box } from "@mui/material";
import M3UsTable from "../components/tables/M3UsTable";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import usePlaylistsStore from "../store/playlists";
import API from "../api";
import M3UForm from "../components/forms/M3U";
const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
const error = useUserAgentsStore((state) => state.error);
const playlists = usePlaylistsStore((state) => state.playlists);
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
await API.deleteUserAgents(ids);
} else {
await API.deleteUserAgent(ids);
}
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<M3UsTable />
</Box>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
/>
</Box>
);
};
export default M3UPage;

View file

@ -0,0 +1,8 @@
import React from "react";
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
const StreamProfilesPage = () => {
return <StreamProfilesTable />;
};
export default StreamProfilesPage;

View file

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

131
frontend/src/store/auth.js Normal file
View file

@ -0,0 +1,131 @@
import { create } from 'zustand';
import API from '../api';
import useChannelsStore from './channels';
import useStreamsStore from './streams';
import useUserAgentsStore from './userAgents';
import usePlaylistsStore from './playlists';
import useEPGsStore from './epgs';
import useStreamProfilesStore from './streamProfiles';
const decodeToken = (token) => {
if (!token) return null;
const payload = token.split('.')[1];
const decodedPayload = JSON.parse(atob(payload));
return decodedPayload.exp;
};
const isTokenExpired = (expirationTime) => {
const now = Math.floor(Date.now() / 1000);
return now >= expirationTime;
};
const useAuthStore = create((set, get) => ({
accessToken: localStorage.getItem('accessToken') || null,
refreshToken: localStorage.getItem('refreshToken') || null,
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
isAuthenticated: false,
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
initData: async () => {
console.log('fetching data');
await Promise.all([
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useStreamsStore.getState().fetchStreams(),
useUserAgentsStore.getState().fetchUserAgents(),
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),
useStreamProfilesStore.getState().fetchProfiles(),
]);
},
getToken: async () => {
const expiration = localStorage.getItem('tokenExpiration');
const tokenExpiration = localStorage.getItem('tokenExpiration');
let accessToken = null;
if (isTokenExpired(tokenExpiration)) {
accessToken = await get().refreshToken();
} else {
accessToken = localStorage.getItem('accessToken');
}
return accessToken;
},
// Action to login
login: async ({ username, password }) => {
try {
const response = await API.login(username, password);
if (response.access) {
const expiration = decodeToken(response.access);
set({
accessToken: response.access,
refreshToken: response.refresh,
tokenExpiration: expiration, // 1 hour from now
isAuthenticated: true,
});
// Store in localStorage
localStorage.setItem('accessToken', response.access);
localStorage.setItem('refreshToken', response.refresh);
localStorage.setItem('tokenExpiration', expiration);
}
} catch (error) {
console.error('Login failed:', error);
}
},
// Action to refresh the token
refreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return;
try {
const data = await API.refreshToken(refreshToken);
if (data.access) {
set({
accessToken: data.access,
tokenExpiration: decodeToken(data.access),
isAuthenticated: true,
});
localStorage.setItem('accessToken', data.access);
localStorage.setItem('tokenExpiration', decodeToken(data.access));
return true;
}
} catch (error) {
console.error('Token refresh failed:', error);
get().logout();
}
return false;
},
// Action to logout
logout: () => {
set({
accessToken: null,
refreshToken: null,
tokenExpiration: null,
isAuthenticated: false,
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tokenExpiration');
},
initializeAuth: async () => {
const refreshToken = localStorage.getItem('refreshToken') || null;
if (refreshToken) {
const loggedIn = await get().refreshToken();
if (loggedIn) {
return true;
}
}
return false;
},
}));
export default useAuthStore;

View file

@ -0,0 +1,64 @@
import { create } from "zustand";
import api from "../api";
const useChannelsStore = create((set) => ({
channels: [],
channelGroups: [],
isLoading: false,
error: null,
fetchChannels: async () => {
set({ isLoading: true, error: null });
try {
const channels = await api.getChannels();
set({ channels: channels, isLoading: false });
} catch (error) {
console.error("Failed to fetch channels:", error);
set({ error: "Failed to load channels.", isLoading: false });
}
},
fetchChannelGroups: async () => {
set({ isLoading: true, error: null });
try {
const channelGroups = await api.getChannelGroups();
set({ channelGroups: channelGroups, isLoading: false });
} catch (error) {
console.error("Failed to fetch channel groups:", error);
set({ error: "Failed to load channel groups.", isLoading: false });
}
},
addChannel: (newChannel) =>
set((state) => ({
channels: [...state.channels, newChannel],
})),
updateChannel: (userAgent) =>
set((state) => ({
channels: state.channels.map((chan) =>
chan.id === userAgent.id ? userAgent : chan,
),
})),
removeChannels: (channelIds) =>
set((state) => ({
channels: state.channels.filter(
(channel) => !channelIds.includes(channel.id),
),
})),
addChannelGroup: (newChannelGroup) =>
set((state) => ({
channelGroups: [...state.channelGroups, newChannelGroup],
})),
updateChannelGroup: (channelGroup) =>
set((state) => ({
channelGroups: state.channelGroups.map((group) =>
group.id === channelGroup.id ? channelGroup : group,
),
})),
}));
export default useChannelsStore;

View file

@ -0,0 +1,31 @@
import { create } from "zustand";
import api from "../api";
const useEPGsStore = create((set) => ({
epgs: [],
isLoading: false,
error: null,
fetchEPGs: async () => {
set({ isLoading: true, error: null });
try {
const epgs = await api.getEPGs();
set({ epgs: epgs, isLoading: false });
} catch (error) {
console.error("Failed to fetch epgs:", error);
set({ error: "Failed to load epgs.", isLoading: false });
}
},
addEPG: (newPlaylist) =>
set((state) => ({
epgs: [...state.epgs, newPlaylist],
})),
removeEPGs: (epgIds) =>
set((state) => ({
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
})),
}));
export default useEPGsStore;

View file

@ -0,0 +1,40 @@
import { create } from "zustand";
import api from "../api";
const usePlaylistsStore = create((set) => ({
playlists: [],
isLoading: false,
error: null,
fetchPlaylists: async () => {
set({ isLoading: true, error: null });
try {
const playlists = await api.getPlaylists();
set({ playlists: playlists, isLoading: false });
} catch (error) {
console.error("Failed to fetch playlists:", error);
set({ error: "Failed to load playlists.", isLoading: false });
}
},
addPlaylist: (newPlaylist) =>
set((state) => ({
playlists: [...state.playlists, newPlaylist],
})),
updatePlaylist: (playlist) =>
set((state) => ({
playlists: state.playlists.map((pl) =>
pl.id === playlist.id ? playlist : pl,
),
})),
removePlaylists: (playlistIds) =>
set((state) => ({
playlists: state.playlists.filter(
(playlist) => !playlistIds.includes(playlist.id),
),
})),
}));
export default usePlaylistsStore;

View file

@ -0,0 +1,40 @@
import { create } from 'zustand';
import api from '../api';
const useStreamProfilesStore = create((set) => ({
profiles: [],
isLoading: false,
error: null,
fetchProfiles: async () => {
set({ isLoading: true, error: null });
try {
const profiles = await api.getStreamProfiles();
set({ profiles: profiles, isLoading: false });
} catch (error) {
console.error('Failed to fetch profiles:', error);
set({ error: 'Failed to load profiles.', isLoading: false });
}
},
addStreamProfile: (profile) =>
set((state) => ({
profiles: [...state.profiles, profile],
})),
updateStreamProfile: (profile) =>
set((state) => ({
profiles: state.profiles.map((prof) =>
prof.id === profile.id ? profile : prof
),
})),
removeStreamProfiles: (propfileIds) =>
set((state) => ({
profiles: state.profiles.filter(
(profile) => !propfileIds.includes(profile.id)
),
})),
}));
export default useStreamProfilesStore;

View file

@ -0,0 +1,36 @@
import { create } from "zustand";
import api from "../api";
const useStreamsStore = create((set) => ({
streams: [],
isLoading: false,
error: null,
fetchStreams: async () => {
set({ isLoading: true, error: null });
try {
const streams = await api.getStreams();
set({ streams: streams, isLoading: false });
} catch (error) {
console.error("Failed to fetch streams:", error);
set({ error: "Failed to load streams.", isLoading: false });
}
},
addStream: (stream) =>
set((state) => ({
streams: [...state.streams, stream],
})),
updateStream: (stream) =>
set((state) => ({
streams: state.streams.map((st) => (st.id === stream.id ? stream : st)),
})),
removeStreams: (streamIds) =>
set((state) => ({
streams: state.streams.filter((stream) => !streamIds.includes(stream.id)),
})),
}));
export default useStreamsStore;

View file

@ -0,0 +1,40 @@
import { create } from "zustand";
import api from "../api";
const useUserAgentsStore = create((set) => ({
userAgents: [],
isLoading: false,
error: null,
fetchUserAgents: async () => {
set({ isLoading: true, error: null });
try {
const userAgents = await api.getUserAgents();
set({ userAgents: userAgents, isLoading: false });
} catch (error) {
console.error("Failed to fetch userAgents:", error);
set({ error: "Failed to load userAgents.", isLoading: false });
}
},
addUserAgent: (userAgent) =>
set((state) => ({
userAgents: [...state.userAgents, userAgent],
})),
updateUserAgent: (userAgent) =>
set((state) => ({
userAgents: state.userAgents.map((ua) =>
ua.id === userAgent.id ? userAgent : ua,
),
})),
removeUserAgents: (userAgentIds) =>
set((state) => ({
userAgents: state.userAgents.filter(
(userAgent) => !userAgentIds.includes(userAgent.id),
),
})),
}));
export default useUserAgentsStore;

21
frontend/src/theme.js Normal file
View file

@ -0,0 +1,21 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#495057',
contrastText: '#ffffff', // Ensure text is visible on primary color
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
// textTransform: 'none', // Disable uppercase on buttons
},
},
},
},
});
export default theme;

38
frontend/src/utils.js Normal file
View file

@ -0,0 +1,38 @@
export default {
Limiter: (concurrency, promiseList) => {
if (!promiseList || promiseList.length === 0) {
return Promise.resolve([]); // Return a resolved empty array if no promises
}
let index = 0; // Keeps track of the current promise to be processed
const results = []; // Stores the results of all promises
const totalPromises = promiseList.length;
// Helper function to process promises one by one, respecting concurrency
const processNext = () => {
// If we've processed all promises, resolve with the results
if (index >= totalPromises) {
return Promise.all(results);
}
// Execute the current promise and store the result
const currentPromise = promiseList[index]();
results.push(currentPromise);
// Once the current promise resolves, move on to the next one
return currentPromise.then(() => {
index++; // Move to the next promise
return processNext(); // Process the next promise
});
};
// Start processing promises up to the given concurrency
const concurrencyPromises = [];
for (let i = 0; i < concurrency && i < totalPromises; i++) {
concurrencyPromises.push(processNext());
}
// Wait for all promises to resolve
return Promise.all(concurrencyPromises).then(() => results);
}
}

View file

@ -12,3 +12,5 @@ streamlink
python-vlc
yt-dlp
gevent==24.11.1
django-cors-headers
djangorestframework-simplejwt