Merge pull request #736 from Dispatcharr/hide-disabled-channels

Advanced Filtering
This commit is contained in:
dekzter 2025-12-12 08:05:21 -05:00 committed by GitHub
commit ed61ac656a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 135 additions and 18 deletions

View file

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup
- XtreamCodes EPG API now returns correct date/time format for start/end fields and proper string types for timestamps and channel_id
## [0.14.0] - 2025-12-09
### Added

View file

@ -8,6 +8,7 @@ from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Q
import os, json, requests, logging
from apps.accounts.permissions import (
Authenticated,
@ -419,10 +420,36 @@ class ChannelViewSet(viewsets.ModelViewSet):
group_names = channel_group.split(",")
qs = qs.filter(channel_group__name__in=group_names)
if self.request.user.user_level < 10:
qs = qs.filter(user_level__lte=self.request.user.user_level)
filters = {}
q_filters = Q()
return qs
channel_profile_id = self.request.query_params.get("channel_profile_id")
show_disabled_param = self.request.query_params.get("show_disabled", None)
only_streamless = self.request.query_params.get("only_streamless", None)
if channel_profile_id:
try:
profile_id_int = int(channel_profile_id)
filters["channelprofilemembership__channel_profile_id"] = profile_id_int
if show_disabled_param is None:
filters["channelprofilemembership__enabled"] = True
except (ValueError, TypeError):
# Ignore invalid profile id values
pass
if only_streamless:
q_filters &= Q(streams__isnull=True)
if self.request.user.user_level < 10:
filters["user_level__lte"] = self.request.user.user_level
if filters:
qs = qs.filter(**filters)
if q_filters:
qs = qs.filter(q_filters)
return qs.distinct()
def get_serializer_context(self):
context = super().get_serializer_context()

View file

@ -2316,18 +2316,18 @@ def xc_get_epg(request, user, short=False):
"epg_id": f"{epg_id}",
"title": base64.b64encode(title.encode()).decode(),
"lang": "",
"start": start.strftime("%Y%m%d%H%M%S"),
"end": end.strftime("%Y%m%d%H%M%S"),
"start": start.strftime("%Y-%m-%d %H:%M:%S"),
"end": end.strftime("%Y-%m-%d %H:%M:%S"),
"description": base64.b64encode(description.encode()).decode(),
"channel_id": channel_num_int,
"start_timestamp": int(start.timestamp()),
"stop_timestamp": int(end.timestamp()),
"channel_id": str(channel_num_int),
"start_timestamp": str(int(start.timestamp())),
"stop_timestamp": str(int(end.timestamp())),
"stream_id": f"{channel_id}",
}
if short == False:
program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0
program_output["has_archive"] = "0"
program_output["has_archive"] = 0
output['epg_listings'].append(program_output)

View file

@ -29,9 +29,17 @@ if [ "$(id -u)" = "0" ] && [ -d "/app" ]; then
chown $PUID:$PGID /app
fi
fi
# Configure nginx port
sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default
# Configure nginx based on IPv6 availability
if ip -6 addr show | grep -q "inet6"; then
echo "✅ IPv6 is available, enabling IPv6 in nginx"
else
echo "⚠️ IPv6 not available, disabling IPv6 in nginx"
sed -i '/listen \[::\]:/d' /etc/nginx/sites-enabled/default
fi
# NOTE: mac doesn't run as root, so only manage permissions
# if this script is running as root
if [ "$(id -u)" = "0" ]; then

View file

@ -68,7 +68,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, selectedTableIds }) => {
({ rowId, selectedProfileId, selectedTableIds, setSelectedTableIds }) => {
// Directly extract the channels set once to avoid re-renders on every change.
const isEnabled = useChannelsStore(
useCallback(
@ -79,16 +79,20 @@ const ChannelEnabledSwitch = React.memo(
)
);
const handleToggle = () => {
const handleToggle = async () => {
if (selectedTableIds.length > 1) {
API.updateProfileChannels(
await API.updateProfileChannels(
selectedTableIds,
selectedProfileId,
!isEnabled
);
} else {
API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
await API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
}
setSelectedTableIds([]);
return API.requeryChannels();
};
return (
@ -289,6 +293,9 @@ const ChannelsTable = ({}) => {
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
);
const [showDisabled, setShowDisabled] = useState(true);
const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] =
useState(false);
const [paginationString, setPaginationString] = useState('');
const [filters, setFilters] = useState({
@ -369,6 +376,15 @@ const ChannelsTable = ({}) => {
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
params.append('include_streams', 'true');
if (selectedProfileId !== '0') {
params.append('channel_profile_id', selectedProfileId);
}
if (showDisabled === true) {
params.append('show_disabled', true);
}
if (showOnlyStreamlessChannels === true) {
params.append('only_streamless', true);
}
// Apply sorting
if (sorting.length > 0) {
@ -401,7 +417,14 @@ const ChannelsTable = ({}) => {
pageSize: pagination.pageSize,
});
setAllRowIds(ids);
}, [pagination, sorting, debouncedFilters]);
}, [
pagination,
sorting,
debouncedFilters,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
]);
const stopPropagation = useCallback((e) => {
e.stopPropagation();
@ -728,6 +751,7 @@ const ChannelsTable = ({}) => {
rowId={row.original.id}
selectedProfileId={selectedProfileId}
selectedTableIds={table.getState().selectedTableIds}
setSelectedTableIds={table.setSelectedTableIds}
/>
);
},
@ -1326,6 +1350,10 @@ const ChannelsTable = ({}) => {
deleteChannels={deleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
showDisabled={showDisabled}
setShowDisabled={setShowDisabled}
showOnlyStreamlessChannels={showOnlyStreamlessChannels}
setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels}
/>
{/* Table or ghost empty state inside Paper */}

View file

@ -12,20 +12,22 @@ import {
Text,
TextInput,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import {
ArrowDown01,
Binary,
Check,
CircleCheck,
Ellipsis,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
Settings,
Eye,
EyeOff,
Filter,
Square,
SquareCheck,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
@ -102,6 +104,10 @@ const ChannelTableHeader = ({
editChannel,
deleteChannels,
selectedTableIds,
showDisabled,
setShowDisabled,
showOnlyStreamlessChannels,
setShowOnlyStreamlessChannels,
}) => {
const theme = useMantineTheme();
@ -208,6 +214,14 @@ const ChannelTableHeader = ({
);
};
const toggleShowDisabled = () => {
setShowDisabled(!showDisabled);
};
const toggleShowOnlyStreamlessChannels = () => {
setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels);
};
return (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
@ -236,6 +250,41 @@ const ChannelTableHeader = ({
}}
>
<Flex gap={6}>
<Menu shadow="md" width={200}>
<Menu.Target>
<Button size="xs" variant="default" onClick={() => {}}>
<Filter size={18} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={toggleShowDisabled}
leftSection={
showDisabled ? <Eye size={18} /> : <EyeOff size={18} />
}
disabled={selectedProfileId === '0'}
>
<Text size="xs">
{showDisabled ? 'Hide Disabled' : 'Show Disabled'}
</Text>
</Menu.Item>
<Menu.Item
onClick={toggleShowOnlyStreamlessChannels}
leftSection={
showOnlyStreamlessChannels ? (
<SquareCheck size={18} />
) : (
<Square size={18} />
)
}
>
<Text size="xs">Only Empty Channels</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Button
leftSection={<SquarePen size={18} />}
variant="default"