Merge pull request #246 from Dispatcharr/auto-sync-groups

Auto sync groups
This commit is contained in:
SergeantPanda 2025-07-13 19:19:35 -05:00 committed by GitHub
commit 1a475a29d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 396 additions and 49 deletions

View file

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

View file

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

View file

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

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

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

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

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,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 }) => {
<Modal
opened={isOpen}
onClose={onClose}
title="M3U Group Filter"
title="M3U Group Filter & Auto Channel Sync"
size={1000}
styles={{ content: { '--mantine-color-body': '#27272A' } }}
>
<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 }}
size="xs"
/>
<Button variant="default" size="sm" onClick={selectAll}>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
<Button variant="default" size="sm" onClick={deselectAll}>
<Button variant="default" size="xs" onClick={deselectAll}>
Deselect Visible
</Button>
</Flex>
<SimpleGrid cols={4}>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name > 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>
))}
</SimpleGrid>
<Divider label="Groups & Auto Sync Settings" labelPosition="center" />
<Box style={{ maxHeight: '50vh', overflowY: 'auto' }}>
<SimpleGrid
cols={{ base: 1, sm: 2, md: 3 }}
spacing="xs"
verticalSpacing="xs"
>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group key={group.channel_group} spacing="xs" style={{
padding: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: group.enabled ? '#2A2A2E' : '#1E1E22',
flexDirection: 'column',
alignItems: 'stretch'
}}>
{/* Group Enable/Disable Button */}
<Button
color={group.enabled ? 'green' : 'gray'}
variant="filled"
onClick={() => toggleGroupEnabled(group.channel_group)}
radius="md"
size="xs"
leftSection={
group.enabled ? (
<CircleCheck size={14} />
) : (
<CircleX size={14} />
)
}
fullWidth
>
<Text size="xs" truncate>
{group.name}
</Text>
</Button>
{/* Auto Sync Controls */}
<Stack spacing={4}>
<Checkbox
label="Auto Channel Sync"
checked={group.auto_channel_sync && group.enabled}
disabled={!group.enabled}
onChange={() => toggleAutoSync(group.channel_group)}
size="xs"
/>
{group.auto_channel_sync && group.enabled && (
<NumberInput
label="Start Channel #"
value={group.auto_sync_channel_start}
onChange={(value) => updateChannelStart(group.channel_group, value)}
min={1}
step={1}
size="xs"
precision={1}
/>
)}
</Stack>
</Group>
))}
</SimpleGrid>
</Box>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button variant="default" onClick={onClose} size="xs">
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
variant="filled"
color="blue"
disabled={isLoading}
size="small"
onClick={submit}
>
Save and Refresh