From ea81cfb1afd426bb8b12df6bb5205673634e31a1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 13 Jul 2025 15:59:25 -0500 Subject: [PATCH] Add auto channel sync settings to ChannelGroupM3UAccount and update related components - Introduced `auto_channel_sync` and `auto_sync_channel_start` fields in the ChannelGroupM3UAccount model. - Added API endpoint to update M3U group settings. - Updated M3UGroupFilter component to manage auto sync settings. - Enhanced M3URefreshNotification and M3U components for better user guidance. - Created a Celery task for automatic channel synchronization after M3U refresh. --- ...upm3uaccount_auto_channel_sync_and_more.py | 23 +++ apps/channels/models.py | 9 ++ apps/channels/serializers.py | 4 +- apps/m3u/api_views.py | 34 ++++- apps/m3u/tasks.py | 144 ++++++++++++++++++ dispatcharr/celery.py | 1 + frontend/src/api.js | 13 ++ .../src/components/M3URefreshNotification.jsx | 4 +- frontend/src/components/forms/M3U.jsx | 3 +- .../src/components/forms/M3UGroupFilter.jsx | 140 ++++++++++++----- 10 files changed, 334 insertions(+), 41 deletions(-) create mode 100644 apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py diff --git a/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py b/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py new file mode 100644 index 00000000..a0c94c7d --- /dev/null +++ b/apps/channels/migrations/0022_channelgroupm3uaccount_auto_channel_sync_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-07-13 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0021_channel_user_level'), + ] + + operations = [ + 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), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 1bcbcc41..b6333aab 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -541,6 +541,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") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 4d1694dc..0eb5acc3 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -289,10 +289,12 @@ class ChannelSerializer(serializers.ModelSerializer): 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()) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 0ef42272..39b9e22e 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -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""" diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 0b782649..b5614376 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -838,6 +838,144 @@ 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})") + + # Get all streams in this group for this M3U account + current_streams = Stream.objects.filter( + m3u_account=account, + channel_group=channel_group + ) + + # Get existing channels in this group that were auto-created (we'll track this via a custom property) + existing_auto_channels = Channel.objects.filter( + channel_group=channel_group, + streams__m3u_account=account + ).distinct() + + # Create a mapping of stream hashes to existing channels + existing_channel_streams = {} + for channel in existing_auto_channels: + for stream in channel.streams.filter(m3u_account=account): + existing_channel_streams[stream.stream_hash] = channel + + # Track which channels should exist (based on current streams) + channels_to_keep = set() + current_channel_number = start_number + + # Create channels for streams that don't have them + for stream in current_streams.order_by('name'): + if stream.stream_hash in existing_channel_streams: + # Channel already exists for this stream + channels_to_keep.add(existing_channel_streams[stream.stream_hash].id) + continue + + # Find next available channel number + while Channel.objects.filter(channel_number=current_channel_number).exists(): + current_channel_number += 0.1 + + # Create new channel + try: + # 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 + 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 + ) + + # 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_to_keep.add(channel.id) + 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 + + # Delete channels that no longer have corresponding streams + channels_to_delete = existing_auto_channels.exclude(id__in=channels_to_keep) + + for channel in channels_to_delete: + # Only delete if all streams for this channel are from this M3U account + # and this channel group + all_streams_from_account = all( + s.m3u_account_id == account.id and s.channel_group_id == channel_group.id + for s in channel.streams.all() + ) + + if all_streams_from_account: + logger.debug(f"Deleting auto channel: {channel.channel_number} - {channel.name}") + channel.delete() + channels_deleted += 1 + + 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.""" @@ -1120,6 +1258,12 @@ def refresh_single_m3u_account(account_id): message=account.last_message ) + # Run auto channel sync after successful refresh + try: + sync_result = sync_auto_channels(account_id) + logger.info(f"Auto channel sync result for account {account_id}: {sync_result}") + except Exception as e: + logger.error(f"Error running auto channel sync for account {account_id}: {str(e)}") except Exception as e: logger.error(f"Error processing M3U for account {account_id}: {str(e)}") account.status = M3UAccount.Status.ERROR diff --git a/dispatcharr/celery.py b/dispatcharr/celery.py index 8856d330..98c6210b 100644 --- a/dispatcharr/celery.py +++ b/dispatcharr/celery.py @@ -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', diff --git a/frontend/src/api.js b/frontend/src/api.js index e0a62160..e34dabe2 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index 8a6647cb..3b57af37 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -49,7 +49,7 @@ export default function M3URefreshNotification() { message: ( {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.'} diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 24ddd377..0e4d5643 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -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 diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 7ca0fa96..0213eeee 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -21,7 +21,11 @@ import { Center, SimpleGrid, Text, + NumberInput, + Divider, + Alert, } from '@mantine/core'; +import { Info } from 'lucide-react'; import useChannelsStore from '../../store/channels'; import { CircleCheck, CircleX } from 'lucide-react'; @@ -40,6 +44,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 +59,38 @@ 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 new API endpoint + await API.updateM3UGroupSettings(playlist.id, groupStates); + + // Refresh the playlist + API.refreshPlaylist(playlist.id); + onClose(); + } catch (error) { + console.error('Error updating group settings:', error); + } finally { + setIsLoading(false); + } }; const selectAll = () => { @@ -94,14 +123,21 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { + } color="blue" variant="light"> + + Auto Channel Sync: 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. + + + setGroupFilter(event.currentTarget.value)} style={{ flex: 1 }} @@ -113,41 +149,77 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { Deselect Visible - + + + + {groupStates .filter((group) => group.name.toLowerCase().includes(groupFilter.toLowerCase()) ) - .sort((a, b) => a.name > b.name) + .sort((a, b) => a.name.localeCompare(b.name)) .map((group) => ( - + + {/* Group Enable/Disable Button */} + + + {/* Auto Sync Checkbox */} + toggleAutoSync(group.channel_group)} + size="sm" + /> + + {/* Channel Start Number Input */} + updateChannelStart(group.channel_group, value)} + disabled={!group.enabled || !group.auto_channel_sync} + min={1} + step={1} + size="sm" + style={{ width: '120px' }} + precision={1} + /> + ))} - + +