diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 3346495e..a933c496 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -321,13 +321,34 @@ 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) + custom_properties = serializers.JSONField(required=False) class Meta: model = ChannelGroupM3UAccount - fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start"] + fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] - # Optionally, if you only need the id of the ChannelGroup, you can customize it like this: - # channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all()) + def to_representation(self, instance): + ret = super().to_representation(instance) + # Ensure custom_properties is always a dict or None + val = ret.get("custom_properties") + if isinstance(val, str): + import json + try: + ret["custom_properties"] = json.loads(val) + except Exception: + ret["custom_properties"] = None + return ret + + def to_internal_value(self, data): + # Accept both dict and JSON string for custom_properties + val = data.get("custom_properties") + if isinstance(val, str): + import json + try: + data["custom_properties"] = json.loads(val) + except Exception: + pass + return super().to_internal_value(data) class RecordingSerializer(serializers.ModelSerializer): diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 39b9e22e..d3739f19 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -15,6 +15,7 @@ import os from rest_framework.decorators import action from django.conf import settings from .tasks import refresh_m3u_groups +import json from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent @@ -154,6 +155,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet): enabled = setting.get("enabled", True) auto_sync = setting.get("auto_channel_sync", False) sync_start = setting.get("auto_sync_channel_start") + custom_properties = setting.get("custom_properties", {}) if group_id: ChannelGroupM3UAccount.objects.update_or_create( @@ -163,6 +165,11 @@ class M3UAccountViewSet(viewsets.ModelViewSet): "enabled": enabled, "auto_channel_sync": auto_sync, "auto_sync_channel_start": sync_start, + "custom_properties": ( + custom_properties + if isinstance(custom_properties, str) + else json.dumps(custom_properties) + ), }, ) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 03888ead..5ee669f0 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -847,7 +847,6 @@ def sync_auto_channels(account_id): """ 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) @@ -868,6 +867,28 @@ def sync_auto_channels(account_id): channel_group = group_relation.channel_group start_number = group_relation.auto_sync_channel_start or 1.0 + # Get force_dummy_epg and group_override from group custom_properties + group_custom_props = {} + force_dummy_epg = False + override_group_id = None + if group_relation.custom_properties: + try: + group_custom_props = json.loads(group_relation.custom_properties) + force_dummy_epg = group_custom_props.get("force_dummy_epg", False) + override_group_id = group_custom_props.get("group_override") + except Exception: + force_dummy_epg = False + override_group_id = None + + # Determine which group to use for created channels + target_group = channel_group + if override_group_id: + try: + target_group = ChannelGroup.objects.get(id=override_group_id) + logger.info(f"Using override group '{target_group.name}' instead of '{channel_group.name}' for auto-created channels") + except ChannelGroup.DoesNotExist: + logger.warning(f"Override group with ID {override_group_id} not found, using original group '{channel_group.name}'") + logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})") # Get all current streams in this group for this M3U account @@ -876,20 +897,22 @@ def sync_auto_channels(account_id): channel_group=channel_group ).order_by('name') - # Get existing auto-created channels for this account in this group + # Get existing auto-created channels for this account (regardless of current group) + # We'll find them by their stream associations instead of just group location existing_channels = Channel.objects.filter( - channel_group=channel_group, auto_created=True, auto_created_by=account ).select_related('logo', 'epg_data') # Create mapping of existing channels by their associated stream + # This approach finds channels even if they've been moved to different groups existing_channel_map = {} for channel in existing_channels: - # Get streams associated with this channel that belong to our M3U account + # Get streams associated with this channel that belong to our M3U account and original group channel_streams = ChannelStream.objects.filter( channel=channel, - stream__m3u_account=account + stream__m3u_account=account, + stream__channel_group=channel_group # Match streams from the original group ).select_related('stream') # Map each of our M3U account's streams to this channel @@ -903,9 +926,10 @@ def sync_auto_channels(account_id): if not current_streams.exists(): logger.debug(f"No streams found in group {channel_group.name}") # Delete all existing auto channels if no streams - if existing_channels.exists(): - deleted_count = existing_channels.count() - existing_channels.delete() + channels_to_delete = [ch for ch in existing_channel_map.values()] + if channels_to_delete: + deleted_count = len(channels_to_delete) + Channel.objects.filter(id__in=[ch.id for ch in channels_to_delete]).delete() channels_deleted += deleted_count logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)") continue @@ -941,6 +965,12 @@ def sync_auto_channels(account_id): existing_channel.tvc_guide_stationid = tvc_guide_stationid channel_updated = True + # Check if channel group needs to be updated (in case override was added/changed) + if existing_channel.channel_group != target_group: + existing_channel.channel_group = target_group + channel_updated = True + logger.info(f"Moved auto channel '{existing_channel.name}' from '{existing_channel.channel_group.name if existing_channel.channel_group else 'None'}' to '{target_group.name}'") + # Handle logo updates current_logo = None if stream.logo_url: @@ -956,7 +986,7 @@ def sync_auto_channels(account_id): # Handle EPG data updates current_epg_data = None - if stream.tvg_id: + if stream.tvg_id and not force_dummy_epg: current_epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first() if existing_channel.epg_data != current_epg_data: @@ -974,13 +1004,13 @@ def sync_auto_channels(account_id): while Channel.objects.filter(channel_number=current_channel_number).exists(): current_channel_number += 0.1 - # Create the channel with auto-created tracking + # Create the channel with auto-created tracking in the target group 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, + channel_group=target_group, # Use target group (could be override) user_level=0, # Default user level auto_created=True, # Mark as auto-created auto_created_by=account # Track which M3U account created it @@ -994,11 +1024,14 @@ def sync_auto_channels(account_id): ) # Try to match EPG data - if stream.tvg_id: + if stream.tvg_id and not force_dummy_epg: 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']) + elif stream.tvg_id and force_dummy_epg: + channel.epg_data = None + channel.save(update_fields=['epg_data']) # Handle logo if stream.logo_url: diff --git a/frontend/src/api.js b/frontend/src/api.js index fe5deae3..63c193ba 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -739,7 +739,9 @@ export default class API { method: 'PATCH', body: { group_settings: groupSettings }, }); - + // Fetch the updated playlist and update the store + const updatedPlaylist = await API.getPlaylist(playlistId); + usePlaylistsStore.getState().updatePlaylist(updatedPlaylist); return response; } catch (e) { errorNotification('Failed to update M3U group settings', e); @@ -781,7 +783,6 @@ export default class API { const response = await request(`${host}/api/m3u/refresh/${id}/`, { method: 'POST', }); - return response; } catch (e) { errorNotification('Failed to refresh M3U account', e); diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index e0a204ab..315a6424 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -43,12 +43,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { } setGroupStates( - 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.channel_groups.map((group) => { + // Parse custom_properties if present + let customProps = {}; + if (group.custom_properties) { + try { + customProps = typeof group.custom_properties === 'string' + ? JSON.parse(group.custom_properties) + : group.custom_properties; + } catch (e) { + customProps = {}; + } + } + return { + ...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, + custom_properties: customProps, + }; + }) ); }, [playlist, channelGroups]); @@ -79,11 +93,36 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { ); }; + // Toggle force_dummy_epg in custom_properties for a group + const toggleForceDummyEPG = (id) => { + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == id) { + const customProps = { ...(state.custom_properties || {}) }; + customProps.force_dummy_epg = !customProps.force_dummy_epg; + return { + ...state, + custom_properties: customProps, + }; + } + return state; + }) + ); + }; + const submit = async () => { setIsLoading(true); try { + // Prepare groupStates for API: custom_properties must be stringified + const payload = groupStates.map((state) => ({ + ...state, + custom_properties: state.custom_properties + ? JSON.stringify(state.custom_properties) + : undefined, + })); + // Update group settings via API endpoint - await API.updateM3UGroupSettings(playlist.id, groupStates); + await API.updateM3UGroupSettings(playlist.id, payload); // Show notification about the refresh process notifications.show({ @@ -215,24 +254,95 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { {/* Auto Sync Controls */} - toggleAutoSync(group.channel_group)} - size="xs" - /> + + toggleAutoSync(group.channel_group)} + size="xs" + /> + {group.auto_channel_sync && group.enabled && ( + toggleForceDummyEPG(group.channel_group)} + size="xs" + /> + )} + {group.auto_channel_sync && group.enabled && ( - updateChannelStart(group.channel_group, value)} - min={1} - step={1} - size="xs" - precision={1} - /> + <> + updateChannelStart(group.channel_group, value)} + min={1} + step={1} + size="xs" + precision={1} + /> + + {/* Override Channel Group */} + + { + const isEnabled = event.currentTarget.checked; + setGroupStates( + groupStates.map((state) => { + if (state.channel_group == group.channel_group) { + const newCustomProps = { ...(state.custom_properties || {}) }; + if (isEnabled) { + newCustomProps.group_override = null; + } else { + delete newCustomProps.group_override; + } + return { + ...state, + custom_properties: newCustomProps, + }; + } + return state; + }) + ); + }} + size="xs" + /> +