auto-assign numbers now configurable by selection, channel enable switch now applies to all selected

This commit is contained in:
dekzter 2025-05-08 12:43:27 -04:00
parent e63a66bf57
commit 4ebfde2797
6 changed files with 152 additions and 68 deletions

View file

@ -185,6 +185,30 @@ class ChannelViewSet(viewsets.ModelViewSet):
context['include_streams'] = include_streams
return context
@action(detail=False, methods=['patch'], url_path='edit/bulk')
def edit_bulk(self, request):
data_list = request.data
if not isinstance(data_list, list):
return Response({"error": "Expected a list of channel objects objects"}, status=status.HTTP_400_BAD_REQUEST)
updated_channels = []
try:
with transaction.atomic():
for item in data_list:
channel = Channel.objects.id(id=item.pop('id'))
for key, value in item.items():
setattr(channel, key, value)
channel.save(update_fields=item.keys())
updated_channels.append(channel)
except Exception as e:
logger.error("Error during bulk channel edit", e)
return Response({"error": e}, status=500)
response_data = ChannelSerializer(updated_channels, many=True).data
return Response(response_data, status=status.HTTP_200_OK)
@action(detail=False, methods=['get'], url_path='ids')
def get_ids(self, request, *args, **kwargs):
# Get the filtered queryset
@ -204,12 +228,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_order"],
required=["channel_ids"],
properties={
"channel_order": openapi.Schema(
"starting_number": openapi.Schema(type=openapi.TYPE_STRING, description="Starting channel number to assign"),
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of channel IDs in the new order"
description="Channel IDs to assign"
)
}
),
@ -218,9 +243,11 @@ class ChannelViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=['post'], url_path='assign')
def assign(self, request):
with transaction.atomic():
channel_order = request.data.get('channel_order', [])
for order, channel_id in enumerate(channel_order, start=1):
Channel.objects.filter(id=channel_id).update(channel_number=order)
channel_ids = request.data.get('channel_ids', [])
channel_num = request.data.get('starting_number', 1)
for channel_id in channel_ids:
Channel.objects.filter(id=channel_id).update(channel_number=channel_num)
channel_num = channel_num + 1
return Response({"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK)

View file

@ -378,6 +378,31 @@ export default class API {
}
}
static async updateChannels(ids, values) {
const body = [];
for (const id in ids) {
body.push({
id,
...values,
});
}
try {
const response = await request(
`${host}/api/channels/channels/edit/bulk/`,
{
method: 'PATCH',
body,
}
);
useChannelsStore.getState().updateChannels(response);
return response;
} catch (e) {
errorNotification('Failed to update channels', e);
}
}
static async setChannelEPG(channelId, epgDataId) {
try {
const response = await request(
@ -408,16 +433,13 @@ export default class API {
}
}
static async assignChannelNumbers(channelIds) {
static async assignChannelNumbers(channelIds, startingNum = 1) {
try {
const response = await request(`${host}/api/channels/channels/assign/`, {
method: 'POST',
body: { channel_order: channelIds },
body: { channel_ids: channelIds, starting_number: startingNum },
});
// Optionally refesh the channel list in Zustand
// await useChannelsStore.getState().fetchChannels();
return response;
} catch (e) {
errorNotification('Failed to assign channel #s', e);
@ -733,7 +755,11 @@ export default class API {
try {
// If this is just toggling the active state, make a simpler request
if (isToggle && 'is_active' in payload && Object.keys(payload).length === 1) {
if (
isToggle &&
'is_active' in payload &&
Object.keys(payload).length === 1
) {
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
method: 'PATCH',
body: { is_active: payload.is_active },
@ -830,7 +856,11 @@ export default class API {
try {
// If this is just toggling the active state, make a simpler request
if (isToggle && 'is_active' in payload && Object.keys(payload).length === 1) {
if (
isToggle &&
'is_active' in payload &&
Object.keys(payload).length === 1
) {
const response = await request(`${host}/api/epg/sources/${id}/`, {
method: 'PATCH',
body: { is_active: payload.is_active },
@ -1344,7 +1374,9 @@ export default class API {
static async getChannel(id) {
try {
const response = await request(`${host}/api/channels/channels/${id}/?include_streams=true`);
const response = await request(
`${host}/api/channels/channels/${id}/?include_streams=true`
);
return response;
} catch (e) {
errorNotification('Failed to fetch channel details', e);

View file

@ -57,7 +57,7 @@ const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
const ChannelEnabledSwitch = React.memo(
({ rowId, selectedProfileId, toggleChannelEnabled }) => {
({ rowId, selectedProfileId, selectedTableIds }) => {
// Directly extract the channels set once to avoid re-renders on every change.
const isEnabled = useChannelsStore(
useCallback(
@ -68,9 +68,17 @@ const ChannelEnabledSwitch = React.memo(
)
);
const handleToggle = useCallback(() => {
toggleChannelEnabled([rowId], !isEnabled);
}, [rowId, isEnabled, toggleChannelEnabled]);
const handleToggle = () => {
if (selectedTableIds.length > 1) {
API.updateProfileChannels(
selectedTableIds,
selectedProfileId,
!isEnabled
);
} else {
API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
}
};
return (
<Center style={{ width: '100%' }}>
@ -465,21 +473,6 @@ const ChannelsTable = ({}) => {
});
};
const toggleChannelEnabled = useCallback(
async (channelIds, enabled) => {
if (channelIds.length == 1) {
await API.updateProfileChannel(
channelIds[0],
selectedProfileId,
enabled
);
} else {
await API.updateProfileChannels(channelIds, selectedProfileId, enabled);
}
},
[selectedProfileId]
);
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
@ -547,22 +540,6 @@ const ChannelsTable = ({}) => {
}
};
const EnabledHeaderSwitch = useCallback(() => {
let enabled = false;
for (const id of selectedChannelIds) {
if (selectedProfileChannelIds.has(id)) {
enabled = true;
break;
}
}
const toggleSelected = () => {
toggleChannelEnabled(selectedChannelIds, !enabled);
};
return <Switch size="xs" checked={enabled} onChange={toggleSelected} />;
}, [selectedChannelIds, selectedProfileChannelIds, data]);
/**
* useEffect
*/
@ -602,12 +579,12 @@ const ChannelsTable = ({}) => {
{
id: 'enabled',
size: 45,
cell: ({ row }) => {
cell: ({ row, table }) => {
return (
<ChannelEnabledSwitch
rowId={row.original.id}
selectedProfileId={selectedProfileId}
toggleChannelEnabled={toggleChannelEnabled}
selectedTableIds={table.getState().selectedTableIds}
/>
);
},
@ -716,9 +693,6 @@ const ChannelsTable = ({}) => {
switch (header.id) {
case 'enabled':
// if (selectedProfileId !== '0' && table.selectedTableIds.length > 0) {
// return EnabledHeaderSwitch();
// }
return (
<Center style={{ width: '100%' }}>
<ScanEye size="16" />

View file

@ -5,8 +5,10 @@ import {
Button,
Flex,
Group,
NumberInput,
Popover,
Select,
Text,
TextInput,
Tooltip,
useMantineTheme,
@ -14,6 +16,7 @@ import {
import {
ArrowDown01,
Binary,
Check,
CircleCheck,
SquareMinus,
SquarePlus,
@ -87,6 +90,8 @@ const ChannelTableHeader = ({
}) => {
const theme = useMantineTheme();
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
@ -110,11 +115,11 @@ const ChannelTableHeader = ({
const assignChannels = async () => {
try {
// Get row order from the table
const rowOrder = rows.map((row) => row.original.id);
// Call our custom API endpoint
const result = await API.assignChannelNumbers(rowOrder);
const result = await API.assignChannelNumbers(
selectedTableIds,
channelNumAssignmentStart
);
// We might get { message: "Channels have been auto-assigned!" }
notifications.show({
@ -194,15 +199,38 @@ const ChannelTableHeader = ({
</Button>
<Tooltip label="Assign Channel #s">
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
p={5}
>
Assign
</Button>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
p={5}
disabled={selectedTableIds.length == 0}
>
Assign
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<Text>Start #</Text>
<NumberInput
value={channelNumAssignmentStart}
onChange={setChannelNumAssignmentStart}
size="small"
style={{ width: 50 }}
/>
<ActionIcon
size="xs"
color={theme.tailwind.green[5]}
variant="transparent"
onClick={assignChannels}
>
<Check />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
</Tooltip>
<Tooltip label="Auto-Match EPG">

View file

@ -17,6 +17,7 @@ const useTable = ({
expandedRowRenderer = () => <></>,
onRowSelectionChange = null,
getExpandedRowHeight = null,
state = [],
...options
}) => {
const [selectedTableIds, setSelectedTableIds] = useState([]);
@ -155,7 +156,9 @@ const useTable = ({
const rangeIds = allRowIds.slice(startIndex, endIndex + 1);
// Preserve existing selections outside the range
const idsOutsideRange = selectedTableIds.filter(id => !rangeIds.includes(id));
const idsOutsideRange = selectedTableIds.filter(
(id) => !rangeIds.includes(id)
);
const newSelection = [...new Set([...rangeIds, ...idsOutsideRange])];
updateSelectedTableIds(newSelection);

View file

@ -151,6 +151,26 @@ const useChannelsStore = create((set, get) => ({
},
})),
updateChannels: (channels) => {
const channelsByUUID = {};
const updatedChannels = channels.reduce((acc, chan) => {
channelsByUUID[chan.uuid] = chan;
acc[chan.id] = chan;
return acc;
}, {});
return set((state) => ({
channels: {
...state.channels,
...updatedChannels,
},
channelsByUUID: {
...state.channelsByUUID,
...channelsByUUID,
},
}));
},
removeChannels: (channelIds) => {
set((state) => {
const updatedChannels = { ...state.channels };