merged in xtream branch

This commit is contained in:
dekzter 2025-05-03 13:52:42 -04:00
commit 693d33e18d
23 changed files with 711 additions and 296 deletions

View file

@ -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),
),
]

View file

@ -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:

View file

@ -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

View file

@ -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),
),
]

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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
View 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}"

View file

@ -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) {

View file

@ -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,

View file

@ -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" />

View file

@ -42,7 +42,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
name: channelGroups[group.channel_group].name,
}))
);
}, [channelGroups]);
}, [playlist, channelGroups]);
const toggleGroupEnabled = (id) => {
setGroupStates(

View file

@ -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

View file

@ -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',

View file

@ -358,7 +358,7 @@ const M3UTable = () => {
<MantineReactTable table={table} />
<M3UForm
playlist={playlist}
m3uAccount={playlist}
isOpen={playlistModalOpen}
onClose={closeModal}
playlistCreated={playlistCreated}

View file

@ -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,

View file

@ -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>

View file

@ -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={
{

View file

@ -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;
}
}

View file

@ -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>
);
};