diff --git a/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py
new file mode 100644
index 00000000..b1450c09
--- /dev/null
+++ b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py
@@ -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),
+ ),
+ ]
diff --git a/apps/channels/models.py b/apps/channels/models.py
index 1bcbcc41..f53a9875 100644
--- a/apps/channels/models.py
+++ b/apps/channels/models.py
@@ -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")
diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py
index 4d1694dc..278399dd 100644
--- a/apps/channels/serializers.py
+++ b/apps/channels/serializers.py
@@ -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())
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..02128ac0 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -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'])
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..c946d001 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -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: (
{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.'}
@@ -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();
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..e0a204ab 100644
--- a/frontend/src/components/forms/M3UGroupFilter.jsx
+++ b/frontend/src/components/forms/M3UGroupFilter.jsx
@@ -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 }) => {
+ } 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 }}
+ size="xs"
/>
-
-
- {groupStates
- .filter((group) =>
- group.name.toLowerCase().includes(groupFilter.toLowerCase())
- )
- .sort((a, b) => a.name > b.name)
- .map((group) => (
- toggleGroupEnabled(group.channel_group)}
- radius="xl"
- leftSection={
- group.enabled ? (
-
- ) : (
-
- )
- }
- justify="left"
- >
- {group.name}
-
- ))}
-
+
+
+
+
+
+ {groupStates
+ .filter((group) =>
+ group.name.toLowerCase().includes(groupFilter.toLowerCase())
+ )
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((group) => (
+
+ {/* Group Enable/Disable Button */}
+ toggleGroupEnabled(group.channel_group)}
+ radius="md"
+ size="xs"
+ leftSection={
+ group.enabled ? (
+
+ ) : (
+
+ )
+ }
+ fullWidth
+ >
+
+ {group.name}
+
+
+
+ {/* Auto Sync Controls */}
+
+ toggleAutoSync(group.channel_group)}
+ size="xs"
+ />
+
+ {group.auto_channel_sync && group.enabled && (
+ updateChannelStart(group.channel_group, value)}
+ min={1}
+ step={1}
+ size="xs"
+ precision={1}
+ />
+ )}
+
+
+ ))}
+
+
+
+ Cancel
+
Save and Refresh