Enhancement: Add custom logo support for channel groups in Auto Sync Channels.

Closes #555
This commit is contained in:
SergeantPanda 2025-10-17 10:03:21 -05:00
parent d3d7f3c733
commit ca8e9d0143
2 changed files with 337 additions and 17 deletions

View file

@ -1557,6 +1557,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
channel_sort_order = None
channel_sort_reverse = False
stream_profile_id = None
custom_logo_id = None
if group_relation.custom_properties:
group_custom_props = group_relation.custom_properties
force_dummy_epg = group_custom_props.get("force_dummy_epg", False)
@ -1572,6 +1573,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
"channel_sort_reverse", False
)
stream_profile_id = group_custom_props.get("stream_profile_id")
custom_logo_id = group_custom_props.get("custom_logo_id")
# Determine which group to use for created channels
target_group = channel_group
@ -1947,7 +1949,28 @@ def sync_auto_channels(account_id, scan_start_time=None):
channel.save(update_fields=["epg_data"])
# Handle logo
if stream.logo_url:
if custom_logo_id:
# Use the custom logo specified in group settings
from apps.channels.models import Logo
try:
custom_logo = Logo.objects.get(id=custom_logo_id)
channel.logo = custom_logo
channel.save(update_fields=["logo"])
except Logo.DoesNotExist:
logger.warning(
f"Custom logo with ID {custom_logo_id} not found, falling back to stream logo"
)
# Fall back to stream logo if custom logo not found
if stream.logo_url:
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"])
elif stream.logo_url:
from apps.channels.models import Logo
logo, _ = Logo.objects.get_or_create(

View file

@ -16,11 +16,19 @@ import {
Box,
MultiSelect,
Tooltip,
Popover,
ScrollArea,
Center,
} from '@mantine/core';
import { Info } from 'lucide-react';
import useChannelsStore from '../../store/channels';
import useStreamProfilesStore from '../../store/streamProfiles';
import { CircleCheck, CircleX } from 'lucide-react';
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import { FixedSizeList as List } from 'react-window';
import LazyLogo from '../LazyLogo';
import LogoForm from './Logo';
import logo from '../../images/logo.png';
// Custom item component for MultiSelect with tooltip
const OptionWithTooltip = forwardRef(
@ -46,6 +54,20 @@ const LiveGroupFilter = ({
const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles);
const [groupFilter, setGroupFilter] = useState('');
// Logo selection functionality
const {
logos: channelLogos,
ensureLogosLoaded,
isLoading: logosLoading,
} = useChannelLogoSelection();
const [logoModalOpen, setLogoModalOpen] = useState(false);
const [currentEditingGroupId, setCurrentEditingGroupId] = useState(null);
// Ensure logos are loaded when component mounts
useEffect(() => {
ensureLogosLoaded();
}, [ensureLogosLoaded]);
// Fetch stream profiles when component mounts
useEffect(() => {
if (streamProfiles.length === 0) {
@ -68,7 +90,7 @@ const LiveGroupFilter = ({
typeof group.custom_properties === 'string'
? JSON.parse(group.custom_properties)
: group.custom_properties;
} catch (e) {
} catch {
customProps = {};
}
}
@ -115,21 +137,27 @@ const LiveGroupFilter = ({
);
};
// 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;
})
);
// Handle logo selection from LogoForm
const handleLogoSuccess = ({ logo }) => {
if (logo && logo.id && currentEditingGroupId !== null) {
setGroupStates(
groupStates.map((state) => {
if (state.channel_group === currentEditingGroupId) {
return {
...state,
custom_properties: {
...state.custom_properties,
custom_logo_id: logo.id,
},
};
}
return state;
})
);
ensureLogosLoaded(); // Refresh logos
}
setLogoModalOpen(false);
setCurrentEditingGroupId(null);
};
const selectAll = () => {
@ -311,6 +339,12 @@ const LiveGroupFilter = ({
description:
'Assign a specific stream profile to all channels in this group during auto sync',
},
{
value: 'custom_logo',
label: 'Custom Logo',
description:
'Assign a custom logo to all auto-synced channels in this group',
},
]}
itemComponent={OptionWithTooltip}
value={(() => {
@ -356,6 +390,12 @@ const LiveGroupFilter = ({
) {
selectedValues.push('stream_profile_assignment');
}
if (
group.custom_properties?.custom_logo_id !==
undefined
) {
selectedValues.push('custom_logo');
}
return selectedValues;
})()}
onChange={(values) => {
@ -475,6 +515,17 @@ const LiveGroupFilter = ({
delete newCustomProps.stream_profile_id;
}
// Handle custom_logo
if (selectedOptions.includes('custom_logo')) {
if (
newCustomProps.custom_logo_id === undefined
) {
newCustomProps.custom_logo_id = null;
}
} else {
delete newCustomProps.custom_logo_id;
}
return {
...state,
custom_properties: newCustomProps,
@ -801,6 +852,242 @@ const LiveGroupFilter = ({
/>
</Tooltip>
)}
{/* Show logo selector only if custom_logo is selected */}
{group.custom_properties?.custom_logo_id !==
undefined && (
<Box>
<Group justify="space-between">
<Popover
opened={group.logoPopoverOpened || false}
onChange={(opened) => {
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
return {
...state,
logoPopoverOpened: opened,
};
}
return state;
})
);
if (opened) {
ensureLogosLoaded();
}
}}
withArrow
>
<Popover.Target>
<TextInput
label="Custom Logo"
readOnly
value={
channelLogos[
group.custom_properties?.custom_logo_id
]?.name || 'Default'
}
onClick={() => {
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
return {
...state,
logoPopoverOpened: true,
};
}
return {
...state,
logoPopoverOpened: false,
};
})
);
}}
size="xs"
/>
</Popover.Target>
<Popover.Dropdown
onMouseDown={(e) => e.stopPropagation()}
>
<Group>
<TextInput
placeholder="Filter logos..."
size="xs"
value={group.logoFilter || ''}
onChange={(e) => {
const val = e.currentTarget.value;
setGroupStates(
groupStates.map((state) =>
state.channel_group ===
group.channel_group
? {
...state,
logoFilter: val,
}
: state
)
);
}}
/>
{logosLoading && (
<Text size="xs" c="dimmed">
Loading...
</Text>
)}
</Group>
<ScrollArea style={{ height: 200 }}>
{(() => {
const logoOptions = [
{ id: '0', name: 'Default' },
...Object.values(channelLogos),
];
const filteredLogos = logoOptions.filter(
(logo) =>
logo.name
.toLowerCase()
.includes(
(
group.logoFilter || ''
).toLowerCase()
)
);
if (filteredLogos.length === 0) {
return (
<Center style={{ height: 200 }}>
<Text size="sm" c="dimmed">
{group.logoFilter
? 'No logos match your filter'
: 'No logos available'}
</Text>
</Center>
);
}
return (
<List
height={200}
itemCount={filteredLogos.length}
itemSize={55}
style={{ width: '100%' }}
>
{({ index, style }) => {
const logoItem = filteredLogos[index];
return (
<div
style={{
...style,
cursor: 'pointer',
padding: '5px',
borderRadius: '4px',
}}
onClick={() => {
setGroupStates(
groupStates.map((state) => {
if (
state.channel_group ===
group.channel_group
) {
return {
...state,
custom_properties: {
...state.custom_properties,
custom_logo_id:
logoItem.id,
},
logoPopoverOpened: false,
};
}
return state;
})
);
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'rgb(68, 68, 68)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
'transparent';
}}
>
<Center
style={{
flexDirection: 'column',
gap: '2px',
}}
>
<img
src={
logoItem.cache_url || logo
}
height="30"
style={{
maxWidth: 80,
objectFit: 'contain',
}}
alt={logoItem.name || 'Logo'}
onError={(e) => {
if (e.target.src !== logo) {
e.target.src = logo;
}
}}
/>
<Text
size="xs"
c="dimmed"
ta="center"
style={{
maxWidth: 80,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{logoItem.name || 'Default'}
</Text>
</Center>
</div>
);
}}
</List>
);
})()}
</ScrollArea>
</Popover.Dropdown>
</Popover>
<Stack gap="xs" align="center">
<LazyLogo
logoId={group.custom_properties?.custom_logo_id}
alt="custom logo"
style={{ height: 40 }}
/>
</Stack>
</Group>
<Button
onClick={() => {
setCurrentEditingGroupId(group.channel_group);
setLogoModalOpen(true);
}}
fullWidth
variant="default"
size="xs"
mt="xs"
>
Upload or Create Logo
</Button>
</Box>
)}
</>
)}
</Stack>
@ -808,6 +1095,16 @@ const LiveGroupFilter = ({
))}
</SimpleGrid>
</Box>
{/* Logo Upload Modal */}
<LogoForm
isOpen={logoModalOpen}
onClose={() => {
setLogoModalOpen(false);
setCurrentEditingGroupId(null);
}}
onSuccess={handleLogoSuccess}
/>
</Stack>
);
};