mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
merged in xtream branch
This commit is contained in:
commit
693d33e18d
23 changed files with 711 additions and 296 deletions
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-27 14:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0017_alter_channelgroup_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='custom_properties',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from core.models import StreamProfile
|
||||
from django.conf import settings
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
|
|
@ -210,7 +209,7 @@ class ChannelManager(models.Manager):
|
|||
|
||||
|
||||
class Channel(models.Model):
|
||||
channel_number = models.IntegerField(db_index=True)
|
||||
channel_number = models.IntegerField()
|
||||
name = models.CharField(max_length=255)
|
||||
logo = models.ForeignKey(
|
||||
'Logo',
|
||||
|
|
@ -488,6 +487,7 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
on_delete=models.CASCADE,
|
||||
related_name='channel_group'
|
||||
)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from django.core.cache import cache
|
|||
import os
|
||||
from rest_framework.decorators import action
|
||||
from django.conf import settings
|
||||
from .tasks import refresh_m3u_groups
|
||||
|
||||
# Import all models, including UserAgent.
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
|
|
@ -56,6 +57,10 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# Now call super().create() to create the instance
|
||||
response = super().create(request, *args, **kwargs)
|
||||
|
||||
print(response.data.get('account_type'))
|
||||
if response.data.get('account_type') == M3UAccount.Types.XC:
|
||||
refresh_m3u_groups(response.data.get('id'))
|
||||
|
||||
# After the instance is created, return the response
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-27 12:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('m3u', '0008_m3uaccount_stale_stream_days'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='account_type',
|
||||
field=models.CharField(choices=[('STD', 'Standard'), ('XC', 'Xtream Codes')], default='STD'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='m3uaccount',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -10,6 +10,10 @@ from core.models import CoreSettings, UserAgent
|
|||
CUSTOM_M3U_ACCOUNT_NAME="custom"
|
||||
|
||||
class M3UAccount(models.Model):
|
||||
class Types(models.TextChoices):
|
||||
STADNARD = "STD", "Standard"
|
||||
XC = "XC", "Xtream Codes"
|
||||
|
||||
"""Represents an M3U Account for IPTV streams."""
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
|
|
@ -69,6 +73,9 @@ class M3UAccount(models.Model):
|
|||
blank=True,
|
||||
related_name='m3u_accounts'
|
||||
)
|
||||
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
|
||||
username = models.CharField(max_length=255, null=True, blank=True)
|
||||
password = models.CharField(max_length=255, null=True, blank=True)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
refresh_interval = models.IntegerField(default=24)
|
||||
refresh_task = models.ForeignKey(
|
||||
|
|
|
|||
|
|
@ -65,9 +65,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
model = M3UAccount
|
||||
fields = [
|
||||
'id', 'name', 'server_url', 'file_path', 'server_group',
|
||||
'max_streams', 'is_active', 'stale_stream_days', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked',
|
||||
'channel_groups', 'refresh_interval'
|
||||
'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked',
|
||||
'channel_groups', 'refresh_interval', 'custom_properties', 'account_type', 'username', 'password', 'stale_stream_days',
|
||||
]
|
||||
extra_kwargs = {
|
||||
'password': {
|
||||
'required': False,
|
||||
'allow_blank': True,
|
||||
},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Pop out channel group memberships so we can handle them manually
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def refresh_account_on_save(sender, instance, created, **kwargs):
|
|||
call a Celery task that fetches & parses that single account
|
||||
if it is active or newly created.
|
||||
"""
|
||||
if created:
|
||||
if created and instance.account_type != M3UAccount.Types.XC:
|
||||
refresh_m3u_groups.delay(instance.id)
|
||||
|
||||
@receiver(post_save, sender=M3UAccount)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import json
|
|||
from core.utils import RedisClient, acquire_task_lock, release_task_lock
|
||||
from core.models import CoreSettings
|
||||
from asgiref.sync import async_to_sync
|
||||
from core.xtream_codes import Client as XCClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -177,32 +178,33 @@ def check_field_lengths(streams_to_create):
|
|||
print("")
|
||||
|
||||
@shared_task
|
||||
def process_groups(account, group_names):
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(name__in=group_names)}
|
||||
def process_groups(account, groups):
|
||||
existing_groups = {group.name: group for group in ChannelGroup.objects.filter(name__in=groups.keys())}
|
||||
logger.info(f"Currently {len(existing_groups)} existing groups")
|
||||
|
||||
groups = []
|
||||
group_objs = []
|
||||
groups_to_create = []
|
||||
for group_name in group_names:
|
||||
for group_name, custom_props in groups.items():
|
||||
logger.info(f"Handling group: {group_name}")
|
||||
if group_name in existing_groups:
|
||||
groups.append(existing_groups[group_name])
|
||||
else:
|
||||
if group_name not in existing_groups:
|
||||
groups_to_create.append(ChannelGroup(
|
||||
name=group_name,
|
||||
))
|
||||
else:
|
||||
group_objs.append(existing_groups[group_name])
|
||||
|
||||
if groups_to_create:
|
||||
logger.info(f"Creating {len(groups_to_create)} groups")
|
||||
created = ChannelGroup.bulk_create_and_fetch(groups_to_create)
|
||||
logger.info(f"Created {len(created)} groups")
|
||||
groups.extend(created)
|
||||
group_objs.extend(created)
|
||||
|
||||
relations = []
|
||||
for group in groups:
|
||||
for group in group_objs:
|
||||
relations.append(ChannelGroupM3UAccount(
|
||||
channel_group=group,
|
||||
m3u_account=account,
|
||||
custom_properties=json.dumps(groups[group.name]),
|
||||
))
|
||||
|
||||
ChannelGroupM3UAccount.objects.bulk_create(
|
||||
|
|
@ -210,6 +212,78 @@ def process_groups(account, group_names):
|
|||
ignore_conflicts=True
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def process_xc_category(account_id, batch, groups, hash_keys):
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
|
||||
streams_to_create = []
|
||||
streams_to_update = []
|
||||
stream_hashes = {}
|
||||
|
||||
xc_client = XCClient(account.server_url, account.username, account.password)
|
||||
for group_name, props in batch.items():
|
||||
streams = xc_client.get_live_category_streams(props['xc_id'])
|
||||
for stream in streams:
|
||||
name = stream["name"]
|
||||
url = xc_client.get_stream_url(stream["stream_id"])
|
||||
tvg_id = stream["epg_channel_id"]
|
||||
tvg_logo = stream["stream_icon"]
|
||||
group_title = group_name
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys)
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"logo_url": tvg_logo,
|
||||
"tvg_id": tvg_id,
|
||||
"m3u_account": account,
|
||||
"channel_group_id": int(groups.get(group_title)),
|
||||
"stream_hash": stream_hash,
|
||||
"custom_properties": json.dumps(stream),
|
||||
}
|
||||
|
||||
if stream_hash not in stream_hashes:
|
||||
stream_hashes[stream_hash] = stream_props
|
||||
|
||||
existing_streams = {s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys())}
|
||||
|
||||
for stream_hash, stream_props in stream_hashes.items():
|
||||
if stream_hash in existing_streams:
|
||||
obj = existing_streams[stream_hash]
|
||||
existing_attr = {field.name: getattr(obj, field.name) for field in Stream._meta.fields if field != 'channel_group_id'}
|
||||
changed = any(existing_attr[key] != value for key, value in stream_props.items() if key != 'channel_group_id')
|
||||
|
||||
if changed:
|
||||
for key, value in stream_props.items():
|
||||
setattr(obj, key, value)
|
||||
obj.last_seen = timezone.now()
|
||||
streams_to_update.append(obj)
|
||||
del existing_streams[stream_hash]
|
||||
else:
|
||||
existing_streams[stream_hash] = obj
|
||||
else:
|
||||
stream_props["last_seen"] = timezone.now()
|
||||
streams_to_create.append(Stream(**stream_props))
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if streams_to_create:
|
||||
Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True)
|
||||
if streams_to_update:
|
||||
Stream.objects.bulk_update(streams_to_update, { key for key in stream_props.keys() if key not in ["m3u_account", "stream_hash"] and key not in hash_keys})
|
||||
# if len(existing_streams.keys()) > 0:
|
||||
# Stream.objects.bulk_update(existing_streams.values(), ["last_seen"])
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk create failed: {str(e)}")
|
||||
|
||||
retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated."
|
||||
|
||||
# Aggressive garbage collection
|
||||
del streams_to_create, streams_to_update, stream_hashes, existing_streams
|
||||
gc.collect()
|
||||
|
||||
return retval
|
||||
|
||||
@shared_task
|
||||
def process_m3u_batch(account_id, batch, groups, hash_keys):
|
||||
"""Processes a batch of M3U streams using bulk operations."""
|
||||
|
|
@ -232,23 +306,7 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
|
|||
logger.debug(f"Skipping stream in disabled group: {group_title}")
|
||||
continue
|
||||
|
||||
# if any(url.lower().endswith(ext) for ext in SKIP_EXTS) or len(url) > 2000:
|
||||
# continue
|
||||
|
||||
# if _matches_filters(name, group_title, account.filters.all()):
|
||||
# continue
|
||||
|
||||
# if any(compiled_pattern.search(current_info['name']) for ftype, compiled_pattern in compiled_filters if ftype == 'name'):
|
||||
# excluded_count += 1
|
||||
# current_info = None
|
||||
# continue
|
||||
|
||||
stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys)
|
||||
# if redis_client.exists(f"m3u_refresh:{stream_hash}"):
|
||||
# # duplicate already processed by another batch
|
||||
# continue
|
||||
|
||||
# redis_client.set(f"m3u_refresh:{stream_hash}", "true")
|
||||
stream_props = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
|
|
@ -350,31 +408,45 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
|||
return f"M3UAccount with ID={account_id} not found or inactive.", None
|
||||
|
||||
extinf_data = []
|
||||
groups = set(["Default Group"])
|
||||
groups = {"Default Group": {}}
|
||||
|
||||
for line in fetch_m3u_lines(account, use_cache):
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
groups.add(parsed["attributes"]["group-title"])
|
||||
xc_client = None
|
||||
if account.account_type == M3UAccount.Types.XC:
|
||||
xc_client = XCClient(account.server_url, account.username, account.password)
|
||||
try:
|
||||
xc_client.authenticate()
|
||||
except Exception as e:
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
return f"M3UAccount with ID={account_id} failed to authenticate with XC server.", None
|
||||
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
xc_categories = xc_client.get_live_categories()
|
||||
for category in xc_categories:
|
||||
groups[category["category_name"]] = {
|
||||
"xc_id": category["category_id"],
|
||||
}
|
||||
else:
|
||||
for line in fetch_m3u_lines(account, use_cache):
|
||||
line = line.strip()
|
||||
if line.startswith("#EXTINF"):
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
groups[parsed["attributes"]["group-title"]] = {}
|
||||
|
||||
extinf_data.append(parsed)
|
||||
elif extinf_data and line.startswith("http"):
|
||||
# Associate URL with the last EXTINF line
|
||||
extinf_data[-1]["url"] = line
|
||||
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"extinf_data": extinf_data,
|
||||
"groups": groups,
|
||||
}, f)
|
||||
|
||||
send_m3u_update(account_id, "processing_groups", 0)
|
||||
|
||||
groups = list(groups)
|
||||
cache_path = os.path.join(m3u_dir, f"{account_id}.json")
|
||||
with open(cache_path, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
"extinf_data": extinf_data,
|
||||
"groups": groups,
|
||||
}, f)
|
||||
|
||||
process_groups(account, groups)
|
||||
|
||||
release_task_lock('refresh_m3u_account_groups', account_id)
|
||||
|
|
@ -430,7 +502,7 @@ def refresh_single_m3u_account(account_id):
|
|||
if not extinf_data:
|
||||
try:
|
||||
extinf_data, groups = refresh_m3u_groups(account_id, full_refresh=True)
|
||||
if not extinf_data or not groups:
|
||||
if not groups:
|
||||
release_task_lock('refresh_single_m3u_account', account_id)
|
||||
return "Failed to update m3u account, task may already be running"
|
||||
except:
|
||||
|
|
@ -444,9 +516,17 @@ def refresh_single_m3u_account(account_id):
|
|||
m3u_account__enabled=True # Filter by the enabled flag in the join table
|
||||
)}
|
||||
|
||||
# Break into batches and process in parallel
|
||||
batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)]
|
||||
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
if account.account_type == M3UAccount.Types.STADNARD:
|
||||
# Break into batches and process in parallel
|
||||
batches = [extinf_data[i:i + BATCH_SIZE] for i in range(0, len(extinf_data), BATCH_SIZE)]
|
||||
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
else:
|
||||
filtered_groups = [(k, v) for k, v in groups.items() if k in existing_groups]
|
||||
batches = [
|
||||
dict(filtered_groups[i:i + 2])
|
||||
for i in range(0, len(filtered_groups), 2)
|
||||
]
|
||||
task_group = group(process_xc_category.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
|
||||
|
||||
total_batches = len(batches)
|
||||
completed_batches = 0
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .models import UserAgent, StreamProfile, CoreSettings
|
||||
from .models import UserAgent, StreamProfile, CoreSettings, STREAM_HASH_KEY
|
||||
from .serializers import UserAgentSerializer, StreamProfileSerializer, CoreSettingsSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
|
|
@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema
|
|||
import socket
|
||||
import requests
|
||||
import os
|
||||
from core.tasks import rehash_streams
|
||||
|
||||
class UserAgentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
|
|
@ -34,6 +35,15 @@ class CoreSettingsViewSet(viewsets.ModelViewSet):
|
|||
queryset = CoreSettings.objects.all()
|
||||
serializer_class = CoreSettingsSerializer
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
response = super().update(request, *args, **kwargs)
|
||||
if instance.key == STREAM_HASH_KEY:
|
||||
if instance.value != request.data['value']:
|
||||
rehash_streams.delay(request.data['value'].split(','))
|
||||
|
||||
return response
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='get',
|
||||
operation_description="Endpoint for environment details",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from apps.epg.models import EPGSource
|
|||
from apps.m3u.tasks import refresh_single_m3u_account
|
||||
from apps.epg.tasks import refresh_epg_data
|
||||
from .models import CoreSettings
|
||||
from apps.channels.models import Stream, ChannelStream
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -249,3 +251,35 @@ def fetch_channel_stats():
|
|||
"data": {"success": True, "type": "channel_stats", "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})}
|
||||
},
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def rehash_streams(keys):
|
||||
batch_size = 1000
|
||||
queryset = Stream.objects.all()
|
||||
|
||||
hash_keys = {}
|
||||
total_records = queryset.count()
|
||||
for start in range(0, total_records, batch_size):
|
||||
with transaction.atomic():
|
||||
batch = queryset[start:start + batch_size]
|
||||
for obj in batch:
|
||||
stream_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys)
|
||||
if stream_hash in hash_keys:
|
||||
# Handle duplicate keys and remove any without channels
|
||||
stream_channels = ChannelStream.objects.filter(stream_id=obj.id).count()
|
||||
if stream_channels == 0:
|
||||
obj.delete()
|
||||
continue
|
||||
|
||||
|
||||
existing_stream_channels = ChannelStream.objects.filter(stream_id=hash_keys[stream_hash]).count()
|
||||
if existing_stream_channels == 0:
|
||||
Stream.objects.filter(id=hash_keys[stream_hash]).delete()
|
||||
|
||||
obj.stream_hash = stream_hash
|
||||
obj.save(update_fields=['stream_hash'])
|
||||
hash_keys[stream_hash] = obj.id
|
||||
|
||||
logger.debug(f"Re-hashed {batch_size} streams")
|
||||
|
||||
logger.debug(f"Re-hashing complete")
|
||||
|
|
|
|||
26
core/xtream_codes.py
Normal file
26
core/xtream_codes.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import requests
|
||||
|
||||
class Client:
|
||||
host = ""
|
||||
username = ""
|
||||
password = ""
|
||||
|
||||
def __init__(self, host, username, password):
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def authenticate(self):
|
||||
response = requests.get(f"{self.host}/player_api.php?username={self.username}&password={self.password}")
|
||||
return response.json()
|
||||
|
||||
def get_live_categories(self):
|
||||
response = requests.get(f"{self.host}/player_api.php?username={self.username}&password={self.password}&action=get_live_categories")
|
||||
return response.json()
|
||||
|
||||
def get_live_category_streams(self, category_id):
|
||||
response = requests.get(f"{self.host}/player_api.php?username={self.username}&password={self.password}&action=get_live_streams&category_id={category_id}")
|
||||
return response.json()
|
||||
|
||||
def get_stream_url(self, stream_id):
|
||||
return f"{self.host}/{self.username}/{self.password}/{stream_id}"
|
||||
|
|
@ -655,6 +655,10 @@ export default class API {
|
|||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
if (values.custom_properties) {
|
||||
values.custom_properties = JSON.stringify(values.custom_properties);
|
||||
}
|
||||
|
||||
try {
|
||||
let body = null;
|
||||
if (values.file) {
|
||||
|
|
@ -722,6 +726,10 @@ export default class API {
|
|||
static async updatePlaylist(values) {
|
||||
const { id, ...payload } = values;
|
||||
|
||||
if (payload.custom_properties) {
|
||||
payload.custom_properties = JSON.stringify(payload.custom_properties);
|
||||
}
|
||||
|
||||
try {
|
||||
let body = null;
|
||||
if (payload.file) {
|
||||
|
|
@ -740,6 +748,7 @@ export default class API {
|
|||
body = { ...payload };
|
||||
delete body.file;
|
||||
}
|
||||
console.log(body);
|
||||
|
||||
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PATCH',
|
||||
|
|
@ -1246,10 +1255,13 @@ export default class API {
|
|||
|
||||
static async switchStream(channelId, streamId) {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/ts/change_stream/${channelId}`, {
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/proxy/ts/change_stream/${channelId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -1260,10 +1272,13 @@ export default class API {
|
|||
|
||||
static async nextStream(channelId, streamId) {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/ts/next_stream/${channelId}`, {
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/proxy/ts/next_stream/${channelId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { stream_id: streamId },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ export default function M3URefreshNotification() {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
const playlist = playlists.find((pl) => pl.id == data.account);
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNotificationStatus({
|
||||
...notificationStatus,
|
||||
|
|
|
|||
|
|
@ -18,30 +18,35 @@ import {
|
|||
Stack,
|
||||
Group,
|
||||
Switch,
|
||||
Box,
|
||||
PasswordInput,
|
||||
} from '@mantine/core';
|
||||
import M3UGroupFilter from './M3UGroupFilter';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
||||
const M3U = ({
|
||||
m3uAccount = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
playlistCreated = false,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const userAgents = useUserAgentsStore((s) => s.userAgents);
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
|
||||
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
||||
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState('');
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
console.log(file);
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
const [showCredentialFields, setShowCredentialFields] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
|
|
@ -52,6 +57,10 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
is_active: true,
|
||||
max_streams: 0,
|
||||
refresh_interval: 24,
|
||||
account_type: 'STD',
|
||||
create_epg: false,
|
||||
username: '',
|
||||
password: '',
|
||||
stale_stream_days: 7,
|
||||
},
|
||||
|
||||
|
|
@ -63,23 +72,47 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
console.log(m3uAccount);
|
||||
if (m3uAccount) {
|
||||
setPlaylist(m3uAccount);
|
||||
form.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url || '',
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent ? `${playlist.user_agent}` : '0',
|
||||
is_active: playlist.is_active,
|
||||
stale_stream_days: playlist.stale_stream_days || 7,
|
||||
refresh_interval: playlist.refresh_interval,
|
||||
name: m3uAccount.name,
|
||||
server_url: m3uAccount.server_url,
|
||||
max_streams: m3uAccount.max_streams,
|
||||
user_agent: m3uAccount.user_agent ? `${m3uAccount.user_agent}` : '0',
|
||||
is_active: m3uAccount.is_active,
|
||||
refresh_interval: m3uAccount.refresh_interval,
|
||||
account_type: m3uAccount.account_type,
|
||||
username: m3uAccount.username ?? '',
|
||||
password: '',
|
||||
stale_stream_days: m3uAccount.stale_stream_days || 7,
|
||||
});
|
||||
|
||||
if (m3uAccount.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
} else {
|
||||
setShowCredentialFields(false);
|
||||
}
|
||||
} else {
|
||||
setPlaylist(null);
|
||||
form.reset();
|
||||
}
|
||||
}, [playlist]);
|
||||
}, [m3uAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.values.account_type == 'XC') {
|
||||
setShowCredentialFields(true);
|
||||
}
|
||||
}, [form.values.account_type]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const { create_epg, ...values } = form.getValues();
|
||||
|
||||
if (values.account_type == 'XC' && values.password == '') {
|
||||
// If account XC and no password input, assuming no password change
|
||||
// from previously stored value.
|
||||
delete values.password;
|
||||
}
|
||||
|
||||
if (values.user_agent == '0') {
|
||||
values.user_agent = null;
|
||||
|
|
@ -98,15 +131,37 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
file,
|
||||
});
|
||||
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
});
|
||||
if (create_epg) {
|
||||
API.addEPG({
|
||||
name: values.name,
|
||||
source_type: 'xmltv',
|
||||
url: `${values.server_url}/xmltv.php?username=${values.username}&password=${values.password}`,
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
refresh_interval: 24,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
// in case we want to revive it
|
||||
newPlaylist = null;
|
||||
if (values.account_type != 'XC') {
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
});
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
// in case we want to revive it
|
||||
newPlaylist = null;
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPlaylist = await API.getPlaylist(newPlaylist.id);
|
||||
await Promise.all([fetchChannelGroups(), fetchPlaylists(), fetchEPGs()]);
|
||||
console.log('opening group options');
|
||||
setPlaylist(updatedPlaylist);
|
||||
setGroupFilterModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
|
@ -114,13 +169,16 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
onClose(newPlaylist);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
form.reset();
|
||||
setFile(null);
|
||||
setPlaylist(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const closeGroupFilter = () => {
|
||||
setGroupFilterModalOpen(false);
|
||||
if (playlistCreated) {
|
||||
form.reset();
|
||||
setFile(null);
|
||||
onClose();
|
||||
}
|
||||
close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -134,7 +192,7 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal size={700} opened={isOpen} onClose={onClose} title="M3U Account">
|
||||
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
|
||||
<LoadingOverlay
|
||||
visible={form.submitting}
|
||||
overlayBlur={2}
|
||||
|
|
@ -152,7 +210,6 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="server_url"
|
||||
|
|
@ -162,13 +219,64 @@ const M3U = ({ playlist = null, isOpen, onClose, playlistCreated = false }) => {
|
|||
key={form.key('server_url')}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
// value={formik.file}
|
||||
onChange={handleFileChange}
|
||||
<Select
|
||||
id="account_type"
|
||||
name="account_type"
|
||||
label="Account Type"
|
||||
data={[
|
||||
{
|
||||
value: 'STD',
|
||||
label: 'Standard',
|
||||
},
|
||||
{
|
||||
value: 'XC',
|
||||
label: 'XTream Codes',
|
||||
},
|
||||
]}
|
||||
key={form.key('account_type')}
|
||||
{...form.getInputProps('account_type')}
|
||||
/>
|
||||
|
||||
{form.getValues().account_type == 'XC' && (
|
||||
<Box>
|
||||
{!m3uAccount && (
|
||||
<Group justify="space-between">
|
||||
<Box>Create EPG</Box>
|
||||
<Switch
|
||||
id="create_epg"
|
||||
name="create_epg"
|
||||
key={form.key('create_epg')}
|
||||
{...form.getInputProps('create_epg', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
id="username"
|
||||
name="username"
|
||||
label="Username"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{form.getValues().account_type != 'XC' && (
|
||||
<FileInput
|
||||
id="file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
onChange={setFile}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
name: channelGroups[group.channel_group].name,
|
||||
}))
|
||||
);
|
||||
}, [channelGroups]);
|
||||
}, [playlist, channelGroups]);
|
||||
|
||||
const toggleGroupEnabled = (id) => {
|
||||
setGroupStates(
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const ChannelRowActions = React.memo(
|
|||
// Extract the channel ID once to ensure consistency
|
||||
const channelId = row.original.id;
|
||||
const channelUuid = row.original.uuid;
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
// Use the ID directly to avoid issues with filtered tables
|
||||
|
|
@ -119,11 +120,14 @@ const ChannelRowActions = React.memo(
|
|||
createRecording(row.original);
|
||||
}, [channelId]);
|
||||
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%', justifyContent: 'left' }}>
|
||||
<Center>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={onEdit}
|
||||
|
|
@ -132,7 +136,7 @@ const ChannelRowActions = React.memo(
|
|||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={onDelete}
|
||||
|
|
@ -141,7 +145,7 @@ const ChannelRowActions = React.memo(
|
|||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={onPreview}
|
||||
|
|
@ -151,7 +155,7 @@ const ChannelRowActions = React.memo(
|
|||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size="sm">
|
||||
<ActionIcon variant="transparent" size={iconSize}>
|
||||
<EllipsisVertical size="18" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
|
@ -228,6 +232,7 @@ const ChannelsTable = ({ }) => {
|
|||
// store/settings
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
|
|
@ -555,26 +560,24 @@ const ChannelsTable = ({ }) => {
|
|||
size: 40,
|
||||
cell: ({ getValue }) => (
|
||||
<Flex justify="flex-end" style={{ width: '100%' }}>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ row, getValue }) => {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
cell: ({ getValue }) => (
|
||||
<Box
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channel_group',
|
||||
|
|
@ -590,7 +593,7 @@ const ChannelsTable = ({ }) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -615,7 +618,7 @@ const ChannelsTable = ({ }) => {
|
|||
},
|
||||
{
|
||||
id: 'actions',
|
||||
size: 75,
|
||||
size: tableSize == 'compact' ? 75 : 100,
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<ChannelRowActions
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { useCallback, useState, useRef } from 'react';
|
|||
import { flexRender } from '@tanstack/react-table';
|
||||
import table from '../../../helpers/table';
|
||||
import CustomTableBody from './CustomTableBody';
|
||||
import useLocalStorage from '../../../hooks/useLocalStorage';
|
||||
|
||||
const CustomTable = ({ table }) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="divTable table-striped"
|
||||
className={`divTable table-striped table-size-${tableSize}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ const M3UTable = () => {
|
|||
<MantineReactTable table={table} />
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
m3uAccount={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={closeModal}
|
||||
playlistCreated={playlistCreated}
|
||||
|
|
|
|||
|
|
@ -213,38 +213,14 @@ const StreamProfiles = () => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(60vh - 100px)',
|
||||
// height: 'calc(60vh - 100px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Stream Profiles
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Stack gap={0} style={{ padding: 0 }}>
|
||||
<Paper
|
||||
style={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import useSettingsStore from '../../store/settings';
|
|||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useChannelsTableStore from '../../store/channelsTable';
|
||||
import { CustomTable, useTable } from './CustomTable';
|
||||
import useLocalStorage from '../../hooks/useLocalStorage';
|
||||
|
||||
const StreamRowActions = ({
|
||||
theme,
|
||||
|
|
@ -52,6 +53,7 @@ const StreamRowActions = ({
|
|||
handleWatchStream,
|
||||
selectedChannelIds,
|
||||
}) => {
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
const channelSelectionStreams = useChannelsTableStore(
|
||||
(state) =>
|
||||
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
|
||||
|
|
@ -89,15 +91,25 @@ const StreamRowActions = ({
|
|||
}, [row.original.id, deleteStream]);
|
||||
|
||||
const onPreview = useCallback(() => {
|
||||
console.log('Previewing stream:', row.original.name, 'ID:', row.original.id, 'Hash:', row.original.stream_hash);
|
||||
console.log(
|
||||
'Previewing stream:',
|
||||
row.original.name,
|
||||
'ID:',
|
||||
row.original.id,
|
||||
'Hash:',
|
||||
row.original.stream_hash
|
||||
);
|
||||
handleWatchStream(row.original.stream_hash);
|
||||
}, [row.original.id]); // Add proper dependency to ensure correct stream
|
||||
|
||||
const iconSize =
|
||||
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label="Add to Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
color={theme.tailwind.blue[6]}
|
||||
variant="transparent"
|
||||
onClick={addStreamToChannel}
|
||||
|
|
@ -116,7 +128,7 @@ const StreamRowActions = ({
|
|||
|
||||
<Tooltip label="Create New Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
size={iconSize}
|
||||
color={theme.tailwind.green[5]}
|
||||
variant="transparent"
|
||||
onClick={createChannelFromStream}
|
||||
|
|
@ -127,7 +139,7 @@ const StreamRowActions = ({
|
|||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size="xs">
|
||||
<ActionIcon variant="transparent" size={iconSize}>
|
||||
<EllipsisVertical size="18" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
|
@ -157,7 +169,7 @@ const StreamRowActions = ({
|
|||
);
|
||||
};
|
||||
|
||||
const StreamsTable = ({ }) => {
|
||||
const StreamsTable = ({}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
/**
|
||||
|
|
@ -203,6 +215,7 @@ const StreamsTable = ({ }) => {
|
|||
);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [tableSize, _] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const handleSelectClick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -216,7 +229,7 @@ const StreamsTable = ({ }) => {
|
|||
() => [
|
||||
{
|
||||
id: 'actions',
|
||||
size: 60,
|
||||
size: tableSize == 'compact' ? 60 : 80,
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
|
|
@ -233,7 +246,7 @@ const StreamsTable = ({ }) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -251,7 +264,7 @@ const StreamsTable = ({ }) => {
|
|||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
{getValue()}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
|
|
@ -269,7 +282,7 @@ const StreamsTable = ({ }) => {
|
|||
}}
|
||||
>
|
||||
<Tooltip label={getValue()} openDelay={500}>
|
||||
<Text size="sm">{getValue()}</Text>
|
||||
<Box>{getValue()}</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
|
|
@ -609,17 +622,34 @@ const StreamsTable = ({ }) => {
|
|||
<Box>
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? "light" : "default"}
|
||||
variant={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? 'light'
|
||||
: 'default'
|
||||
}
|
||||
size="xs"
|
||||
onClick={addStreamsToChannel}
|
||||
p={5}
|
||||
color={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? theme.tailwind.green[5] : undefined}
|
||||
style={selectedStreamIds.length > 0 && selectedChannelIds.length === 1 ? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
} : undefined}
|
||||
disabled={!(selectedStreamIds.length > 0 && selectedChannelIds.length === 1)}
|
||||
color={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? theme.tailwind.green[5]
|
||||
: undefined
|
||||
}
|
||||
style={
|
||||
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
|
||||
? {
|
||||
borderWidth: '1px',
|
||||
borderColor: theme.tailwind.green[5],
|
||||
color: 'white',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
!(
|
||||
selectedStreamIds.length > 0 &&
|
||||
selectedChannelIds.length === 1
|
||||
)
|
||||
}
|
||||
>
|
||||
Add Streams to Channel
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ const UserAgentsTable = () => {
|
|||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(60vh - 100px)',
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
// margin: 5,
|
||||
},
|
||||
|
|
@ -234,34 +234,7 @@ const UserAgentsTable = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Stack gap={0} style={{ width: '49%', padding: 0 }}>
|
||||
<Flex
|
||||
style={
|
||||
{
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// paddingTop: 10,
|
||||
// paddingBottom: 10,
|
||||
}
|
||||
}
|
||||
// gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
// marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
User-Agents
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Stack gap={0} style={{ padding: 0 }}>
|
||||
<Paper
|
||||
style={
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,10 +45,24 @@ html {
|
|||
}
|
||||
|
||||
.td {
|
||||
height: 28px;
|
||||
border-bottom: solid 1px rgb(68, 68, 68);
|
||||
}
|
||||
|
||||
.divTable.table-size-compact .td {
|
||||
height: 28px;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.divTable.table-size-default .td {
|
||||
height: 40px;
|
||||
font-size: var(--mantine-font-size-md);
|
||||
}
|
||||
|
||||
.divTable.table-size-large .td {
|
||||
height: 48px;
|
||||
font-size: var(--mantine-font-size-md);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -165,4 +179,4 @@ html {
|
|||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
cursor: text !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,28 +4,30 @@ import useSettingsStore from '../store/settings';
|
|||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Accordion,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Paper,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import useLocalStorage from '../hooks/useLocalStorage';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const userAgents = useUserAgentsStore((s) => s.userAgents);
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
|
||||
// UI / local storage settings
|
||||
const [tableSize, setTableSize] = useLocalStorage('table-size', 'default');
|
||||
|
||||
const regionChoices = [
|
||||
{ value: 'ad', label: 'AD' },
|
||||
{ value: 'ae', label: 'AE' },
|
||||
|
|
@ -284,6 +286,7 @@ const SettingsPage = () => {
|
|||
'default-stream-profile': '',
|
||||
'preferred-region': '',
|
||||
'auto-import-mapped-files': true,
|
||||
'm3u-hash-key': [],
|
||||
},
|
||||
|
||||
validate: {
|
||||
|
|
@ -295,8 +298,9 @@ const SettingsPage = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.setValues(
|
||||
Object.entries(settings).reduce((acc, [key, value]) => {
|
||||
console.log(settings);
|
||||
const formValues = Object.entries(settings).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Modify each value based on its own properties
|
||||
switch (value.value) {
|
||||
case 'true':
|
||||
|
|
@ -307,10 +311,23 @@ const SettingsPage = () => {
|
|||
break;
|
||||
}
|
||||
|
||||
acc[key] = value.value;
|
||||
let val = null;
|
||||
switch (key) {
|
||||
case 'm3u-hash-key':
|
||||
val = value.value.split(',');
|
||||
break;
|
||||
default:
|
||||
val = value.value;
|
||||
break;
|
||||
}
|
||||
|
||||
acc[key] = val;
|
||||
return acc;
|
||||
}, {})
|
||||
},
|
||||
{}
|
||||
);
|
||||
console.log(formValues);
|
||||
form.setValues(formValues);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
|
|
@ -333,98 +350,158 @@ const SettingsPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onUISettingsChange = (name, value) => {
|
||||
switch (name) {
|
||||
case 'table-size':
|
||||
setTableSize(value);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Center
|
||||
style={{
|
||||
height: '40vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 20, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Settings
|
||||
</Title>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
key={form.key('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
key={form.key('default-stream-profile')}
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('preferred-region')}
|
||||
key={form.key('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" style={{ paddingTop: 5 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-Import Mapped Files
|
||||
</Text>
|
||||
<Switch
|
||||
{...form.getInputProps('auto-import-mapped-files', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('auto-import-mapped-files')}
|
||||
id={
|
||||
settings['auto-import-mapped-files']?.id ||
|
||||
'auto-import-mapped-files'
|
||||
}
|
||||
<Center
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Box style={{ width: '100%', maxWidth: 800 }}>
|
||||
<Accordion variant="separated" defaultValue="ui-settings">
|
||||
<Accordion.Item value="ui-settings">
|
||||
<Accordion.Control>UI Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Select
|
||||
label="Table Size"
|
||||
value={tableSize}
|
||||
onChange={(val) => onUISettingsChange('table-size', val)}
|
||||
data={[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
},
|
||||
{
|
||||
value: 'large',
|
||||
label: 'Large',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
<Accordion.Item value="stream-settings">
|
||||
<Accordion.Control>Stream Settings</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
key={form.key('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-around"
|
||||
align="top"
|
||||
style={{ width: '100%' }}
|
||||
gap={0}
|
||||
>
|
||||
<StreamProfilesTable />
|
||||
<UserAgentsTable />
|
||||
</Group>
|
||||
</Stack>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
key={form.key('default-stream-profile')}
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
searchable
|
||||
{...form.getInputProps('preferred-region')}
|
||||
key={form.key('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={
|
||||
settings['preferred-region']?.name || 'Preferred Region'
|
||||
}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" style={{ paddingTop: 5 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
Auto-Import Mapped Files
|
||||
</Text>
|
||||
<Switch
|
||||
{...form.getInputProps('auto-import-mapped-files', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('auto-import-mapped-files')}
|
||||
id={
|
||||
settings['auto-import-mapped-files']?.id ||
|
||||
'auto-import-mapped-files'
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<MultiSelect
|
||||
id="m3u-hash-key"
|
||||
name="m3u-hash-key"
|
||||
label="M3U Hash Key"
|
||||
data={[
|
||||
{
|
||||
value: 'name',
|
||||
label: 'Name',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
label: 'URL',
|
||||
},
|
||||
{
|
||||
value: 'tvg_id',
|
||||
label: 'TVG-ID',
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps('m3u-hash-key')}
|
||||
key={form.key('m3u-hash-key')}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.submitting}
|
||||
variant="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="user-agents">
|
||||
<Accordion.Control>User-Agents</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<UserAgentsTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="stream-profiles">
|
||||
<Accordion.Control>Stream Profiles</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<StreamProfilesTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Box>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue