Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into logo-manager

This commit is contained in:
SergeantPanda 2025-07-17 19:56:11 -05:00
commit 122b902f0f
6 changed files with 216 additions and 43 deletions

View file

@ -321,13 +321,34 @@ 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)
custom_properties = serializers.JSONField(required=False)
class Meta:
model = ChannelGroupM3UAccount
fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start"]
fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"]
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
def to_representation(self, instance):
ret = super().to_representation(instance)
# Ensure custom_properties is always a dict or None
val = ret.get("custom_properties")
if isinstance(val, str):
import json
try:
ret["custom_properties"] = json.loads(val)
except Exception:
ret["custom_properties"] = None
return ret
def to_internal_value(self, data):
# Accept both dict and JSON string for custom_properties
val = data.get("custom_properties")
if isinstance(val, str):
import json
try:
data["custom_properties"] = json.loads(val)
except Exception:
pass
return super().to_internal_value(data)
class RecordingSerializer(serializers.ModelSerializer):

View file

@ -15,6 +15,7 @@ import os
from rest_framework.decorators import action
from django.conf import settings
from .tasks import refresh_m3u_groups
import json
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
@ -154,6 +155,7 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
enabled = setting.get("enabled", True)
auto_sync = setting.get("auto_channel_sync", False)
sync_start = setting.get("auto_sync_channel_start")
custom_properties = setting.get("custom_properties", {})
if group_id:
ChannelGroupM3UAccount.objects.update_or_create(
@ -163,6 +165,11 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
"enabled": enabled,
"auto_channel_sync": auto_sync,
"auto_sync_channel_start": sync_start,
"custom_properties": (
custom_properties
if isinstance(custom_properties, str)
else json.dumps(custom_properties)
),
},
)

View file

