mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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:
parent
2cf9ade105
commit
ea81cfb1af
10 changed files with 334 additions and 41 deletions
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue