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.
This commit is contained in:
SergeantPanda 2025-07-13 15:59:25 -05:00
parent 2cf9ade105
commit ea81cfb1af
10 changed files with 334 additions and 41 deletions

View file

@ -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),
),
]

View file

@ -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")

View file

@ -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())

View file

@ -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"""

View file

@ -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

View file

@ -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',

View file

@ -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);

View file

@ -49,7 +49,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 +72,7 @@ export default function M3URefreshNotification() {
navigate('/sources');
}}
>
Edit Groups
Configure Groups
</Button>
</Group>
</Stack>

View file

@ -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

View file

@ -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 }) => {
<Modal
opened={isOpen}
onClose={onClose}
title="M3U Group Filter"
size={1000}
title="M3U Group Filter & Auto Channel Sync"
size={1200}
>
<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 }}
@ -113,41 +149,77 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
Deselect Visible
</Button>
</Flex>
<SimpleGrid cols={4}>
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
<Stack spacing="xs" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{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) => (
<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>
<Group key={group.channel_group} spacing="md" style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
backgroundColor: group.enabled ? '#f8f9fa' : '#f5f5f5'
}}>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="sm"
leftSection={
group.enabled ? (
<CircleCheck size={16} />
) : (
<CircleX size={16} />
)
}
style={{ minWidth: '140px' }}
>
<Text size="sm" truncate style={{ maxWidth: '120px' }}>
{group.name}
</Text>
</Button>
{/* Auto Sync Checkbox */}
<Checkbox
label="Auto Channel Sync"
checked={group.auto_channel_sync && group.enabled}
disabled={!group.enabled}
onChange={() => toggleAutoSync(group.channel_group)}
size="sm"
/>
{/* Channel Start Number Input */}
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) => updateChannelStart(group.channel_group, value)}
disabled={!group.enabled || !group.auto_channel_sync}
min={1}
step={1}
size="sm"
style={{ width: '120px' }}
precision={1}
/>
</Group>
))}
</SimpleGrid>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
variant="filled"
color="blue"
disabled={isLoading}
size="small"
onClick={submit}
>
Save and Refresh