mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 12:36:42 +00:00
Merge pull request #246 from Dispatcharr/auto-sync-groups
Auto sync groups
This commit is contained in:
commit
1a475a29d0
10 changed files with 396 additions and 49 deletions
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 5.1.6 on 2025-07-13 23:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0021_channel_user_level'),
|
||||
('m3u', '0012_alter_m3uaccount_refresh_interval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='auto_created',
|
||||
field=models.BooleanField(default=False, help_text='Whether this channel was automatically created via M3U auto channel sync'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channel',
|
||||
name='auto_created_by',
|
||||
field=models.ForeignKey(blank=True, help_text='The M3U account that auto-created this channel', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_created_channels', to='m3u.m3uaccount'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='auto_channel_sync',
|
||||
field=models.BooleanField(default=False, help_text='Automatically create/delete channels to match streams in this group'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='channelgroupm3uaccount',
|
||||
name='auto_sync_channel_start',
|
||||
field=models.FloatField(blank=True, help_text='Starting channel number for auto-created channels in this group', null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -277,6 +277,19 @@ class Channel(models.Model):
|
|||
|
||||
user_level = models.IntegerField(default=0)
|
||||
|
||||
auto_created = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this channel was automatically created via M3U auto channel sync"
|
||||
)
|
||||
auto_created_by = models.ForeignKey(
|
||||
"m3u.M3UAccount",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="auto_created_channels",
|
||||
help_text="The M3U account that auto-created this channel"
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
# Enforce unique channel_number within a given group
|
||||
existing = Channel.objects.filter(
|
||||
|
|
@ -541,6 +554,15 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
)
|
||||
custom_properties = models.TextField(null=True, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
auto_channel_sync = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Automatically create/delete channels to match streams in this group'
|
||||
)
|
||||
auto_sync_channel_start = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Starting channel number for auto-created channels in this group'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("channel_group", "m3u_account")
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
required=False,
|
||||
)
|
||||
|
||||
auto_created_by_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Channel
|
||||
fields = [
|
||||
|
|
@ -188,6 +190,9 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
"uuid",
|
||||
"logo_id",
|
||||
"user_level",
|
||||
"auto_created",
|
||||
"auto_created_by",
|
||||
"auto_created_by_name",
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
|
|
@ -286,13 +291,21 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
return None
|
||||
return value # PrimaryKeyRelatedField will handle the conversion to object
|
||||
|
||||
def get_auto_created_by_name(self, obj):
|
||||
"""Get the name of the M3U account that auto-created this channel."""
|
||||
if obj.auto_created_by:
|
||||
return obj.auto_created_by.name
|
||||
return None
|
||||
|
||||
|
||||
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
||||
enabled = serializers.BooleanField()
|
||||
auto_channel_sync = serializers.BooleanField(default=False)
|
||||
auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = ChannelGroupM3UAccount
|
||||
fields = ["id", "channel_group", "enabled"]
|
||||
fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start"]
|
||||
|
||||
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
|
||||
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
|
||||
|
|
|
|||
|
|
@ -16,13 +16,11 @@ 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
|
||||
from core.models import UserAgent
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from core.serializers import UserAgentSerializer
|
||||
|
||||
# Import all serializers, including the UserAgentSerializer.
|
||||
from .serializers import (
|
||||
M3UAccountSerializer,
|
||||
M3UFilterSerializer,
|
||||
|
|
@ -144,6 +142,38 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
# Continue with regular partial update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=["patch"], url_path="group-settings")
|
||||
def update_group_settings(self, request, pk=None):
|
||||
"""Update auto channel sync settings for M3U account groups"""
|
||||
account = self.get_object()
|
||||
group_settings = request.data.get("group_settings", [])
|
||||
|
||||
try:
|
||||
for setting in group_settings:
|
||||
group_id = setting.get("channel_group")
|
||||
enabled = setting.get("enabled", True)
|
||||
auto_sync = setting.get("auto_channel_sync", False)
|
||||
sync_start = setting.get("auto_sync_channel_start")
|
||||
|
||||
if group_id:
|
||||
ChannelGroupM3UAccount.objects.update_or_create(
|
||||
channel_group_id=group_id,
|
||||
m3u_account=account,
|
||||
defaults={
|
||||
"enabled": enabled,
|
||||
"auto_channel_sync": auto_sync,
|
||||
"auto_sync_channel_start": sync_start,
|
||||
},
|
||||
)
|
||||
|
||||
return Response({"message": "Group settings updated successfully"})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Failed to update group settings: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class M3UFilterViewSet(viewsets.ModelViewSet):
|
||||
"""Handles CRUD operations for M3U filters"""
|
||||
|
|
|
|||
|
|
@ -838,6 +838,126 @@ def delete_m3u_refresh_task_by_id(account_id):
|
|||
logger.error(f"Error deleting periodic task for M3UAccount {account_id}: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
@shared_task
|
||||
def sync_auto_channels(account_id):
|
||||
"""
|
||||
Automatically create/delete channels to match streams in groups with auto_channel_sync enabled.
|
||||
Called after M3U refresh completes successfully.
|
||||
"""
|
||||
from apps.channels.models import Channel, ChannelGroup, ChannelGroupM3UAccount, Stream, ChannelStream
|
||||
from apps.epg.models import EPGData
|
||||
import json
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id)
|
||||
logger.info(f"Starting auto channel sync for M3U account {account.name}")
|
||||
|
||||
# Get groups with auto sync enabled for this account
|
||||
auto_sync_groups = ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account=account,
|
||||
enabled=True,
|
||||
auto_channel_sync=True
|
||||
).select_related('channel_group')
|
||||
|
||||
channels_created = 0
|
||||
channels_deleted = 0
|
||||
|
||||
for group_relation in auto_sync_groups:
|
||||
channel_group = group_relation.channel_group
|
||||
start_number = group_relation.auto_sync_channel_start or 1.0
|
||||
|
||||
logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})")
|
||||
|
||||
# Step 1: Delete all existing auto-created channels for this account in this group
|
||||
existing_auto_channels = Channel.objects.filter(
|
||||
channel_group=channel_group,
|
||||
auto_created=True,
|
||||
auto_created_by=account
|
||||
)
|
||||
|
||||
deleted_count = existing_auto_channels.count()
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"Deleting {deleted_count} existing auto-created channels in group {channel_group.name}")
|
||||
existing_auto_channels.delete()
|
||||
channels_deleted += deleted_count
|
||||
|
||||
# Step 2: Get all current streams in this group for this M3U account
|
||||
current_streams = Stream.objects.filter(
|
||||
m3u_account=account,
|
||||
channel_group=channel_group
|
||||
).order_by('name') # Sort by name for consistent ordering
|
||||
|
||||
if not current_streams.exists():
|
||||
logger.debug(f"No streams found in group {channel_group.name}, skipping channel creation")
|
||||
continue
|
||||
|
||||
# Step 3: Create new channels for all streams
|
||||
current_channel_number = start_number
|
||||
|
||||
for stream in current_streams:
|
||||
try:
|
||||
# Find next available channel number
|
||||
while Channel.objects.filter(channel_number=current_channel_number).exists():
|
||||
current_channel_number += 0.1
|
||||
|
||||
# Parse custom properties for additional info
|
||||
stream_custom_props = json.loads(stream.custom_properties) if stream.custom_properties else {}
|
||||
|
||||
# Get tvc_guide_stationid from custom properties if it exists
|
||||
tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid")
|
||||
|
||||
# Create the channel with auto-created tracking
|
||||
channel = Channel.objects.create(
|
||||
channel_number=current_channel_number,
|
||||
name=stream.name,
|
||||
tvg_id=stream.tvg_id,
|
||||
tvc_guide_stationid=tvc_guide_stationid,
|
||||
channel_group=channel_group,
|
||||
user_level=0, # Default user level
|
||||
auto_created=True, # Mark as auto-created
|
||||
auto_created_by=account # Track which M3U account created it
|
||||
)
|
||||
|
||||
# Associate the stream with the channel
|
||||
ChannelStream.objects.create(
|
||||
channel=channel,
|
||||
stream=stream,
|
||||
order=0
|
||||
)
|
||||
|
||||
# Try to match EPG data
|
||||
if stream.tvg_id:
|
||||
epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first()
|
||||
if epg_data:
|
||||
channel.epg_data = epg_data
|
||||
channel.save(update_fields=['epg_data'])
|
||||
|
||||
# Handle logo
|
||||
if stream.logo_url:
|
||||
from apps.channels.models import Logo
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=stream.logo_url,
|
||||
defaults={"name": stream.name or stream.tvg_id or "Unknown"}
|
||||
)
|
||||
channel.logo = logo
|
||||
channel.save(update_fields=['logo'])
|
||||
|
||||
channels_created += 1
|
||||
current_channel_number += 1.0
|
||||
|
||||
logger.debug(f"Created auto channel: {channel.channel_number} - {channel.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating auto channel for stream {stream.name}: {str(e)}")
|
||||
continue
|
||||
|
||||
logger.info(f"Auto channel sync complete for account {account.name}: {channels_created} created, {channels_deleted} deleted")
|
||||
return f"Auto sync: {channels_created} channels created, {channels_deleted} deleted"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}")
|
||||
return f"Auto sync error: {str(e)}"
|
||||
|
||||
@shared_task
|
||||
def refresh_single_m3u_account(account_id):
|
||||
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
|
||||
|
|
@ -1092,6 +1212,16 @@ def refresh_single_m3u_account(account_id):
|
|||
# Now run cleanup
|
||||
streams_deleted = cleanup_streams(account_id, refresh_start_timestamp)
|
||||
|
||||
# Run auto channel sync after successful refresh
|
||||
auto_sync_message = ""
|
||||
try:
|
||||
sync_result = sync_auto_channels(account_id)
|
||||
logger.info(f"Auto channel sync result for account {account_id}: {sync_result}")
|
||||
if sync_result and "created" in sync_result:
|
||||
auto_sync_message = f" {sync_result}."
|
||||
except Exception as e:
|
||||
logger.error(f"Error running auto channel sync for account {account_id}: {str(e)}")
|
||||
|
||||
# Calculate elapsed time
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
|
|
@ -1100,7 +1230,7 @@ def refresh_single_m3u_account(account_id):
|
|||
account.last_message = (
|
||||
f"Processing completed in {elapsed_time:.1f} seconds. "
|
||||
f"Streams: {streams_created} created, {streams_updated} updated, {streams_deleted} removed. "
|
||||
f"Total processed: {streams_processed}."
|
||||
f"Total processed: {streams_processed}.{auto_sync_message}"
|
||||
)
|
||||
account.updated_at = timezone.now()
|
||||
account.save(update_fields=['status', 'last_message', 'updated_at'])
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ def cleanup_task_memory(**kwargs):
|
|||
'apps.m3u.tasks.refresh_m3u_accounts',
|
||||
'apps.m3u.tasks.process_m3u_batch',
|
||||
'apps.m3u.tasks.process_xc_category',
|
||||
'apps.m3u.tasks.sync_auto_channels',
|
||||
'apps.epg.tasks.refresh_epg_data',
|
||||
'apps.epg.tasks.refresh_all_epg_data',
|
||||
'apps.epg.tasks.parse_programs_for_source',
|
||||
|
|
|
|||
|
|
@ -733,6 +733,19 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async updateM3UGroupSettings(playlistId, groupSettings) {
|
||||
try {
|
||||
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
|
||||
method: 'PATCH',
|
||||
body: { group_settings: groupSettings },
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update M3U group settings', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
if (values.custom_properties) {
|
||||
values.custom_properties = JSON.stringify(values.custom_properties);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default function M3URefreshNotification() {
|
|||
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
|
||||
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
|
||||
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
|
||||
const fetchChannels = useChannelsStore((s) => s.fetchChannels);
|
||||
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
|
||||
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export default function M3URefreshNotification() {
|
|||
message: (
|
||||
<Stack>
|
||||
{data.message ||
|
||||
'M3U groups loaded. Please select groups or refresh M3U to complete setup.'}
|
||||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
|
||||
<Group grow>
|
||||
<Button
|
||||
size="xs"
|
||||
|
|
@ -72,7 +73,7 @@ export default function M3URefreshNotification() {
|
|||
navigate('/sources');
|
||||
}}
|
||||
>
|
||||
Edit Groups
|
||||
Configure Groups
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
|
@ -135,6 +136,8 @@ export default function M3URefreshNotification() {
|
|||
// Only trigger additional fetches on successful completion
|
||||
if (data.action == 'parsing') {
|
||||
fetchStreams();
|
||||
API.requeryChannels();
|
||||
fetchChannels();
|
||||
} else if (data.action == 'processing_groups') {
|
||||
fetchStreams();
|
||||
fetchChannelGroups();
|
||||
|
|
|
|||
|
|
@ -145,8 +145,7 @@ const M3U = ({
|
|||
if (values.account_type != 'XC') {
|
||||
notifications.show({
|
||||
title: 'Fetching M3U Groups',
|
||||
message: 'Filter out groups or refresh M3U once complete.',
|
||||
// color: 'green.5',
|
||||
message: 'Configure group filters and auto sync settings once complete.',
|
||||
});
|
||||
|
||||
// Don't prompt for group filters, but keeping this here
|
||||
|
|
|
|||
|
|
@ -21,9 +21,15 @@ import {
|
|||
Center,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
NumberInput,
|
||||
Divider,
|
||||
Alert,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { CircleCheck, CircleX } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
|
|
@ -40,6 +46,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
playlist.channel_groups.map((group) => ({
|
||||
...group,
|
||||
name: channelGroups[group.channel_group].name,
|
||||
auto_channel_sync: group.auto_channel_sync || false,
|
||||
auto_sync_channel_start: group.auto_sync_channel_start || 1.0,
|
||||
}))
|
||||
);
|
||||
}, [playlist, channelGroups]);
|
||||
|
|
@ -53,15 +61,54 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const toggleAutoSync = (id) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => ({
|
||||
...state,
|
||||
auto_channel_sync: state.channel_group == id ? !state.auto_channel_sync : state.auto_channel_sync,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const updateChannelStart = (id, value) => {
|
||||
setGroupStates(
|
||||
groupStates.map((state) => ({
|
||||
...state,
|
||||
auto_sync_channel_start: state.channel_group == id ? value : state.auto_sync_channel_start,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setIsLoading(true);
|
||||
await API.updatePlaylist({
|
||||
...playlist,
|
||||
channel_groups: groupStates,
|
||||
});
|
||||
setIsLoading(false);
|
||||
API.refreshPlaylist(playlist.id);
|
||||
onClose();
|
||||
try {
|
||||
// Update group settings via API endpoint
|
||||
await API.updateM3UGroupSettings(playlist.id, groupStates);
|
||||
|
||||
// Show notification about the refresh process
|
||||
notifications.show({
|
||||
title: 'Group Settings Updated',
|
||||
message: 'Settings saved. Starting M3U refresh to apply changes...',
|
||||
color: 'green',
|
||||
autoClose: 3000,
|
||||
});
|
||||
|
||||
// Refresh the playlist - this will handle channel sync automatically at the end
|
||||
await API.refreshPlaylist(playlist.id);
|
||||
|
||||
notifications.show({
|
||||
title: 'M3U Refresh Started',
|
||||
message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.',
|
||||
color: 'blue',
|
||||
autoClose: 5000,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error updating group settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
|
|
@ -94,60 +141,114 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
|
|||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="M3U Group Filter"
|
||||
title="M3U Group Filter & Auto Channel Sync"
|
||||
size={1000}
|
||||
styles={{ content: { '--mantine-color-body': '#27272A' } }}
|
||||
>
|
||||
<LoadingOverlay visible={isLoading} overlayBlur={2} />
|
||||
<Stack>
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Auto Channel Sync:</strong> When enabled, channels will be automatically created for all streams in the group during M3U updates,
|
||||
and removed when streams are no longer present. Set a starting channel number for each group to organize your channels.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Flex gap="sm">
|
||||
<TextInput
|
||||
placeholder="Filter"
|
||||
placeholder="Filter groups..."
|
||||
value={groupFilter}
|
||||
onChange={(event) => setGroupFilter(event.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
/>
|
||||
<Button variant="default" size="sm" onClick={selectAll}>
|
||||
<Button variant="default" size="xs" onClick={selectAll}>
|
||||
Select Visible
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={deselectAll}>
|
||||
<Button variant="default" size="xs" onClick={deselectAll}>
|
||||
Deselect Visible
|
||||
</Button>
|
||||
</Flex>
|
||||
<SimpleGrid cols={4}>
|
||||
{groupStates
|
||||
.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => a.name > b.name)
|
||||
.map((group) => (
|
||||
<Button
|
||||
key={group.channel_group}
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
checked={group.enabled}
|
||||
onClick={() => toggleGroupEnabled(group.channel_group)}
|
||||
radius="xl"
|
||||
leftSection={
|
||||
group.enabled ? (
|
||||
<CircleCheck size={20} />
|
||||
) : (
|
||||
<CircleX size={20} />
|
||||
)
|
||||
}
|
||||
justify="left"
|
||||
>
|
||||
<Text size="xs">{group.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
|
||||
|
||||
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, md: 3 }}
|
||||
spacing="xs"
|
||||
verticalSpacing="xs"
|
||||
>
|
||||
{groupStates
|
||||
.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => (
|
||||
<Group key={group.channel_group} spacing="xs" style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Group Enable/Disable Button */}
|
||||
<Button
|
||||
color={group.enabled ? 'green' : 'gray'}
|
||||
variant="filled"
|
||||
onClick={() => toggleGroupEnabled(group.channel_group)}
|
||||
radius="md"
|
||||
size="xs"
|
||||
leftSection={
|
||||
group.enabled ? (
|
||||
<CircleCheck size={14} />
|
||||
) : (
|
||||
<CircleX size={14} />
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<Text size="xs" truncate>
|
||||
{group.name}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Auto Sync Controls */}
|
||||
<Stack spacing={4}>
|
||||
<Checkbox
|
||||
label="Auto Channel Sync"
|
||||
checked={group.auto_channel_sync && group.enabled}
|
||||
disabled={!group.enabled}
|
||||
onChange={() => toggleAutoSync(group.channel_group)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
{group.auto_channel_sync && group.enabled && (
|
||||
<NumberInput
|
||||
label="Start Channel #"
|
||||
value={group.auto_sync_channel_start}
|
||||
onChange={(value) => updateChannelStart(group.channel_group, value)}
|
||||
min={1}
|
||||
step={1}
|
||||
size="xs"
|
||||
precision={1}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button variant="default" onClick={onClose} size="xs">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
disabled={isLoading}
|
||||
size="small"
|
||||
onClick={submit}
|
||||
>
|
||||
Save and Refresh
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue