From 56cf37d637c2072b30482ab7d7563c38b019409d Mon Sep 17 00:00:00 2001 From: James Blackwell Date: Fri, 12 Dec 2025 12:59:03 +0700 Subject: [PATCH 01/34] Give arguments to docker/build-dev.sh This command improves docker/build-dev.sh, providing a variety of arguments to assist building images -h for help -p push the build -r Specify a different registry, such as myname on dockerhub, or myregistry.local -a arch[,arch] cross build to one or more architectures; .e.g. -a linux/arm64,linux/amd64 --- CHANGELOG.md | 2 ++ docker/build-dev.sh | 72 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4716c250..67c27667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Update docker/dev-build.sh to support private registries, multiple architectures and pushing. Now [@jdblack](https://github.com/jblack). Now you can do things like `dev-build.sh -p -r my.private.registry -a linux/arm64,linux/amd64`. + ## [0.14.0] - 2025-12-09 ### Added diff --git a/docker/build-dev.sh b/docker/build-dev.sh index b02c314e..61640814 100755 --- a/docker/build-dev.sh +++ b/docker/build-dev.sh @@ -1,11 +1,65 @@ -#!/bin/bash -docker build --build-arg BRANCH=dev -t dispatcharr/dispatcharr:dev -f Dockerfile .. +#!/bin/bash +set -e + +# Default values +VERSION=$(python3 -c "import sys; sys.path.append('..'); import version; print(version.__version__)") +REGISTRY="dispatcharr" # Registry or private repo to push to +IMAGE="dispatcharr" # Image that we're building +BRANCH="dev" +ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64 +PUSH=false + +usage() { + cat <<- EOF + To test locally: + ./build-dev.sh + + To build and push to registry: + ./build-dev.sh -p + + To build and push to a private registry: + ./build-dev.sh -p -r myregistry:5000 + + To build for -both- x86_64 and arm_64: + ./build-dev.sh -p -a linux/amd64,linux/arm64 + + Do it all: + ./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64 +EOF +exit 0 +} + +# Parse options +while getopts "pr:a:b:i:h" opt; do + case $opt in + r) REGISTRY="$OPTARG" ;; + a) ARCH="--platform $OPTARG" ;; + b) BRANCH="$OPTARG" ;; + i) IMAGE="$OPTARG" ;; + p) PUSH=true ;; + h) usage ;; + \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; + esac +done + +BUILD_ARGS="BRANCH=$BRANCH" + +echo docker build --build-arg $BUILD_ARGS $ARCH -t $IMAGE +docker build -f Dockerfile --build-arg $BUILD_ARGS $ARCH -t $IMAGE .. +docker tag $IMAGE $IMAGE:$BRANCH +docker tag $IMAGE $IMAGE:$VERSION + +if [ -z "$PUSH" ]; then + echo "Please run 'docker push -t $IMAGE:dev -t $IMAGE:${VERSION}' when ready" +else + for TAG in latest "$VERSION" "$BRANCH"; do + docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG" + docker push -q "$REGISTRY/$IMAGE:$TAG" + done + echo "Images pushed successfully." +fi + + + -# Get version information -VERSION=$(python -c "import sys; sys.path.append('..'); import version; print(version.__version__)") -# Build with version tag -docker build --build-arg BRANCH=dev \ - -t dispatcharr/dispatcharr:dev \ - -t dispatcharr/dispatcharr:${VERSION} \ - -f Dockerfile .. From c51916b40c3427df26bb2424cc8493606fdc25f4 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 12 Dec 2025 08:30:17 -0500 Subject: [PATCH 02/34] Revert "Advanced Filtering" --- CHANGELOG.md | 5 -- apps/channels/api_views.py | 31 +---------- apps/output/views.py | 12 ++-- docker/init/03-init-dispatcharr.sh | 10 +--- .../src/components/tables/ChannelsTable.jsx | 38 ++----------- .../ChannelsTable/ChannelTableHeader.jsx | 55 +------------------ 6 files changed, 17 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e363135f..4716c250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,6 @@ 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 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 40063245..eccc5028 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,7 +8,6 @@ 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, @@ -420,36 +419,10 @@ 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) - 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 + qs = qs.filter(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() + return qs def get_serializer_context(self): context = super().get_serializer_context() diff --git a/apps/output/views.py b/apps/output/views.py index 3a8406cb..bc2bace5 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": str(channel_num_int), - "start_timestamp": str(int(start.timestamp())), - "stop_timestamp": str(int(end.timestamp())), + "channel_id": channel_num_int, + "start_timestamp": int(start.timestamp()), + "stop_timestamp": 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) diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index da7d4484..5fbef23d 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -29,16 +29,8 @@ 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 +sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default # NOTE: mac doesn't run as root, so only manage permissions # if this script is running as root diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 7f82140f..9b9958f7 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, setSelectedTableIds }) => { + ({ rowId, selectedProfileId, selectedTableIds }) => { // Directly extract the channels set once to avoid re-renders on every change. const isEnabled = useChannelsStore( useCallback( @@ -79,20 +79,16 @@ const ChannelEnabledSwitch = React.memo( ) ); - const handleToggle = async () => { + const handleToggle = () => { if (selectedTableIds.length > 1) { - await API.updateProfileChannels( + API.updateProfileChannels( selectedTableIds, selectedProfileId, !isEnabled ); } else { - await API.updateProfileChannel(rowId, selectedProfileId, !isEnabled); + API.updateProfileChannel(rowId, selectedProfileId, !isEnabled); } - - setSelectedTableIds([]); - - return API.requeryChannels(); }; return ( @@ -293,9 +289,6 @@ 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({ @@ -376,15 +369,6 @@ 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) { @@ -417,14 +401,7 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [ - pagination, - sorting, - debouncedFilters, - showDisabled, - selectedProfileId, - showOnlyStreamlessChannels, - ]); + }, [pagination, sorting, debouncedFilters]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -751,7 +728,6 @@ const ChannelsTable = ({}) => { rowId={row.original.id} selectedProfileId={selectedProfileId} selectedTableIds={table.getState().selectedTableIds} - setSelectedTableIds={table.setSelectedTableIds} /> ); }, @@ -1350,10 +1326,6 @@ const ChannelsTable = ({}) => { deleteChannels={deleteChannels} selectedTableIds={table.selectedTableIds} 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 460ab12a..b7e04d7d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -12,22 +12,20 @@ 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'; @@ -104,10 +102,6 @@ const ChannelTableHeader = ({ editChannel, deleteChannels, selectedTableIds, - showDisabled, - setShowDisabled, - showOnlyStreamlessChannels, - setShowOnlyStreamlessChannels, }) => { const theme = useMantineTheme(); @@ -214,14 +208,6 @@ const ChannelTableHeader = ({ ); }; - const toggleShowDisabled = () => { - setShowDisabled(!showDisabled); - }; - - const toggleShowOnlyStreamlessChannels = () => { - setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels); - }; - return ( @@ -250,41 +236,6 @@ const ChannelTableHeader = ({ }} > - - - - - - - : - } - disabled={selectedProfileId === '0'} - > - - {showDisabled ? 'Hide Disabled' : 'Show Disabled'} - - - - - ) : ( - - ) - } - > - Only Empty Channels - - - - + + {version && ( + + v{version} + + )} ); From 944736612bfa0940b16cb98e5f7e49724af3341a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 19 Dec 2025 15:49:18 -0600 Subject: [PATCH 21/34] Bug Fix: M3U profile form resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row --- CHANGELOG.md | 1 + frontend/src/components/forms/M3UProfile.jsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a42843db..2c365565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- M3U Profile form now properly resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row - DVR series rule deletion now properly handles TVG IDs that contain slashes by encoding them in the URL path (Fixes #697) - VOD episode processing now correctly handles duplicate episodes (same episode in multiple languages/qualities) by reusing Episode records across multiple M3UEpisodeRelation entries instead of attempting to create duplicates (Fixes #556) - XtreamCodes series streaming endpoint now correctly handles episodes with multiple streams (different languages/qualities) by selecting the best available stream based on account priority (Fixes #569) diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 353e48d1..b225ec38 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -149,6 +149,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { } resetForm(); + // Reset local state to sync with formik reset + setSearchPattern(''); + setReplacePattern(''); setSubmitting(false); onClose(); }, From f0a9a3fc15889fe304c3682fcce9955282223c92 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 19 Dec 2025 17:00:30 -0600 Subject: [PATCH 22/34] Bug Fix: Docker init script now validates DISPATCHARR_PORT is an integer before using it, preventing sed errors when Kubernetes sets it to a service URL like `tcp://10.98.37.10:80`. Falls back to default port 9191 when invalid (Fixes #737) --- CHANGELOG.md | 1 + docker/init/03-init-dispatcharr.sh | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c365565..3d09a733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Docker init script now validates DISPATCHARR_PORT is an integer before using it, preventing sed errors when Kubernetes sets it to a service URL like `tcp://10.98.37.10:80`. Falls back to default port 9191 when invalid (Fixes #737) - M3U Profile form now properly resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row - DVR series rule deletion now properly handles TVG IDs that contain slashes by encoding them in the URL path (Fixes #697) - VOD episode processing now correctly handles duplicate episodes (same episode in multiple languages/qualities) by reusing Episode records across multiple M3UEpisodeRelation entries instead of attempting to create duplicates (Fixes #556) diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index da7d4484..03fe6816 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -30,6 +30,10 @@ if [ "$(id -u)" = "0" ] && [ -d "/app" ]; then fi fi # Configure nginx port +if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then + echo "⚠️ Warning: DISPATCHARR_PORT is not a valid integer, using default port 9191" + DISPATCHARR_PORT=9191 +fi sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default # Configure nginx based on IPv6 availability From 05b62c22ad7a7ef04f3f46c7af6a04b27bdcb4ed Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 20 Dec 2025 00:08:41 +0000 Subject: [PATCH 23/34] Release v0.15.0 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d09a733..4155bb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0] - 2025-12-20 + ### Added - VOD client stop button in Stats page: Users can now disconnect individual VOD clients from the Stats view, similar to the existing channel client disconnect functionality. diff --git a/version.py b/version.py index 807fc629..07d3d4c7 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.14.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.15.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From ee183a9f753ac1755933de0acf95df98d299c32d Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 19 Dec 2025 18:39:43 -0600 Subject: [PATCH 24/34] Bug Fix: XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency --- CHANGELOG.md | 4 ++++ apps/output/views.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4155bb68..adb9c748 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 + +- XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency + ## [0.15.0] - 2025-12-20 ### Added diff --git a/apps/output/views.py b/apps/output/views.py index c0d72bfb..635bb9d9 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -2326,7 +2326,7 @@ def xc_get_epg(request, user, short=False): 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 18645fc08fbcf442329c32e4d090c0655a0570bd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 22 Dec 2025 16:39:09 -0600 Subject: [PATCH 25/34] Bug Fix: Re-apply failed merge to fix clients that don't have ipv6 support. --- CHANGELOG.md | 1 + docker/init/03-init-dispatcharr.sh | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb9c748..d10635e8 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 - XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency +- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup (Fixes #744) ## [0.15.0] - 2025-12-20 diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index c9eaf18b..03fe6816 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -36,6 +36,14 @@ if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then fi 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 c7590d204e2ca6fc6368696d08dfea66c402712a Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 22 Dec 2025 22:58:41 +0000 Subject: [PATCH 26/34] Release v0.15.1 --- CHANGELOG.md | 2 ++ version.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10635e8..0cb610fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.1] - 2025-12-22 + ### Fixed - XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency diff --git a/version.py b/version.py index 07d3d4c7..714a29fd 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.15.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.15.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process From eea84cfd8b8e9ccd69942f2a5f27536b88a2f8bd Mon Sep 17 00:00:00 2001 From: drnikcuk Date: Mon, 22 Dec 2025 23:33:26 +0000 Subject: [PATCH 27/34] Update Stats.jsx (#773) * Update Stats.jsx Adds fix for stats control arrows direction swap --- frontend/src/pages/Stats.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index e7e3043a..8ec576a8 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -481,8 +481,8 @@ const VODCard = ({ vodContent, stopVODClient }) => { size={16} style={{ transform: isClientExpanded - ? 'rotate(180deg)' - : 'rotate(0deg)', + ? 'rotate(0deg)' + : 'rotate(180deg)', transition: 'transform 0.2s', }} /> From 106ea72c9ddc5d1a62d7f9d9850ff595d9cd3796 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 22 Dec 2025 17:38:55 -0600 Subject: [PATCH 28/34] Changelog: Fix event viewer arrow direction for corrected UI behavior --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb610fa..2e2e9003 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] +### Changed + +- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) + ## [0.15.1] - 2025-12-22 ### Fixed From 904500906ca0f5d843225a3751aa3bf40c3b47d3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 09:51:02 -0600 Subject: [PATCH 29/34] Bug Fix: Update stream validation to return original URL instead of redirected URL when using redirect profile. --- CHANGELOG.md | 4 ++++ apps/proxy/ts_proxy/url_utils.py | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2e9003..a36db70a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +### Fixed + +- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect + ## [0.15.1] - 2025-12-22 ### Fixed diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 3b05c9f2..2afe2871 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -471,7 +471,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # If HEAD not supported, server will return 405 or other error if 200 <= head_response.status_code < 300: # HEAD request successful - return True, head_response.url, head_response.status_code, "Valid (HEAD request)" + return True, url, head_response.status_code, "Valid (HEAD request)" # Try a GET request with stream=True to avoid downloading all content get_response = session.get( @@ -484,7 +484,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # IMPORTANT: Check status code first before checking content if not (200 <= get_response.status_code < 300): logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}") - return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" + return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" # Only check content if status code is valid try: @@ -538,7 +538,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): get_response.close() # If we have content, consider it valid even with unrecognized content type - return is_valid, get_response.url, get_response.status_code, message + return is_valid, url, get_response.status_code, message except requests.exceptions.Timeout: return False, url, 0, "Timeout connecting to stream" From 44a122924fb98ca467d6c159d9708b0d187b40c8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 23 Dec 2025 17:37:38 -0600 Subject: [PATCH 30/34] advanced filtering for hiding disabled channels and viewing only empty channels (cherry picked from commit ea38c0b4b88bac1d89c186f4d17cd9f1dde0ef6d) Closes #182 --- CHANGELOG.md | 4 ++ apps/channels/api_views.py | 33 ++++++++++- .../src/components/tables/ChannelsTable.jsx | 25 ++++++++- .../ChannelsTable/ChannelTableHeader.jsx | 55 ++++++++++++++++++- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36db70a..99784402 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] +### Added + +- Advanced filtering for Channels table: Filter menu now allows toggling disabled channels visibility (when a profile is selected) and filtering to show only empty channels without streams (Closes #182) + ### Changed - Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 1f98358e..aebb74a3 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 urllib.parse import unquote from apps.accounts.permissions import ( @@ -420,10 +421,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() diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 9b9958f7..ee57dabf 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -289,6 +289,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 +372,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 +413,14 @@ const ChannelsTable = ({}) => { pageSize: pagination.pageSize, }); setAllRowIds(ids); - }, [pagination, sorting, debouncedFilters]); + }, [ + pagination, + sorting, + debouncedFilters, + showDisabled, + selectedProfileId, + showOnlyStreamlessChannels, + ]); const stopPropagation = useCallback((e) => { e.stopPropagation(); @@ -1326,6 +1345,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 */} diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index b7e04d7d..460ab12a 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -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 ( @@ -236,6 +250,41 @@ const ChannelTableHeader = ({ }} > + + + + + + + : + } + disabled={selectedProfileId === '0'} + > + + {showDisabled ? 'Hide Disabled' : 'Show Disabled'} + + + + + ) : ( + + ) + } + > + Only Empty Channels + + + +