@ -847,7 +847,6 @@ def sync_auto_channels(account_id):
"""
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)
@ -868,6 +867,28 @@ def sync_auto_channels(account_id):
channel_group = group_relation.channel_group
start_number = group_relation.auto_sync_channel_start or 1.0
# Get force_dummy_epg and group_override from group custom_properties
group_custom_props = {}
force_dummy_epg = False
override_group_id = None
if group_relation.custom_properties:
try:
group_custom_props = json.loads(group_relation.custom_properties)
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
override_group_id = group_custom_props.get("group_override")
except Exception:
force_dummy_epg = False
override_group_id = None
# Determine which group to use for created channels
target_group = channel_group
if override_group_id:
try:
target_group = ChannelGroup.objects.get(id=override_group_id)
logger.info(f"Using override group '{target_group.name}' instead of '{channel_group.name}' for auto-created channels")
except ChannelGroup.DoesNotExist:
logger.warning(f"Override group with ID {override_group_id} not found, using original group '{channel_group.name}'")
logger.info(f"Processing auto sync for group: {channel_group.name} (start: {start_number})")
# Get all current streams in this group for this M3U account
@ -876,20 +897,22 @@ def sync_auto_channels(account_id):
channel_group=channel_group
).order_by('name')
# Get existing auto-created channels for this account in this group
# Get existing auto-created channels for this account (regardless of current group)
# We'll find them by their stream associations instead of just group location
existing_channels = Channel.objects.filter(
channel_group=channel_group,
auto_created=True,
auto_created_by=account
).select_related('logo', 'epg_data')
# Create mapping of existing channels by their associated stream
# This approach finds channels even if they've been moved to different groups
existing_channel_map = {}
for channel in existing_channels:
# Get streams associated with this channel that belong to our M3U account
# Get streams associated with this channel that belong to our M3U account and original group
channel_streams = ChannelStream.objects.filter(
channel=channel,
stream__m3u_account=account
stream__m3u_account=account,
stream__channel_group=channel_group # Match streams from the original group
).select_related('stream')
# Map each of our M3U account's streams to this channel
@ -903,9 +926,10 @@ def sync_auto_channels(account_id):
if not current_streams.exists():
logger.debug(f"No streams found in group {channel_group.name}")
# Delete all existing auto channels if no streams
if existing_channels.exists():
deleted_count = existing_channels.count()
existing_channels.delete()
channels_to_delete = [ch for ch in existing_channel_map.values()]
if channels_to_delete:
deleted_count = len(channels_to_delete)
Channel.objects.filter(id__in=[ch.id for ch in channels_to_delete]).delete()
channels_deleted += deleted_count
logger.debug(f"Deleted {deleted_count} auto channels (no streams remaining)")
continue
@ -941,6 +965,12 @@ def sync_auto_channels(account_id):
existing_channel.tvc_guide_stationid = tvc_guide_stationid
channel_updated = True
# Check if channel group needs to be updated (in case override was added/changed)
if existing_channel.channel_group != target_group:
existing_channel.channel_group = target_group
channel_updated = True
logger.info(f"Moved auto channel '{existing_channel.name}' from '{existing_channel.channel_group.name if existing_channel.channel_group else 'None'}' to '{target_group.name}'")
# Handle logo updates
current_logo = None
if stream.logo_url:
@ -956,7 +986,7 @@ def sync_auto_channels(account_id):
# Handle EPG data updates
current_epg_data = None
if stream.tvg_id:
if stream.tvg_id and not force_dummy_epg:
current_epg_data = EPGData.objects.filter(tvg_id=stream.tvg_id).first()
if existing_channel.epg_data != current_epg_data:
@ -974,13 +1004,13 @@ def sync_auto_channels(account_id):
while Channel.objects.filter(channel_number=current_channel_number).exists():
current_channel_number += 0.1
# Create the channel with auto-created tracking
# Create the channel with auto-created tracking in the target group
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,
channel_group=target_group, # Use target group (could be override)
user_level=0, # Default user level
auto_created=True, # Mark as auto-created
auto_created_by=account # Track which M3U account created it
@ -994,11 +1024,14 @@ def sync_auto_channels(account_id):
)
# Try to match EPG data
if stream.tvg_id:
if stream.tvg_id and not force_dummy_epg:
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'])
elif stream.tvg_id and force_dummy_epg:
channel.epg_data = None
channel.save(update_fields=['epg_data'])
# Handle logo
if stream.logo_url:

View file

@ -739,7 +739,9 @@ export default class API {
method: 'PATCH',
body: { group_settings: groupSettings },
});
// Fetch the updated playlist and update the store
const updatedPlaylist = await API.getPlaylist(playlistId);
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
return response;
} catch (e) {
errorNotification('Failed to update M3U group settings', e);
@ -781,7 +783,6 @@ export default class API {
const response = await request(`${host}/api/m3u/refresh/${id}/`, {
method: 'POST',
});
return response;
} catch (e) {
errorNotification('Failed to refresh M3U account', e);

View file

@ -43,12 +43,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
}
setGroupStates(
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.channel_groups.map((group) => {
// Parse custom_properties if present
let customProps = {};
if (group.custom_properties) {
try {
customProps = typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch (e) {
customProps = {};
}
}
return {
...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,
custom_properties: customProps,
};
})
);
}, [playlist, channelGroups]);
@ -79,11 +93,36 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
);
};
// Toggle force_dummy_epg in custom_properties for a group
const toggleForceDummyEPG = (id) => {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group == id) {
const customProps = { ...(state.custom_properties || {}) };
customProps.force_dummy_epg = !customProps.force_dummy_epg;
return {
...state,
custom_properties: customProps,
};
}
return state;
})
);
};
const submit = async () => {
setIsLoading(true);
try {
// Prepare groupStates for API: custom_properties must be stringified
const payload = groupStates.map((state) => ({
...state,
custom_properties: state.custom_properties
? JSON.stringify(state.custom_properties)
: undefined,
}));
// Update group settings via API endpoint
await API.updateM3UGroupSettings(playlist.id, groupStates);
await API.updateM3UGroupSettings(playlist.id, payload);
// Show notification about the refresh process
notifications.show({
@ -215,24 +254,95 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
{/* 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"
/>
<Flex align="center" gap="xs">
<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 && (
<Checkbox
label="Force Dummy EPG"
checked={!!(group.custom_properties && group.custom_properties.force_dummy_epg)}
onChange={() => toggleForceDummyEPG(group.channel_group)}
size="xs"
/>
)}
</Flex>
{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}
/>
<>
<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}
/>
{/* Override Channel Group */}
<Flex align="center" gap="xs">
<Checkbox
checked={!!(group.custom_properties && Object.prototype.hasOwnProperty.call(group.custom_properties, 'group_override'))}
onChange={(event) => {
const isEnabled = event.currentTarget.checked;
setGroupStates(
groupStates.map((state) => {
if (state.channel_group == group.channel_group) {
const newCustomProps = { ...(state.custom_properties || {}) };
if (isEnabled) {
newCustomProps.group_override = null;
} else {
delete newCustomProps.group_override;
}
return {
...state,
custom_properties: newCustomProps,
};
}
return state;
})
);
}}
size="xs"
/>
<Select
label="Override Channel Group"
placeholder="Choose group..."
value={group.custom_properties?.group_override?.toString() || null}
onChange={(value) => {
const newValue = value ? parseInt(value) : null;
setGroupStates(
groupStates.map((state) => {
if (state.channel_group == group.channel_group) {
return {
...state,
custom_properties: {
...state.custom_properties,
group_override: newValue,
},
};
}
return state;
})
);
}}
data={Object.values(channelGroups).map((g) => ({
value: g.id.toString(),
label: g.name,
}))}
disabled={!(group.custom_properties && Object.prototype.hasOwnProperty.call(group.custom_properties, 'group_override'))}
clearable
searchable
size="xs"
style={{ flex: 1 }}
/>
</Flex>
</>
)}
</Stack>
</Group>
@ -259,4 +369,4 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
);
};
export default M3UGroupFilter;
export default M3UGroupFilter;

View file

@ -288,7 +288,8 @@ const ChannelsTable = ({ }) => {
const [isLoading, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase); const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
@ -308,7 +309,7 @@ const ChannelsTable = ({ }) => {
});
/**
* Dereived variables
* Derived variables
*/
const activeGroupIds = new Set(
Object.values(channels).map((channel) => channel.channel_group_id)