From 43b55e2d9913a71ffec5a1998079e7ea2dd4be3c Mon Sep 17 00:00:00 2001 From: dekzter Date: Mon, 8 Dec 2025 08:38:39 -0500 Subject: [PATCH 1/4] first run at hiding disabled channels in channel profiles --- apps/channels/api_views.py | 21 ++++++++++++++++ .../src/components/tables/ChannelsTable.jsx | 24 +++++++++++++++---- .../ChannelsTable/ChannelTableHeader.jsx | 14 +++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index eccc5028..4cfe9777 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -419,6 +419,27 @@ class ChannelViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) + channel_profile_id = self.request.query_params.get("channel_profile_id") + show_disabled_param = self.request.query_params.get("show_disabled", None) + + if channel_profile_id: + try: + profile_id_int = int(channel_profile_id) + # If show_disabled is present, include all memberships for that profile. + # If absent, restrict to enabled=True. + if show_disabled_param is None: + qs = qs.filter( + channelprofilemembership__channel_profile_id=profile_id_int, + channelprofilemembership__enabled=True, + ) + else: + qs = qs.filter( + channelprofilemembership__channel_profile_id=profile_id_int + ) + except (ValueError, TypeError): + # Ignore invalid profile id values + pass + if self.request.user.user_level < 10: qs = qs.filter(user_level__lte=self.request.user.user_level) diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 9b9958f7..949b9760 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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,7 @@ const ChannelsTable = ({}) => { const [selectedProfile, setSelectedProfile] = useState( profiles[selectedProfileId] ); + const [showDisabled, setShowDisabled] = useState(true); const [paginationString, setPaginationString] = useState(''); const [filters, setFilters] = useState({ @@ -369,6 +374,12 @@ 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); + } // Apply sorting if (sorting.length > 0) { @@ -401,7 +412,7 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [pagination, sorting, debouncedFilters]); + }, [pagination, sorting, debouncedFilters, showDisabled, selectedProfileId]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -728,6 +739,7 @@ const ChannelsTable = ({}) => { rowId={row.original.id} selectedProfileId={selectedProfileId} selectedTableIds={table.getState().selectedTableIds} + setSelectedTableIds={table.setSelectedTableIds} /> ); }, @@ -1326,6 +1338,8 @@ const ChannelsTable = ({}) => { deleteChannels={deleteChannels} selectedTableIds={table.selectedTableIds} table={table} + showDisabled={showDisabled} + setShowDisabled={setShowDisabled} /> {/* Table or ghost empty state inside Paper */} diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index b7e04d7d..d3376b4d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -26,6 +26,8 @@ import { SquarePen, SquarePlus, Settings, + Eye, + EyeOff, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -102,6 +104,8 @@ const ChannelTableHeader = ({ editChannel, deleteChannels, selectedTableIds, + showDisabled, + setShowDisabled, }) => { const theme = useMantineTheme(); @@ -208,6 +212,10 @@ const ChannelTableHeader = ({ ); }; + const toggleShowDisabled = () => { + setShowDisabled(!showDisabled); + }; + return ( @@ -226,6 +234,12 @@ const ChannelTableHeader = ({ + + + + Date: Mon, 8 Dec 2025 20:12:44 -0600 Subject: [PATCH 2/4] Bug Fix: Remove ipv6 binding from nginx config if ipv6 is not available. --- CHANGELOG.md | 4 ++++ docker/init/03-init-dispatcharr.sh | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4716c250..389bb8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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 + ## [0.14.0] - 2025-12-09 ### Added diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 5fbef23d..da7d4484 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -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 From 514e7e06e4dfcdb8d24ed0eddfd3cf67cc2a7a49 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 8 Dec 2025 20:50:50 -0600 Subject: [PATCH 3/4] Bug fix: EPG API now returns correct date/time format for start/end fields and proper string types for timestamps and channel_id --- CHANGELOG.md | 1 + apps/output/views.py | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 389bb8ad..e363135f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/apps/output/views.py b/apps/output/views.py index bc2bace5..3a8406cb 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -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) From ea38c0b4b88bac1d89c186f4d17cd9f1dde0ef6d Mon Sep 17 00:00:00 2001 From: dekzter Date: Thu, 11 Dec 2025 11:54:41 -0500 Subject: [PATCH 4/4] advanced filtering for hiding disabled channels and viewing only empty channels --- apps/channels/api_views.py | 32 ++++++----- .../src/components/tables/ChannelsTable.jsx | 16 +++++- .../ChannelsTable/ChannelTableHeader.jsx | 53 +++++++++++++++---- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 4cfe9777..40063245 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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,31 +420,36 @@ class ChannelViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) + filters = {} + q_filters = Q() + 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) - # If show_disabled is present, include all memberships for that profile. - # If absent, restrict to enabled=True. + filters["channelprofilemembership__channel_profile_id"] = profile_id_int + if show_disabled_param is None: - qs = qs.filter( - channelprofilemembership__channel_profile_id=profile_id_int, - channelprofilemembership__enabled=True, - ) - else: - qs = qs.filter( - channelprofilemembership__channel_profile_id=profile_id_int - ) + filters["channelprofilemembership__enabled"] = True except (ValueError, TypeError): # Ignore invalid profile id values pass - if self.request.user.user_level < 10: - qs = qs.filter(user_level__lte=self.request.user.user_level) + if only_streamless: + q_filters &= Q(streams__isnull=True) - return qs + 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() diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 949b9760..7f82140f 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -294,6 +294,8 @@ const ChannelsTable = ({}) => { profiles[selectedProfileId] ); const [showDisabled, setShowDisabled] = useState(true); + const [showOnlyStreamlessChannels, setShowOnlyStreamlessChannels] = + useState(false); const [paginationString, setPaginationString] = useState(''); const [filters, setFilters] = useState({ @@ -380,6 +382,9 @@ const ChannelsTable = ({}) => { if (showDisabled === true) { params.append('show_disabled', true); } + if (showOnlyStreamlessChannels === true) { + params.append('only_streamless', true); + } // Apply sorting if (sorting.length > 0) { @@ -412,7 +417,14 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [pagination, sorting, debouncedFilters, showDisabled, selectedProfileId]); + }, [ + pagination, + sorting, + debouncedFilters, + showDisabled, + selectedProfileId, + showOnlyStreamlessChannels, + ]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -1340,6 +1352,8 @@ const ChannelsTable = ({}) => { table={table} showDisabled={showDisabled} setShowDisabled={setShowDisabled} + showOnlyStreamlessChannels={showOnlyStreamlessChannels} + setShowOnlyStreamlessChannels={setShowOnlyStreamlessChannels} /> {/* Table or ghost empty state inside Paper */} diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index d3376b4d..460ab12a 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -12,15 +12,12 @@ import { Text, TextInput, Tooltip, - UnstyledButton, useMantineTheme, } from '@mantine/core'; import { ArrowDown01, Binary, - Check, CircleCheck, - Ellipsis, EllipsisVertical, SquareMinus, SquarePen, @@ -28,6 +25,9 @@ import { Settings, Eye, EyeOff, + Filter, + Square, + SquareCheck, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -106,6 +106,8 @@ const ChannelTableHeader = ({ selectedTableIds, showDisabled, setShowDisabled, + showOnlyStreamlessChannels, + setShowOnlyStreamlessChannels, }) => { const theme = useMantineTheme(); @@ -216,6 +218,10 @@ const ChannelTableHeader = ({ setShowDisabled(!showDisabled); }; + const toggleShowOnlyStreamlessChannels = () => { + setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels); + }; + return ( @@ -234,12 +240,6 @@ const ChannelTableHeader = ({ - - - - + + + + + + + : + } + disabled={selectedProfileId === '0'} + > + + {showDisabled ? 'Hide Disabled' : 'Show Disabled'} + + + + + ) : ( + + ) + } + > + Only Empty Channels + + + +