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}
+ />
+
))}
-
+
+