mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
m3u profiles, style changes, etc
This commit is contained in:
parent
57432748a4
commit
6ae80f4368
17 changed files with 874 additions and 123 deletions
|
|
@ -2,13 +2,13 @@ import os
|
|||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
|
||||
import re
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse, HttpResponseServerError
|
||||
from django.db.models import F
|
||||
from django.db import transaction
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.channels.models import Channel, Stream
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from core.models import StreamProfile
|
||||
|
||||
# Configure logging to output to the console.
|
||||
|
|
@ -41,50 +41,89 @@ def stream_view(request, stream_id):
|
|||
stream = channel.streams.first()
|
||||
logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name)
|
||||
|
||||
# Retrieve m3u account to determine number of streams and profiles
|
||||
m3u_account = stream.m3u_account
|
||||
logger.debug(f"Using M3U account ID={m3u_account.id}, Name={m3u_account.name}")
|
||||
|
||||
# Use the custom URL if available; otherwise, use the standard URL.
|
||||
input_url = stream.custom_url or stream.url
|
||||
logger.debug("Input URL: %s", input_url)
|
||||
|
||||
# Determine which profile we can use
|
||||
m3u_profiles = m3u_account.profiles.all()
|
||||
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
|
||||
|
||||
# Get the remaining objects
|
||||
profiles = [obj for obj in m3u_profiles if not obj.is_default]
|
||||
|
||||
active_profile = None
|
||||
for profile in [default_profile] + profiles:
|
||||
if not profile.is_active:
|
||||
continue
|
||||
if profile.current_viewers < profile.max_streams:
|
||||
logger.debug(f"Using M3U profile ID={profile.id}")
|
||||
active_profile = M3UAccountProfile.objects.get(id=profile.id)
|
||||
logger.debug("Executing the following pattern replacement:")
|
||||
logger.debug(f" search: {profile.search_pattern}")
|
||||
# Convert $1 to \1 for Python regex
|
||||
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
|
||||
logger.debug(f" replace: {profile.replace_pattern}")
|
||||
logger.debug(f" safe replace: {safe_replace_pattern}")
|
||||
stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
|
||||
logger.debug(f"Generated stream url: {stream_url}")
|
||||
break
|
||||
|
||||
if active_profile is None:
|
||||
logger.exception("No available profiles for the stream")
|
||||
return HttpResponseServerError("No available profiles for the stream")
|
||||
|
||||
# Get the stream profile set on the channel.
|
||||
# (Ensure your Channel model has a 'stream_profile' field.)
|
||||
profile = channel.stream_profile
|
||||
if not profile:
|
||||
stream_profile = channel.stream_profile
|
||||
if not stream_profile:
|
||||
logger.error("No stream profile set for channel ID=%s", channel.id)
|
||||
return HttpResponseServerError("No stream profile set for this channel.")
|
||||
logger.debug("Stream profile used: %s", profile.profile_name)
|
||||
logger.debug("Stream profile used: %s", stream_profile.profile_name)
|
||||
|
||||
# Determine the user agent to use.
|
||||
user_agent = profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
|
||||
user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
|
||||
logger.debug("User agent: %s", user_agent)
|
||||
|
||||
# Substitute placeholders in the parameters template.
|
||||
parameters = profile.parameters.format(userAgent=user_agent, streamUrl=input_url)
|
||||
parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
|
||||
logger.debug("Formatted parameters: %s", parameters)
|
||||
|
||||
# Build the final command.
|
||||
cmd = [profile.command] + parameters.split()
|
||||
cmd = [stream_profile.command] + parameters.split()
|
||||
logger.debug("Executing command: %s", cmd)
|
||||
|
||||
# Increment the viewer count.
|
||||
Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1)
|
||||
logger.debug("Viewer count incremented for stream ID=%s", stream.id)
|
||||
# Transactional block to ensure atomic viewer count updates.
|
||||
with transaction.atomic():
|
||||
# Increment the viewer count.
|
||||
active_profile.current_viewers += 1
|
||||
active_profile.save()
|
||||
logger.debug("Viewer count incremented for stream ID=%s", stream.id)
|
||||
|
||||
# Start the streaming process.
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
# Start the streaming process.
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def stream_generator(proc, s):
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
# Decrement the viewer count once streaming ends.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
active_profile.current_viewers -= 1
|
||||
active_profile.save()
|
||||
logger.debug("Viewer count decremented for stream ID=%s", s.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating viewer count for stream {s.id}: {e}")
|
||||
|
||||
return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
|
||||
except Exception as e:
|
||||
logger.exception("Error starting stream for channel ID=%s", stream_id)
|
||||
return HttpResponseServerError(f"Error starting stream: {e}")
|
||||
|
||||
def stream_generator(proc, s):
|
||||
try:
|
||||
while True:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
# Decrement the viewer count once streaming ends.
|
||||
Stream.objects.filter(id=s.id).update(current_viewers=F('current_viewers') - 1)
|
||||
logger.debug("Viewer count decremented for stream ID=%s", s.id)
|
||||
|
||||
return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
|
||||
|
|
|
|||
244
frontend/package-lock.json
generated
244
frontend/package-lock.json
generated
|
|
@ -13,11 +13,14 @@
|
|||
"@fontsource/roboto": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@videojs/http-streaming": "^3.17.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint": "^8.57.1",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"material-react-table": "^3.2.0",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"planby": "^1.1.7",
|
||||
"prettier": "^3.5.2",
|
||||
"react": "^19.0.0",
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-window": "^1.8.11",
|
||||
"video.js": "^8.21.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
|
|
@ -4378,6 +4382,54 @@
|
|||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@videojs/http-streaming": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz",
|
||||
"integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"aes-decrypter": "^4.0.2",
|
||||
"global": "^4.4.0",
|
||||
"m3u8-parser": "^7.2.0",
|
||||
"mpd-parser": "^1.3.1",
|
||||
"mux.js": "7.1.0",
|
||||
"video.js": "^7 || ^8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"video.js": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@videojs/vhs-utils": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
|
||||
"integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/@videojs/xhr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
|
||||
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"global": "~4.4.0",
|
||||
"is-function": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
|
|
@ -4509,6 +4561,15 @@
|
|||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
|
||||
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
|
|
@ -4613,6 +4674,18 @@
|
|||
"node": ">=8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-decrypter": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
|
||||
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"global": "^4.4.0",
|
||||
"pkcs7": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
@ -6758,6 +6831,11 @@
|
|||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-walk": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
|
|
@ -7123,6 +7201,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-promise": {
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
|
||||
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -8598,6 +8682,16 @@
|
|||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
|
||||
},
|
||||
"node_modules/global": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"min-document": "^2.19.0",
|
||||
"process": "^0.11.10"
|
||||
}
|
||||
},
|
||||
"node_modules/global-modules": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
|
||||
|
|
@ -8818,6 +8912,12 @@
|
|||
"npm": ">= 9"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.5.20",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
|
||||
"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
|
@ -9400,6 +9500,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-function": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
|
||||
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-generator-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
|
||||
|
|
@ -11033,6 +11139,17 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/m3u8-parser": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
|
||||
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"global": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
|
|
@ -11276,6 +11393,14 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-document": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
|
||||
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
|
||||
"dependencies": {
|
||||
"dom-walk": "^0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz",
|
||||
|
|
@ -11338,6 +11463,31 @@
|
|||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mpd-parser": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
|
||||
"integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/vhs-utils": "^4.0.0",
|
||||
"@xmldom/xmldom": "^0.8.3",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"mpd-to-m3u8-json": "bin/parse.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mpegts.js": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz",
|
||||
"integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"es6-promise": "^4.2.5",
|
||||
"webworkify-webpack": "github:xqq/webworkify-webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -11355,6 +11505,23 @@
|
|||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mux.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
|
||||
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.11.2",
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"muxjs-transmux": "bin/transmux.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
|
|
@ -11926,6 +12093,18 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pkcs7": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
|
||||
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5"
|
||||
},
|
||||
"bin": {
|
||||
"pkcs7": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
|
|
@ -13316,6 +13495,15 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
|
@ -16243,6 +16431,57 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/video.js": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.21.0.tgz",
|
||||
"integrity": "sha512-zcwerRb257QAuWfi8NH9yEX7vrGKFthjfcONmOQ4lxFRpDAbAi+u5LAjCjMWqhJda6zEmxkgdDpOMW3Y21QpXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@videojs/http-streaming": "^3.16.2",
|
||||
"@videojs/vhs-utils": "^4.1.1",
|
||||
"@videojs/xhr": "2.7.0",
|
||||
"aes-decrypter": "^4.0.2",
|
||||
"global": "4.4.0",
|
||||
"m3u8-parser": "^7.2.0",
|
||||
"mpd-parser": "^1.3.1",
|
||||
"mux.js": "^7.0.1",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-font": "4.2.0",
|
||||
"videojs-vtt.js": "0.15.5"
|
||||
}
|
||||
},
|
||||
"node_modules/videojs-contrib-quality-levels": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
|
||||
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"global": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
"npm": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"video.js": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/videojs-font": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
|
||||
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/videojs-vtt.js": {
|
||||
"version": "0.15.5",
|
||||
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
|
||||
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"global": "^4.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-hr-time": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||
|
|
@ -16533,6 +16772,11 @@
|
|||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webworkify-webpack": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
"@fontsource/roboto": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@videojs/http-streaming": "^3.17.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint": "^8.57.1",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"material-react-table": "^3.2.0",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"planby": "^1.1.7",
|
||||
"prettier": "^3.5.2",
|
||||
"react": "^19.0.0",
|
||||
|
|
@ -20,6 +23,7 @@
|
|||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-window": "^1.8.11",
|
||||
"video.js": "^8.21.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
|
|
@ -49,5 +53,5 @@
|
|||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://127.0.0.1:5656"
|
||||
"proxy": "http://host.docker.internal:5656"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import logo from './images/logo.png';
|
|||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
|
||||
const defaultRoute = '/channels';
|
||||
const defaultRoute = '/m3u';
|
||||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
|
@ -116,7 +116,6 @@ const App = () => {
|
|||
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
|
||||
|
|
|
|||
|
|
@ -360,6 +360,18 @@ export default class API {
|
|||
useUserAgentsStore.getState().removeUserAgents([id]);
|
||||
}
|
||||
|
||||
static async getPlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getPlaylists() {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
headers: {
|
||||
|
|
@ -603,4 +615,63 @@ export default class API {
|
|||
const retval = await response.json();
|
||||
return retval.data;
|
||||
}
|
||||
|
||||
static async addM3UProfile(accountId, values) {
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
}
|
||||
);
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
// Fetch m3u account to update it with its new playlists
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore
|
||||
.getState()
|
||||
.updateProfiles(playlist.id, playlist.profiles);
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteM3UProfile(accountId, id) {
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore.getState().updatePlaylist(playlist);
|
||||
}
|
||||
|
||||
static async updateM3UProfile(accountId, values) {
|
||||
const { id, ...payload } = values;
|
||||
const response = await fetch(
|
||||
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
const playlist = await API.getPlaylist(accountId);
|
||||
usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid2,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
|
|
@ -17,11 +12,11 @@ import {
|
|||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import API from "../../api";
|
||||
import useEPGsStore from "../../store/epgs";
|
||||
} 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);
|
||||
|
|
@ -36,15 +31,15 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
source_type: "",
|
||||
url: "",
|
||||
api_key: "",
|
||||
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"),
|
||||
name: Yup.string().required('Name is required'),
|
||||
source_type: Yup.string().required('Source type is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (epg?.id) {
|
||||
|
|
@ -85,8 +80,8 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
EPG Source
|
||||
|
|
@ -169,7 +164,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
|
@ -17,15 +17,17 @@ import {
|
|||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import API from "../../api";
|
||||
import useUserAgentsStore from "../../store/userAgents";
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
|
|
@ -36,15 +38,15 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
server_url: "",
|
||||
name: '',
|
||||
server_url: '',
|
||||
max_streams: 0,
|
||||
user_agent: "",
|
||||
user_agent: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required("Name is required"),
|
||||
user_agent: Yup.string().required("User-Agent is required"),
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (playlist?.id) {
|
||||
|
|
@ -89,8 +91,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
M3U Account
|
||||
|
|
@ -131,7 +133,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
alignItems: 'center',
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
|
|
@ -143,7 +145,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
name="uploaded_file"
|
||||
accept="image/*"
|
||||
onChange={(event) => handleFileChange(event)}
|
||||
style={{ display: "none" }}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label htmlFor="uploaded_file">
|
||||
<Button variant="contained" component="span">
|
||||
|
|
@ -198,7 +200,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue("is_active", e.target.checked)
|
||||
formik.setFieldValue('is_active', e.target.checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
|
@ -207,18 +209,33 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
|
|||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
{playlist && (
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
197
frontend/src/components/forms/M3UProfile.js
Normal file
197
frontend/src/components/forms/M3UProfile.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
||||
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
||||
const [searchPattern, setSearchPattern] = useState('');
|
||||
const [replacePattern, setReplacePattern] = useState('');
|
||||
|
||||
console.log(profile);
|
||||
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(searchPattern, 'g');
|
||||
} catch (e) {
|
||||
regex = null;
|
||||
}
|
||||
|
||||
const highlightedUrl = regex
|
||||
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
|
||||
: m3u.server_url;
|
||||
|
||||
const resultUrl = regex
|
||||
? m3u.server_url.replace(regex, replacePattern)
|
||||
: m3u.server_url;
|
||||
|
||||
const onSearchPatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setSearchPattern(e.target.value);
|
||||
};
|
||||
|
||||
const onReplacePatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setReplacePattern(e.target.value);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
max_streams: 0,
|
||||
search_pattern: '',
|
||||
replace_pattern: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().required('Search pattern is required'),
|
||||
replace_pattern: Yup.string().required('Replace pattern is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log('submiting');
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...values,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setSearchPattern(profile.search_pattern);
|
||||
setReplacePattern(profile.replace_pattern);
|
||||
formik.setValues({
|
||||
name: profile.name,
|
||||
max_streams: profile.max_streams,
|
||||
search_pattern: profile.search_pattern,
|
||||
replace_pattern: profile.replace_pattern,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
|
||||
>
|
||||
M3U Profile
|
||||
</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="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"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="search_pattern"
|
||||
name="search_pattern"
|
||||
label="Search Pattern (Regex)"
|
||||
value={searchPattern}
|
||||
onChange={onSearchPatternUpdate}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.search_pattern &&
|
||||
Boolean(formik.errors.search_pattern)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.search_pattern && formik.errors.search_pattern
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="replace_pattern"
|
||||
name="replace_pattern"
|
||||
label="Replace Pattern"
|
||||
value={replacePattern}
|
||||
onChange={onReplacePatternUpdate}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.replace_pattern &&
|
||||
Boolean(formik.errors.replace_pattern)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.replace_pattern && formik.errors.replace_pattern
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Search</Typography>
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Replace</Typography>
|
||||
<Typography>{resultUrl}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegexFormAndView;
|
||||
133
frontend/src/components/forms/M3UProfiles.js
Normal file
133
frontend/src/components/forms/M3UProfiles.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Switch,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import M3UProfile from './M3UProfile';
|
||||
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
||||
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
||||
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
|
||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const editProfile = (profile = null) => {
|
||||
if (profile) {
|
||||
setProfile(profile);
|
||||
}
|
||||
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
};
|
||||
|
||||
const toggleActive = async (values) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setProfile(null);
|
||||
setProfileEditorOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen || !profiles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
Profiles
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{profiles
|
||||
.filter((playlist) => playlist.is_default == false)
|
||||
.map((item) => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ marginRight: 2 }}>
|
||||
Max Streams: {item.max_streams}
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
color="primary"
|
||||
inputProps={{ 'aria-label': 'active switch' }}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => editProfile(item)}
|
||||
color="warning"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={editProfile}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<M3UProfile
|
||||
m3u={playlist}
|
||||
profile={profile}
|
||||
isOpen={profileEditorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UProfiles;
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
LiveTv as LiveTvIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
|
|
@ -57,13 +58,14 @@ const Example = () => {
|
|||
},
|
||||
{
|
||||
header: 'Group',
|
||||
|
||||
accessorFn: (row) => row.channel_group?.name || '',
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
size: 50,
|
||||
cell: (info) => (
|
||||
size: 55,
|
||||
Cell: ({ cell }) => (
|
||||
<Grid2
|
||||
container
|
||||
direction="row"
|
||||
|
|
@ -72,7 +74,7 @@ const Example = () => {
|
|||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={info.getValue() || logo} width="20" />
|
||||
<img src={cell.getValue() || logo} width="20" />
|
||||
</Grid2>
|
||||
),
|
||||
meta: {
|
||||
|
|
@ -121,7 +123,14 @@ const Example = () => {
|
|||
const selected = table
|
||||
.getRowModel()
|
||||
.rows.filter((row) => row.getIsSelected());
|
||||
await API.assignChannelNumbers(selected.map((sel) => sel.id));
|
||||
await API.assignChannelNumbers(selected.map((sel) => sel.original.id));
|
||||
|
||||
// @TODO: update the channels that were assigned
|
||||
};
|
||||
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -177,7 +186,6 @@ const Example = () => {
|
|||
columns,
|
||||
data: channels,
|
||||
enablePagination: false,
|
||||
// enableRowNumbers: true,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
|
@ -196,23 +204,31 @@ const Example = () => {
|
|||
renderRowActions: ({ row }) => (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
size="small"
|
||||
color="warning"
|
||||
onClick={() => {
|
||||
editChannel(row.original);
|
||||
}}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{/* <IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => previewChannel(row.original.id)}
|
||||
sx={{ p: 0 }}
|
||||
>
|
||||
<LiveTvIcon fontSize="small" />
|
||||
</IconButton> */}
|
||||
</Box>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
|
|
@ -234,32 +250,32 @@ const Example = () => {
|
|||
<Typography>Channels</Typography>
|
||||
<Tooltip title="Add New Channel">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
size="small"
|
||||
color="success"
|
||||
variant="contained"
|
||||
onClick={() => editChannel()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Channels">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={deleteChannels}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Assign Channels">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="contained"
|
||||
onClick={assignChannels}
|
||||
>
|
||||
<SwapVertIcon fontSize="small" /> {/* Small icon size */}
|
||||
<SwapVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
|
|
@ -288,7 +304,7 @@ const Example = () => {
|
|||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModelOpen}
|
||||
onClose={() => setChannelModalOpen(false)}
|
||||
onClose={closeChannelForm}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ const EPGsTable = () => {
|
|||
setSnackbarOpen(true);
|
||||
};
|
||||
|
||||
const closeEPGForm = () => {
|
||||
setEPG(null);
|
||||
setEPGModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
|
|
@ -182,11 +187,7 @@ const EPGsTable = () => {
|
|||
>
|
||||
<MaterialReactTable table={table} />
|
||||
|
||||
<EPGForm
|
||||
epg={epg}
|
||||
isOpen={epgModalOpen}
|
||||
onClose={() => setEPGModalOpen(false)}
|
||||
/>
|
||||
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
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';
|
||||
|
|
@ -106,7 +102,9 @@ const Example = () => {
|
|||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
setPlaylist(playlist);
|
||||
if (playlist) {
|
||||
setPlaylist(playlist);
|
||||
}
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -118,6 +116,11 @@ const Example = () => {
|
|||
await API.deletePlaylist(id);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setPlaylistModalOpen(false);
|
||||
setPlaylist(null);
|
||||
};
|
||||
|
||||
const deletePlaylists = async (ids) => {
|
||||
const selected = table
|
||||
.getRowModel()
|
||||
|
|
@ -228,7 +231,7 @@ const Example = () => {
|
|||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ const StreamProfiles = () => {
|
|||
await API.deleteStreamProfile(ids);
|
||||
};
|
||||
|
||||
const closeStreamProfileForm = () => {
|
||||
setProfile(null);
|
||||
setProfileModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
|
|
@ -210,7 +215,7 @@ const StreamProfiles = () => {
|
|||
<StreamProfileForm
|
||||
profile={profile}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
onClose={closeStreamProfileForm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const Example = () => {
|
|||
const createChannelFromStream = async (stream) => {
|
||||
await API.createChannelFromStream({
|
||||
channel_name: stream.name,
|
||||
channel_number: 0,
|
||||
channel_number: null,
|
||||
stream_id: stream.id,
|
||||
});
|
||||
};
|
||||
|
|
@ -96,6 +96,11 @@ const Example = () => {
|
|||
await API.deleteStreams(selected.map((stream) => stream.original.id));
|
||||
};
|
||||
|
||||
const closeStreamForm = () => {
|
||||
setStream(null);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
|
|
@ -222,7 +227,7 @@ const Example = () => {
|
|||
<StreamForm
|
||||
stream={stream}
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onClose={closeStreamForm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,11 @@ const UserAgentsTable = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const closeUserAgentForm = () => {
|
||||
setUserAgent(null);
|
||||
setUserAgentModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
|
|
@ -210,7 +215,7 @@ const UserAgentsTable = () => {
|
|||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
onClose={closeUserAgentForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,26 +60,18 @@ const TVChannelGuide = ({ startDate, endDate }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const fetchPrograms = async () => {
|
||||
console.log('Fetching program grid...');
|
||||
const programs = await API.getGrid();
|
||||
console.log(`Received ${programs.length} programs`);
|
||||
const programIds = [...new Set(programs.map((prog) => prog.tvg_id))];
|
||||
console.log('program tvg-ids');
|
||||
console.log(programIds);
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
programIds.includes(ch.tvg_id)
|
||||
);
|
||||
console.log(
|
||||
`found ${filteredChannels.length} channels with matching tvg-ids`
|
||||
);
|
||||
console.log(filteredChannels);
|
||||
setGuideChannels(filteredChannels);
|
||||
setActiveChannels(guideChannels.map((channel) => channel.channel_name));
|
||||
setPrograms(programs);
|
||||
};
|
||||
|
||||
fetchPrograms();
|
||||
}, [channels]);
|
||||
}, [channels, activeChannels]);
|
||||
|
||||
const latestHalfHour = new Date();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { create } from "zustand";
|
||||
import api from "../api";
|
||||
import { create } from 'zustand';
|
||||
import api from '../api';
|
||||
|
||||
const usePlaylistsStore = create((set) => ({
|
||||
playlists: [],
|
||||
profiles: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -10,30 +11,54 @@ const usePlaylistsStore = create((set) => ({
|
|||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const playlists = await api.getPlaylists();
|
||||
set({ playlists: playlists, isLoading: false });
|
||||
set({
|
||||
playlists: playlists,
|
||||
isLoading: false,
|
||||
profiles: playlists.reduce((acc, playlist) => {
|
||||
acc[playlist.id] = playlist.profiles;
|
||||
return acc;
|
||||
}, {}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch playlists:", error);
|
||||
set({ error: "Failed to load playlists.", isLoading: false });
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
set({ error: 'Failed to load playlists.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addPlaylist: (newPlaylist) =>
|
||||
set((state) => ({
|
||||
playlists: [...state.playlists, newPlaylist],
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[newPlaylist.id]: newPlaylist.profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
updatePlaylist: (playlist) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.map((pl) =>
|
||||
pl.id === playlist.id ? playlist : pl,
|
||||
pl.id === playlist.id ? playlist : pl
|
||||
),
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[playlist.id]: playlist.profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
updateProfiles: (playlistId, profiles) =>
|
||||
set((state) => ({
|
||||
profiles: {
|
||||
...state.profiles,
|
||||
[playlistId]: profiles,
|
||||
},
|
||||
})),
|
||||
|
||||
removePlaylists: (playlistIds) =>
|
||||
set((state) => ({
|
||||
playlists: state.playlists.filter(
|
||||
(playlist) => !playlistIds.includes(playlist.id),
|
||||
(playlist) => !playlistIds.includes(playlist.id)
|
||||
),
|
||||
// @TODO: remove playlist profiles here
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue