diff --git a/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py b/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py new file mode 100644 index 00000000..51507843 --- /dev/null +++ b/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py @@ -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), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 4485936e..f158c2d0 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -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: diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 054bdaa9..ddccbc11 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -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 diff --git a/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py b/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py new file mode 100644 index 00000000..d57f7ccd --- /dev/null +++ b/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py @@ -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), + ), + ] diff --git a/apps/m3u/models.py b/apps/m3u/models.py index 06c206f6..9901836b 100644 --- a/apps/m3u/models.py +++ b/apps/m3u/models.py @@ -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( diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 43015713..faafda2a 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -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 diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index 6e46a0ff..dc96ed57 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -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) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 7c2dd4a7..56528bb5 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -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 diff --git a/core/api_views.py b/core/api_views.py index 7f3ecf57..77473b5d 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -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", diff --git a/core/tasks.py b/core/tasks.py index 83682a69..64a89c7a 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -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") diff --git a/core/xtream_codes.py b/core/xtream_codes.py new file mode 100644 index 00000000..e79ec5e9 --- /dev/null +++ b/core/xtream_codes.py @@ -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}" diff --git a/frontend/src/api.js b/frontend/src/api.js index 3cec6e38..274ff1fa 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) { diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 9e469f43..b1c1984f 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -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, diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 8fee0d9b..1d81279f 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -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 ( - + { {...form.getInputProps('name')} key={form.key('name')} /> - { key={form.key('server_url')} /> - + + {form.getValues().account_type == 'XC' && ( + + {!m3uAccount && ( + + Create EPG + + + )} + + + + + + )} + + {form.getValues().account_type != 'XC' && ( + + )} diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index fd82c906..9a7194bf 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -42,7 +42,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { name: channelGroups[group.channel_group].name, })) ); - }, [channelGroups]); + }, [playlist, channelGroups]); const toggleGroupEnabled = (id) => { setGroupStates( diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index c5fce026..b737e4b5 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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 (
- + @@ -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 }) => ( - {getValue()} + {getValue()} ), }, { id: 'name', accessorKey: 'name', - cell: ({ row, getValue }) => { - return ( - - {getValue()} - - ); - }, + cell: ({ getValue }) => ( + + {getValue()} + + ), }, { id: 'channel_group', @@ -590,7 +593,7 @@ const ChannelsTable = ({ }) => { textOverflow: 'ellipsis', }} > - {getValue()} + {getValue()} ), }, @@ -615,7 +618,7 @@ const ChannelsTable = ({ }) => { }, { id: 'actions', - size: 75, + size: tableSize == 'compact' ? 75 : 100, header: '', cell: ({ row }) => ( { + const [tableSize, _] = useLocalStorage('table-size', 'default'); + return ( { { ), mantineTableContainerProps: { style: { - height: 'calc(60vh - 100px)', + // height: 'calc(60vh - 100px)', overflowY: 'auto', }, }, }); return ( - - - - Stream Profiles - - - + { + 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 ( <> - + @@ -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', }} > - {getValue()} + {getValue()} ), }, @@ -251,7 +264,7 @@ const StreamsTable = ({ }) => { textOverflow: 'ellipsis', }} > - {getValue()} + {getValue()} ), }, @@ -269,7 +282,7 @@ const StreamsTable = ({ }) => { }} > - {getValue()} + {getValue()} ), @@ -609,17 +622,34 @@ const StreamsTable = ({ }) => { diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx index e0c9b504..6d649ba7 100644 --- a/frontend/src/components/tables/UserAgentsTable.jsx +++ b/frontend/src/components/tables/UserAgentsTable.jsx @@ -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 ( - - - - User-Agents - - - + { 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 ( - -
- - - Settings - -
- ({ - value: `${option.id}`, - label: option.name, - }))} - /> - onUISettingsChange('table-size', val)} + data={[ + { + value: 'default', + label: 'Default', + }, + { + value: 'compact', + label: 'Compact', + }, + { + value: 'large', + label: 'Large', + }, + ]} /> - + + - - - -
-
-
+ + Stream Settings + +
+ ({ + value: `${option.id}`, + label: option.name, + }))} + /> +